From a8cb33d12a5a7e209d0e1fc55dd4e0f1aaa0c0f4 Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 2 Jan 2024 10:55:32 +0000 Subject: [PATCH 01/56] Extract validation into Objects --- composer.json | 1 + docs/validation.md | 0 src/Exception/CannotSupport.php | 40 +++ src/Exception/InvalidOpenAPI.php | 191 ++++++++++++- src/Factory/V30/FromCebe.php | 142 ++++++++++ src/In.php | 13 + src/Method.php | 10 +- src/Reader.php | 46 +--- src/Style.php | 16 ++ src/ValueObject/Partial/MediaType.php | 14 + src/ValueObject/Partial/OpenAPI.php | 22 ++ src/ValueObject/Partial/Operation.php | 18 ++ src/ValueObject/Partial/Parameter.php | 22 ++ src/ValueObject/Partial/PathItem.php | 20 ++ src/ValueObject/Partial/Schema.php | 21 ++ src/ValueObject/Valid/HasIdentifier.php | 10 + src/ValueObject/Valid/HasWarnings.php | 12 + src/ValueObject/Valid/Identifier.php | 39 +++ src/ValueObject/Valid/V30/MediaType.php | 25 ++ src/ValueObject/Valid/V30/OpenAPI.php | 132 +++++++++ src/ValueObject/Valid/V30/Operation.php | 122 +++++++++ src/ValueObject/Valid/V30/Parameter.php | 161 +++++++++++ src/ValueObject/Valid/V30/PathItem.php | 137 ++++++++++ src/ValueObject/Valid/V30/Schema.php | 96 +++++++ src/ValueObject/Valid/Validated.php | 47 ++++ src/ValueObject/Valid/Warning.php | 19 ++ src/ValueObject/Valid/Warnings.php | 29 ++ tests/Factory/V30/FromCebeTest.php | 161 +++++++++++ tests/ReaderTest.php | 237 ++++++++++++++--- tests/ValueObject/IdentifierTest.php | 107 ++++++++ tests/ValueObject/Valid/V30/MediaTypeTest.php | 49 ++++ tests/ValueObject/Valid/V30/OpenAPITest.php | 146 ++++++++++ tests/ValueObject/Valid/V30/OperationTest.php | 199 ++++++++++++++ tests/ValueObject/Valid/V30/ParameterTest.php | 240 +++++++++++++++++ tests/ValueObject/Valid/V30/PathItemTest.php | 250 ++++++++++++++++++ tests/ValueObject/Valid/V30/SchemaTest.php | 160 +++++++++++ tests/ValueObject/WarningsTest.php | 54 ++++ tests/fixtures/Helper/PartialHelper.php | 104 ++++++++ tests/fixtures/petstore.yaml | 6 + 39 files changed, 3034 insertions(+), 84 deletions(-) create mode 100644 docs/validation.md create mode 100644 src/Factory/V30/FromCebe.php create mode 100644 src/In.php create mode 100644 src/Style.php create mode 100644 src/ValueObject/Partial/MediaType.php create mode 100644 src/ValueObject/Partial/OpenAPI.php create mode 100644 src/ValueObject/Partial/Operation.php create mode 100644 src/ValueObject/Partial/Parameter.php create mode 100644 src/ValueObject/Partial/PathItem.php create mode 100644 src/ValueObject/Partial/Schema.php create mode 100644 src/ValueObject/Valid/HasIdentifier.php create mode 100644 src/ValueObject/Valid/HasWarnings.php create mode 100644 src/ValueObject/Valid/Identifier.php create mode 100644 src/ValueObject/Valid/V30/MediaType.php create mode 100644 src/ValueObject/Valid/V30/OpenAPI.php create mode 100644 src/ValueObject/Valid/V30/Operation.php create mode 100644 src/ValueObject/Valid/V30/Parameter.php create mode 100644 src/ValueObject/Valid/V30/PathItem.php create mode 100644 src/ValueObject/Valid/V30/Schema.php create mode 100644 src/ValueObject/Valid/Validated.php create mode 100644 src/ValueObject/Valid/Warning.php create mode 100644 src/ValueObject/Valid/Warnings.php create mode 100644 tests/Factory/V30/FromCebeTest.php create mode 100644 tests/ValueObject/IdentifierTest.php create mode 100644 tests/ValueObject/Valid/V30/MediaTypeTest.php create mode 100644 tests/ValueObject/Valid/V30/OpenAPITest.php create mode 100644 tests/ValueObject/Valid/V30/OperationTest.php create mode 100644 tests/ValueObject/Valid/V30/ParameterTest.php create mode 100644 tests/ValueObject/Valid/V30/PathItemTest.php create mode 100644 tests/ValueObject/Valid/V30/SchemaTest.php create mode 100644 tests/ValueObject/WarningsTest.php create mode 100644 tests/fixtures/Helper/PartialHelper.php diff --git a/composer.json b/composer.json index 2e3d9ab..facc647 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ }, "autoload-dev": { "psr-4": { + "Membrane\\OpenAPIReader\\Tests\\Fixtures\\Helper\\": "tests/fixtures/Helper", "Membrane\\OpenAPIReader\\Tests\\Fixtures\\": "tests/fixtures/", "Membrane\\OpenAPIReader\\Tests\\": "tests/" } diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..e69de29 diff --git a/src/Exception/CannotSupport.php b/src/Exception/CannotSupport.php index 91087ff..8320bc8 100644 --- a/src/Exception/CannotSupport.php +++ b/src/Exception/CannotSupport.php @@ -4,16 +4,21 @@ namespace Membrane\OpenAPIReader\Exception; +use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use RuntimeException; /* * This exception occurs if your Open API is readable but cannot be supported by Membrane. */ + final class CannotSupport extends RuntimeException { public const UNSUPPORTED_METHOD = 0; public const UNSUPPORTED_VERSION = 1; public const MISSING_OPERATION_ID = 2; + public const MISSING_TYPE_DECLARATION = 3; + public const AMBIGUOUS_RESOLUTION = 4; + public const CANNOT_PARSE = 4; public static function unsupportedMethod(string $pathUrl, string $method): self { @@ -46,4 +51,39 @@ public static function missingOperationId(string $pathUrl, string $method): self TEXT; return new self($message, self::MISSING_OPERATION_ID); } + + public static function undeclaredType(Identifier $identifier) + { + $message = << Media Type Object pairs + TEXT; + + return new self ($message); + } + public static function failedCebeValidation(string ...$errors): self { $message = sprintf("OpenAPI is invalid for the following reasons:\n\t- %s", implode("\n\t- ", $errors)); - return new self($message, self::INVALID_OPEN_API); + return new self($message); + } + + public static function emptyComplexSchema(Identifier $identifier): self + { + $message = <<openapi, + $openApi->info?->title, // @phpstan-ignore-line + $openApi->info?->version, // @phpstan-ignore-line + self::createPaths($openApi->paths) + )); + } + + /** + * @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, + self::createParameters($pathItem->parameters), + self::createOperations($pathItem->getOperations()), + ); + } + + 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 { + 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( + $schema->type, + isset($schema->allOf) ? $createSchemas($schema->allOf) : null, + isset($schema->anyOf) ? $createSchemas($schema->anyOf) : null, + isset($schema->oneOf) ? $createSchemas($schema->oneOf) : 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; + } + + /** + * @param array $operations + * @return Operation[] + */ + private static function createOperations(array $operations): array + { + $result = []; + + foreach ($operations as $method => $operation) { + $result[] = new Operation( + $method, + $operation->operationId, + self::createParameters($operation->parameters) + ); + } + + return $result; + } +} diff --git a/src/In.php b/src/In.php new file mode 100644 index 0000000..8caf5f5 --- /dev/null +++ b/src/In.php @@ -0,0 +1,13 @@ +isVersionSupported($openAPI->openapi) ?: throw CannotSupport::unsupportedVersion($openAPI->openapi); - $existingOperationIds = []; - - // OpenAPI Version 3.1 does not require paths - if (isset($openAPI->paths)) { - foreach ($openAPI->paths as $pathUrl => $path) { - $this->parametersContainSchemaXorContent($path); - - foreach ($path->getOperations() as $method => $operation) { - $this->parametersContainSchemaXorContent($operation); - - Method::tryFrom($method) !== null ?: throw CannotSupport::unsupportedMethod($pathUrl, $method); - - isset($operation->operationId) ?: throw CannotSupport::missingOperationId($pathUrl, $method); - - if (isset($existingOperationIds[$operation->operationId])) { - throw InvalidOpenAPI::duplicateOperationIds( - $operation->operationId, - $existingOperationIds[$operation->operationId]['path'], - $existingOperationIds[$operation->operationId]['method'], - $pathUrl, - $method - ); - } - $existingOperationIds[$operation->operationId] = ['path' => $pathUrl, 'method' => $method]; - } - } - } + FromCebe::createOpenAPI($openAPI); $openAPI->validate() ?: throw InvalidOpenAPI::failedCebeValidation(...$openAPI->getErrors()); } @@ -109,21 +84,4 @@ private function isVersionSupported(string $version): bool { return in_array(OpenAPIVersion::fromString($version), $this->supportedVersions, true); } - - private function parametersContainSchemaXorContent(CebeSpec\PathItem | CebeSpec\Operation $specObject): void - { - foreach ($specObject->parameters as $parameter) { - assert($parameter instanceof CebeSpec\Parameter); - - $result = isset($parameter->schema); - - if (!empty($parameter->content)) { - $result = !$result; - } - - if (!$result) { - throw InvalidOpenAPI::mustHaveSchemaXorContent($parameter->name); - } - } - } } diff --git a/src/Style.php b/src/Style.php new file mode 100644 index 0000000..dda2168 --- /dev/null +++ b/src/Style.php @@ -0,0 +1,16 @@ +chain = [$field, ...$fields]; + } + + public function append(string $primaryId, string $secondaryId = ''): self + { + $field = sprintf( + '%s%s', + $primaryId, + $secondaryId === '' ? '' : "($secondaryId)" + ); + + return new self(...[...$this->chain, $field]); + } + + public function fromEnd(int $level): ?string + { + return array_reverse($this->chain)[$level] ?? null; + } + + public function __toString() + { + return sprintf('["%s"]', implode('"]["', $this->chain)); + } +} diff --git a/src/ValueObject/Valid/V30/MediaType.php b/src/ValueObject/Valid/V30/MediaType.php new file mode 100644 index 0000000..1083517 --- /dev/null +++ b/src/ValueObject/Valid/V30/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/V30/OpenAPI.php b/src/ValueObject/Valid/V30/OpenAPI.php new file mode 100644 index 0000000..080f997 --- /dev/null +++ b/src/ValueObject/Valid/V30/OpenAPI.php @@ -0,0 +1,132 @@ + + */ + private readonly array $paths; + + public function __construct(Partial\OpenAPI $openAPI) + { + if (!isset($openAPI->title) || !isset($openAPI->version)) { + throw InvalidOpenAPI::missingInfo(); + } + + parent::__construct(new Identifier("$openAPI->title($openAPI->version)")); + + if (!isset($openAPI->openAPI)) { + throw InvalidOpenAPI::missingOpenAPIVersion($this->getIdentifier()); + } + + if (empty($openAPI->paths)) { + $this->addWarning('No Paths in OpenAPI', Warning::EMPTY_PATHS); + } + + $this->paths = $this->getPaths($openAPI->paths); + } + + /** + * @param Partial\PathItem[] $pathItems + * @return array + */ + private function getPaths(array $pathItems): array + { + $result = []; + foreach ($pathItems as $pathItem) { + if (!isset($pathItem->path)) { + throw InvalidOpenAPI::pathMissingEndPoint($this->getIdentifier()); + } + + if (!str_starts_with($pathItem->path, '/')) { + throw InvalidOpenAPI::forwardSlashMustPrecedePath( + $this->getIdentifier(), + $pathItem->path + ); + } + if (isset($result[$pathItem->path])) { + throw InvalidOpenAPI::identicalEndpoints( + $result[$pathItem->path]->getIdentifier() + ); + } + + $result[$pathItem->path] = new PathItem( + $this->getIdentifier()->append($pathItem->path), + $pathItem + ); + } + + $this->checkForEquivalentPathTemplates($result); + $this->checkForDuplicatedOperationIds($result); + + return $result; + } + + /** + * @param array $pathItems + */ + private function checkForEquivalentPathTemplates(array $pathItems): void + { + $regexToIdentifier = []; + foreach ($pathItems as $path => $pathItem) { + $regex = $this->getPathRegex($path); + + if (isset($regexToIdentifier[$regex])) { + throw InvalidOpenAPI::equivalentTemplates( + $regexToIdentifier[$regex], + $pathItem->getIdentifier() + ); + } + + $regexToIdentifier[$regex] = $pathItem->getIdentifier(); + } + } + + /** + * @param PathItem[] $paths + */ + private 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 function getPathRegex(string $path): string + { + $pattern = preg_replace('#{[^/]+}#', '{([^/]+)}', $path); + + if (!is_string($pattern)) { + throw InvalidOpenAPI::malformedUrl($this->getIdentifier(), $path); + } + + return $pattern; + } +} diff --git a/src/ValueObject/Valid/V30/Operation.php b/src/ValueObject/Valid/V30/Operation.php new file mode 100644 index 0000000..3529136 --- /dev/null +++ b/src/ValueObject/Valid/V30/Operation.php @@ -0,0 +1,122 @@ +operationId = $operation->operationId ?? + throw CannotSupport::missingOperationId( + $parentIdentifier->fromEnd(0), + $operation->method, + ); + + parent::__construct($parentIdentifier->append("$this->operationId($operation->method)")); + + $this->parameters = $this->mergeParameters( + $pathParameters, + $operation->parameters + ); + + $parametersThatCanConflict = array_filter($this->parameters, fn($p) => $this->canParameterConflict($p)); + if (count($parametersThatCanConflict) > 1) { + throw CannotSupport::conflictingParameterStyles( + ...array_map(fn($p) => (string)$p->getIdentifier(), $parametersThatCanConflict) + ); + } + } + + /** + * @param Parameter[] $pathParameters + * @param Partial\Parameter[] $operationParameters + * @return Parameter[] + */ + private function mergeParameters( + array $pathParameters, + array $operationParameters + ): array { + $result = array_map( + fn($p) => new Parameter($this->getIdentifier(), $p), + $operationParameters + ); + + foreach ($pathParameters as $pathParameter) { + if (!$this->isIdenticalParameterInList($pathParameter, $result)) { + $result[] = $pathParameter; + } + } + + foreach (array_values($result) as $index => $parameter) { + if ($this->isIdenticalParameterInList($parameter, array_slice(array_values($result), $index + 1))) { + throw InvalidOpenAPI::duplicateParameters( + $this->getIdentifier(), + $parameter->name, + $parameter->in->value + ); + } + } + + return $result; + } + + /** @param Parameter[] $otherParameters */ + private function isIdenticalParameterInList( + Parameter $parameter, + array $otherParameters + ): bool { + foreach ($otherParameters as $otherParameter) { + if (!$this->isParameterUnique($parameter, $otherParameter)) { + return true; + } + } + return false; + } + + private function isParameterUnique( + Parameter $parameter, + Parameter $otherParameter + ): bool { + return $parameter->name !== $otherParameter->name || + $parameter->in !== $otherParameter->in; + } + + + + private function canParameterConflict(Parameter $parameter): bool + { + if ($parameter->in !== In::Query) { + return false; + } + + $canBeObject = $parameter->getSchema()->canItBeAnObject(); + $canBeArray = $parameter->getSchema()->canItBeAnArray(); + + return match ($parameter->style) { + Style::Form => $canBeObject && $parameter->explode, + Style::PipeDelimited, Style::SpaceDelimited => $canBeObject || $canBeArray, + default => false, + }; + } +} diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php new file mode 100644 index 0000000..e1590d6 --- /dev/null +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -0,0 +1,161 @@ + */ + public readonly array $content; + + public function __construct(Identifier $parentIdentifier, Partial\Parameter $parameter) + { + if (!isset($parameter->name)) { + throw InvalidOpenAPI::parameterMissingName($parentIdentifier); + } + $this->name = $parameter->name; + + if (!isset($parameter->in)) { + throw InvalidOpenAPI::parameterMissingLocation($parentIdentifier); + } + + parent::__construct($parentIdentifier->append($parameter->name, $parameter->in)); + + $this->in = In::tryFrom($parameter->in) ?? + throw InvalidOpenAPI::parameterInvalidLocation($this->getIdentifier()); + + if ($this->in === In::Path && $parameter->required !== true) { + throw InvalidOpenAPI::parameterMissingRequired($this->getIdentifier()); + } + + if (!isset($parameter->style)) { + $this->style = $this->defaultStyle($this->in); + } elseif (Style::tryFrom($parameter->style) === null) { + throw InvalidOpenAPI::parameterInvalidStyle($this->getIdentifier()); + } else { + $this->style = Style::from($parameter->style); + } + + if (!$this->styleIsValid($this->in, $this->style)) { + throw InvalidOpenAPI::parameterIncompatibleStyle($this->getIdentifier()); + } + + $this->explode = $parameter->explode ?? $this->defaultExplode($this->style); + + if (isset($parameter->schema) !== empty($parameter->content)) { + throw InvalidOpenAPI::mustHaveSchemaXorContent($parameter->name); + } + + if (isset($parameter->schema)) { + $this->schema = new Schema( + $this->appendedIdentifier('schema'), + $parameter->schema + ); + $this->content = []; + } else { + $this->schema = null; + + $this->content = $this->getContent( + $this->getIdentifier(), + $parameter->name, + $parameter->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 defaultStyle(In $in): Style + { + return match ($in) { + In::Path, In::Header => Style::Simple, + In::Query, In::Cookie => Style::Form, + }; + } + + private function defaultExplode(Style $style): bool + { + return $style === Style::Form; + } + + private function styleIsValid(In $in, Style $style): bool + { + return in_array( + $style, + match ($in) { + In::Path => [Style::Matrix, Style::Label, Style::Simple], + In::Query => [Style::Form, Style::SpaceDelimited, Style::PipeDelimited, Style::DeepObject], + In::Header => [Style::Simple], + In::Cookie => [Style::Form], + }, + true + ); + } + + /** + * @param array $content + * @return array + */ + private function getContent( + Identifier $identifier, + string $name, + array $content, + ): array { + if (count($content) !== 1) { + throw InvalidOpenAPI::parameterContentCanOnlyHaveOneEntry($this->getIdentifier()); + } + + if (!isset($content[0]->contentType)) { + throw InvalidOpenAPI::contentMissingMediaType($identifier); + } + + if (!isset($content[0]->schema)) { + throw InvalidOpenAPI::mustHaveSchemaXorContent($name); + } + + return [ + $content[0]->contentType => new MediaType( + $this->appendedIdentifier($content[0]->contentType), + $content[0] + ), + ]; + } +} diff --git a/src/ValueObject/Valid/V30/PathItem.php b/src/ValueObject/Valid/V30/PathItem.php new file mode 100644 index 0000000..1da2d65 --- /dev/null +++ b/src/ValueObject/Valid/V30/PathItem.php @@ -0,0 +1,137 @@ + + */ + private readonly array $operations; + 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; + + public function __construct( + Identifier $parentIdentifier, + Partial\PathItem $pathItem, + ) { + parent::__construct($parentIdentifier); + + $this->parameters = $this->validateParameters($pathItem->parameters); + + $this->operations = $this->validateOperations($pathItem->operations); + $this->get = $this->operations[Method::GET->value] ?? null; + $this->put = $this->operations[Method::PUT->value] ?? null; + $this->post = $this->operations[Method::POST->value] ?? null; + $this->delete = $this->operations[Method::DELETE->value] ?? null; + $this->options = $this->operations[Method::OPTIONS->value] ?? null; + $this->head = $this->operations[Method::HEAD->value] ?? null; + $this->patch = $this->operations[Method::PATCH->value] ?? null; + $this->trace = $this->operations[Method::TRACE->value] ?? null; + } + + /** + * Operation "method" keys mapped to Operation values + * @return array + */ + public function getOperations(): array + { + return $this->operations; + } + + /** + * @param Partial\Parameter[] $parameters + * @return Parameter[] + */ + private function validateParameters(array $parameters): array + { + $result = array_map( + fn($p) => new Parameter($this->getIdentifier(), $p), + $parameters + ); + + foreach (array_values($result) as $index => $parameter) { + foreach (array_slice($result, $index + 1) as $otherParameter) { + if ( + strcmp($parameter->name, $otherParameter->name) === 0 && + $parameter->in === $otherParameter->in + ) { + throw InvalidOpenAPI::duplicateParameters( + $this->getIdentifier(), + $parameter->name, + $parameter->in->value + ); + } + + if (strcasecmp($parameter->name, $otherParameter->name) === 0) { + $this->addWarning( + <<name + this may lead to confusion.', + TEXT, + Warning::SIMILAR_NAMES + ); + } + } + } + + return $result; + } + + /** + * @param Partial\Operation[] $partialOperations + * @return array + */ + private function validateOperations(array $partialOperations): array + { + $result = []; + + if (empty($partialOperations)) { + $this->addWarning('No Operations on Path', Warning::EMPTY_PATH); + } + + foreach ($partialOperations as $partialOperation) { + $method = Method::tryFrom($partialOperation->method); + if ($method === null) { + throw InvalidOpenAPI::unrecognisedMethod( + $this->getIdentifier(), + $partialOperation->method + ); + } + + if ($method->isRedundant()) { + $this->addWarning( + "$method->value is redundant in an OpenAPI Specification.", + Warning::REDUNDANT_METHOD, + ); + } + + $result[$method->value] = new Operation( + $this->getIdentifier(), + $this->parameters, + $partialOperation + ); + } + + return $result; + } +} diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php new file mode 100644 index 0000000..cdf5d93 --- /dev/null +++ b/src/ValueObject/Valid/V30/Schema.php @@ -0,0 +1,96 @@ +type = $schema->type ?? null; + + if (isset($schema->allOf)) { + if (empty($schema->allOf)) { + throw InvalidOpenAPI::emptyComplexSchema($this->getIdentifier()); + } + $this->allOf = $this->getSubSchemas('allOf', $schema->allOf); + } else { + $this->allOf = null; + } + + if (isset($schema->anyOf)) { + if (empty($schema->anyOf)) { + throw InvalidOpenAPI::emptyComplexSchema($this->getIdentifier()); + } + $this->anyOf = $this->getSubSchemas('anyOf', $schema->anyOf); + } else { + $this->anyOf = null; + } + + if (isset($schema->oneOf)) { + if (empty($schema->oneOf)) { + throw InvalidOpenAPI::emptyComplexSchema($this->getIdentifier()); + } + $this->oneOf = $this->getSubSchemas('oneOf', $schema->oneOf); + } else { + $this->oneOf = null; + } + } + + /** + * @param Partial\Schema[] $subSchemas + * @return self[] + */ + private function getSubSchemas(string $keyword, array $subSchemas): array + { + $result = []; + foreach ($subSchemas as $index => $subSchema) { + $result[] = new Schema( + $this->getIdentifier()->append("$keyword($index)"), + $subSchema + ); + } + return $result; + } + + public function canItBeAnObject(): bool + { + return $this->canItBeThisType('object'); + } + + public function canItBeAnArray(): bool + { + return $this->canItBeThisType('array'); + } + + private function canItBeThisType(string $type): bool + { + if ($this->type === $type) { + return true; + } + + return array_reduce( + [...($this->allOf ?? []), ...($this->anyOf ?? []), ...($this->oneOf ?? [])], + fn($v, Schema $schema) => $v || $schema->canItBeThisType($type), + false + ); + } +} diff --git a/src/ValueObject/Valid/Validated.php b/src/ValueObject/Valid/Validated.php new file mode 100644 index 0000000..785dfb5 --- /dev/null +++ b/src/ValueObject/Valid/Validated.php @@ -0,0 +1,47 @@ +parentIdentifier; + } + + protected function appendedIdentifier( + string $primaryId, + string $secondaryId = '' + ): Identifier { + return $this->parentIdentifier->append($primaryId, $secondaryId); + } + + + public function hasWarnings(): bool + { + return isset($this->warnings); + } + + public function getWarnings(): Warnings + { + if (!isset($this->warnings)) { + $this->warnings = new Warnings($this->parentIdentifier); + } + + return $this->warnings; + } + + protected function addWarning(string $message, string $code): void + { + $this->getWarnings()->add($message, $code); + } +} diff --git a/src/ValueObject/Valid/Warning.php b/src/ValueObject/Valid/Warning.php new file mode 100644 index 0000000..94ba2be --- /dev/null +++ b/src/ValueObject/Valid/Warning.php @@ -0,0 +1,19 @@ +warnings = $warnings; + } + + public function add(string $message, string $code): void + { + $this->warnings[] = new Warning($message, $code); + } + + /** @return Warning[] */ + public function all(): array + { + return $this->warnings; + } +} diff --git a/tests/Factory/V30/FromCebeTest.php b/tests/Factory/V30/FromCebeTest.php new file mode 100644 index 0000000..80c04c8 --- /dev/null +++ b/tests/Factory/V30/FromCebeTest.php @@ -0,0 +1,161 @@ + [ + new OpenAPI(PartialHelper::createOpenAPI( + openapi: '3.0.0', + title: 'Test API', + version: '1.0.1', + paths: [] + )), + new Cebe\OpenApi([ + 'openapi' => '3.0.0', + 'info' => ['title' => 'Test API', 'version' => '1.0.1'], + 'paths' => [], + ]) + ]; + + yield 'detailed OpenAPI' => [ + new OpenAPI(PartialHelper::createOpenAPI( + openapi: '3.0.0', + title: 'Test API', + version: '1.0.1', + paths: [ + PartialHelper::createPathItem( + path: '/first', + parameters: [ + PartialHelper::createParameter( + name: 'limit', + in: 'query', + required: false, + schema: PartialHelper::createSchema( + type: 'integer' + ) + ), + ], + operations: [ + PartialHelper::createOperation( + operationId: 'test-id', + method: 'get', + parameters: [ + PartialHelper::createParameter( + name: 'pet', + in: 'header', + required: true, + schema: null, + content: [ + PartialHelper::createMediaType( + mediaType: 'application/json', + schema: PartialHelper::createSchema( + allOf: [ + PartialHelper::createSchema( + type: 'integer' + ), + PartialHelper::createSchema( + type: 'number' + ) + ] + ) + ) + ] + ) + ] + ) + ] + ) + ] + )), + new Cebe\OpenApi([ + 'openapi' => '3.0.0', + 'info' => ['title' => 'Test API', 'version' => '1.0.1'], + 'paths' => [ + '/first' => [ + 'parameters' => [ + [ + 'name' => 'limit', + 'in' => 'query', + 'required' => false, + 'schema' => ['type' => 'integer'] + ] + ], + 'get' => [ + 'operationId' => 'test-id', + 'parameters' => [ + [ + 'name' => 'pet', + 'in' => 'header', + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'allOf' => [ + ['type' => 'integer'], + ['type' => 'number'] + ] + ] + ] + ] + ] + ] + ] + ] + ], + ]) + ]; + } +} diff --git a/tests/ReaderTest.php b/tests/ReaderTest.php index 742c3a4..b4e0090 100644 --- a/tests/ReaderTest.php +++ b/tests/ReaderTest.php @@ -4,11 +4,15 @@ namespace Membrane\OpenAPIReader\Tests; -use cebe\{openapi as Cebe, openapi\exceptions as CebeException, openapi\spec as CebeSpec}; +use cebe\{openapi\exceptions as CebeException, openapi\spec as CebeSpec}; use Generator; -use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; use Membrane\OpenAPIReader\{FileFormat, Method, OpenAPIVersion}; +use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; +use Membrane\OpenAPIReader\Factory\V30\FromCebe; use Membrane\OpenAPIReader\Reader; +use Membrane\OpenAPIReader\ValueObject\Partial; +use Membrane\OpenAPIReader\ValueObject\Valid; +use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test, TestDox, UsesClass}; use PHPUnit\Framework\TestCase; @@ -17,6 +21,22 @@ #[CoversClass(Reader::class)] #[CoversClass(CannotRead::class), CoversClass(CannotSupport::class), CoversClass(InvalidOpenAPI::class)] #[UsesClass(FileFormat::class), UsesClass(Method::class), UsesClass(OpenAPIVersion::class)] +#[UsesClass(FromCebe::class)] +#[UsesClass(Identifier::class)] +#[UsesClass(Partial\OpenAPI::class)] +#[UsesClass(Valid\V30\OpenAPI::class)] +#[UsesClass(Partial\Operation::class)] +#[UsesClass(Valid\V30\Operation::class)] +#[UsesClass(Partial\PathItem::class)] +#[UsesClass(Valid\V30\PathItem::class)] +#[UsesClass(Partial\Parameter::class)] +#[UsesClass(Valid\V30\Parameter::class)] +#[UsesClass(Partial\MediaType::class)] +#[UsesClass(Partial\Schema::class)] +#[UsesClass(Valid\V30\Schema::class)] +#[UsesClass(Valid\Validated::class)] +#[UsesClass(Valid\Warning::class)] +#[UsesClass(Valid\Warnings::class)] class ReaderTest extends TestCase { private string $petstorePath = __DIR__ . '/fixtures/petstore.yaml'; @@ -173,7 +193,7 @@ public static function provideInvalidOpenAPIs(): Generator $jsonOpenAPIString = json_encode(['openapi' => '3.0.0']); return [ $jsonOpenAPIString, - InvalidOpenAPI::failedCebeValidation(...Cebe\Reader::readFromJson($jsonOpenAPIString)->getErrors()), + InvalidOpenAPI::missingInfo(), ]; })(); @@ -183,6 +203,23 @@ public static function provideInvalidOpenAPIs(): Generator 'responses' => [200 => ['description' => ' Successful Response']], ]; + yield 'paths with same template' => [ + (function () use ($openAPI, $openAPIPath) { + $openAPIArray = $openAPI; + $openAPIArray['paths']['/path/{param1}'] = [ + 'get' => $openAPIPath('id-1'), + ]; + $openAPIArray['paths']['/path/{param2}'] = [ + 'get' => $openAPIPath('id-2'), + ]; + return json_encode($openAPIArray); + })(), + InvalidOpenAPI::equivalentTemplates( + new Identifier('(1.0.0)', '/path/{param1}'), + new Identifier('(1.0.0)', '/path/{param2}'), + ), + ]; + yield 'duplicate operationIds on the same path' => [ (function () use ($openAPI, $openAPIPath) { $openAPIArray = $openAPI; @@ -289,38 +326,6 @@ public function itWillNotProcessInvalidOpenAPIFromString(string $openAPIString, ->readFromString($openAPIString, FileFormat::Json); } - public static function provideUnsupportedMethods(): Generator - { - yield 'HEAD' => ['head']; - yield 'OPTIONS' => ['options']; - yield 'TRACE' => ['trace']; - } - - #[Test, TestDox('It only supports cases of the Method Enum')] - #[DataProvider('provideUnsupportedMethods')] - public function itCannotSupportUnsupportedMethods(string $method): void - { - self::assertNull(Method::tryFrom($method)); - - $openAPIString = json_encode([ - 'openapi' => '3.0.0', - 'info' => ['title' => '', 'version' => '1.0.0'], - 'paths' => [ - '/path' => [ - $method => [ - 'operationId' => 'test-id', - 'responses' => [200 => ['description' => ' Successful Response']], - ], - ], - ], - ]); - - self::expectExceptionObject(CannotSupport::unsupportedMethod('/path', $method)); - - (new Reader([OpenAPIVersion::Version_3_0])) - ->readFromString($openAPIString, FileFormat::Json); - } - #[Test, TestDox('Membrane requires operationIds for caching and routing')] public function itCannotSupportMissingOperationIds(): void { @@ -487,4 +492,166 @@ public function itCannotResolveInvalidReferenceFromString(string $openAPIString, (new Reader([OpenAPIVersion::Version_3_0])) ->readFromString($openAPIString, FileFormat::Json); } + + public static function provideConflictingParameters(): Generator + { + $openAPI = fn(array $path) => [ + 'openapi' => '3.0.0', + 'info' => ['title' => 'test-api', 'version' => '1.0.0'], + 'paths' => ['/path' => $path] + ]; + + $path = fn(array $parameters, array $operation) => [ + 'parameters' => $parameters, + 'get' => $operation, + ]; + + $operation = fn(array $data) => [ + ...$data, + 'responses' => [200 => ['description' => ' Successful Response']] + ]; + + yield 'operation with two spaceDelimited exploding arrays' => [ + json_encode( + $openAPI($path( + [], + $operation([ + 'operationId' => 'test-op', + 'parameters' => [ + [ + 'name' => 'param1', + 'in' => 'query', + 'explode' => true, + 'style' => 'spaceDelimited', + 'schema' => ['type' => 'array'], + ], + [ + 'name' => 'param2', + 'in' => 'query', + 'explode' => true, + 'style' => 'spaceDelimited', + 'schema' => ['type' => 'array'], + ] + ], + ]) + )) + ), + CannotSupport::conflictingParameterStyles( + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param1(query)"]', + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', + ) + ]; + + yield 'operation with spaceDelimited and pipeDelimited exploding arrays' => [ + json_encode( + $openAPI($path( + [], + $operation([ + 'operationId' => 'test-op', + 'parameters' => [ + [ + 'name' => 'param1', + 'in' => 'query', + 'explode' => true, + 'style' => 'spaceDelimited', + 'schema' => ['type' => 'array'], + ], + [ + 'name' => 'param2', + 'in' => 'query', + 'explode' => true, + 'style' => 'pipeDelimited', + 'schema' => ['type' => 'array'], + ] + ], + ]) + )) + ), + CannotSupport::conflictingParameterStyles( + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param1(query)"]', + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', + ) + ]; + + yield 'spaceDelimited exploding in path, pipeDelimited exploding in query' => [ + json_encode( + $openAPI($path( + [ + [ + 'name' => 'param1', + 'in' => 'query', + 'explode' => true, + 'style' => 'spaceDelimited', + 'schema' => ['type' => 'array'], + ], + ], + $operation([ + 'operationId' => 'test-op', + 'parameters' => [ + [ + 'name' => 'param2', + 'in' => 'query', + 'explode' => true, + 'style' => 'pipeDelimited', + 'schema' => ['type' => 'array'], + ] + ], + ]) + )) + ), + CannotSupport::conflictingParameterStyles( + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', + '["test-api(1.0.0)"]["/path"]["param1(query)"]', + ) + ]; + + yield 'form exploding object in path, pipeDelimited exploding in query' => [ + json_encode( + $openAPI($path( + [ + [ + 'name' => 'param1', + 'in' => 'query', + 'explode' => true, + 'style' => 'form', + 'schema' => ['type' => 'object'], + ], + ], + $operation([ + 'operationId' => 'test-op', + 'parameters' => [ + [ + 'name' => 'param2', + 'in' => 'query', + 'explode' => true, + 'style' => 'pipeDelimited', + 'schema' => ['type' => 'array'], + ] + ], + ]) + )) + ), + CannotSupport::conflictingParameterStyles( + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', + '["test-api(1.0.0)"]["/path"]["param1(query)"]', + ) + ]; + } + + #[Test, DataProvider('provideConflictingParameters')] + #[TestDox('It cannot support multiple parameters with the potential to conflict.')] + public function itCannotSupportAmbiguousResolution( + string $openAPIString, + CannotSupport $expected + ): void { + $filePath = vfsStream::setup()->url() . '/openapi.json'; + file_put_contents($filePath, $openAPIString); + + self::expectExceptionObject($expected); + + (new Reader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath($filePath, FileFormat::Json); + + + } } diff --git a/tests/ValueObject/IdentifierTest.php b/tests/ValueObject/IdentifierTest.php new file mode 100644 index 0000000..3655716 --- /dev/null +++ b/tests/ValueObject/IdentifierTest.php @@ -0,0 +1,107 @@ + ['api']; + yield 'two strings' => ['api', '/path']; + yield 'three strings' => ['api', '/path', 'get']; + + + } + + #[Test, DataProvider('provideArraysOfStringsToConstructFrom')] + #[TestDox('it returns a new instance of itself with a single string appended to the end of the chain')] + public function itCanBeAppended(string ...$chain): void + { + $stringToAppend = 'appended'; + $expected = new Identifier(...[...$chain, $stringToAppend]); + + $sut = new Identifier(...$chain); + + $actual = $sut->append($stringToAppend); + + self::assertEquals($expected, $actual); + } + + public static function provideChainsToCastToString(): Generator + { + yield 'single field' => [ + '["field1"]', + ['field1'], + ]; + + yield 'three fields' => [ + '["field1"]["field2"]["field3"]', + ['field1', 'field2', 'field3'] + ]; + } + + /** @param string[] $chain */ + #[Test, DataProvider('provideChainsToCastToString')] + public function itCanBeCastToString(string $expected, array $chain): void + { + $sut = new Identifier(...$chain); + + self::assertSame($expected, (string)$sut); + } + + public static function provideChainsToSearchThrough(): Generator + { + yield 'single field, first field from end' => [ + 'field1', + 0, + ['field1'] + ]; + + yield 'single field, second field from end which should be null' => [ + null, + 1, + ['field1'] + ]; + + yield 'three fields, pick the first from end' => [ + 'field3', + 0, + ['field1', 'field2', 'field3'] + ]; + + yield 'three fields, pick the second from end' => [ + 'field2', + 1, + ['field1', 'field2', 'field3'] + ]; + + yield 'three fields, pick the third from end' => [ + 'field1', + 2, + ['field1', 'field2', 'field3'] + ]; + } + + /** @param string[] $chain */ + #[Test, DataProvider('provideChainsToSearchThrough')] + public function itGetsStringsFromEndOfChain( + ?string $expected, + int $index, + array $chain, + ): void { + $sut = new Identifier(...$chain); + + self::assertSame($expected, $sut->fromEnd($index)); + } +} diff --git a/tests/ValueObject/Valid/V30/MediaTypeTest.php b/tests/ValueObject/Valid/V30/MediaTypeTest.php new file mode 100644 index 0000000..8d0ce1c --- /dev/null +++ b/tests/ValueObject/Valid/V30/MediaTypeTest.php @@ -0,0 +1,49 @@ +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/V30/OpenAPITest.php b/tests/ValueObject/Valid/V30/OpenAPITest.php new file mode 100644 index 0000000..22d7270 --- /dev/null +++ b/tests/ValueObject/Valid/V30/OpenAPITest.php @@ -0,0 +1,146 @@ + [ + $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 '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', + operations: [ + PartialHelper::createOperation(operationId: 'duplicate-id', method: 'get'), + PartialHelper::createOperation(operationId: 'duplicate-id', method: 'post') + ], + ) + ] + ] + ); + + yield 'two path with identical operationIds' => $case( + InvalidOpenAPI::duplicateOperationIds('duplicate-id', '/first', 'get', '/second', 'get'), + [ + 'paths' => [ + PartialHelper::createPathItem( + path: '/first', + operations: [ + PartialHelper::createOperation(operationId: 'duplicate-id', method: 'get'), + ], + ), + PartialHelper::createPathItem( + path: '/second', + operations: [ + PartialHelper::createOperation(operationId: 'duplicate-id', method: 'get'), + ], + ), + ] + ] + ); + } +} diff --git a/tests/ValueObject/Valid/V30/OperationTest.php b/tests/ValueObject/Valid/V30/OperationTest.php new file mode 100644 index 0000000..deefef4 --- /dev/null +++ b/tests/ValueObject/Valid/V30/OperationTest.php @@ -0,0 +1,199 @@ +parameters); + } + + #[Test] + public function itCannotSupportConflictingParameters(): void + { + $parentIdentifier = new Identifier('test-path'); + + $operationId = 'test-id'; + $method = 'get'; + $identifier = $parentIdentifier->append($operationId, $method); + + $parameterNames = ['param1', 'param2']; + $parameterIdentifiers = array_map( + fn($n) => (string)$identifier->append($n, 'query'), + $parameterNames + ); + + $partialOperation = PartialHelper::createOperation( + method: $method, + 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)); + + new Operation($parentIdentifier, [], $partialOperation); + } + + #[Test, DataProvider('provideOperationsToValidate')] + public function itValidatesOperations( + InvalidOpenAPI $expected, + Identifier $parentIdentifier, + Partial\Operation $partialOperation, + ): void { + self::expectExceptionObject($expected); + + new Operation($parentIdentifier, [], $partialOperation); + } + + public static function provideParameters(): Generator + { + $parentIdentifier = new Identifier('/path'); + $operationId = 'test-operation'; + $identifier = $parentIdentifier->append("$operationId(get)"); + + $case = fn($expected, $pathParameters, $operationParameters) => [ + $parentIdentifier, + $expected, + array_map(fn($p) => new Parameter($parentIdentifier, $p), $pathParameters), + 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 = 'get'; + $identifier = $parentIdentifier->append($operationId, $method); + + $case = fn($expected, $data) => [ + $expected, + $parentIdentifier, + PartialHelper::createOperation(...array_merge( + ['operationId' => $operationId, 'method' => $method], + $data + )) + ]; + + yield 'duplicate parameters' => $case( + InvalidOpenAPI::duplicateParameters($identifier, 'duplicate', 'path'), + [ + 'parameters' => array_pad([], 2, PartialHelper::createParameter( + name: 'duplicate', + in: 'path', + )) + ] + ); + } +} diff --git a/tests/ValueObject/Valid/V30/ParameterTest.php b/tests/ValueObject/Valid/V30/ParameterTest.php new file mode 100644 index 0000000..56cbb3e --- /dev/null +++ b/tests/ValueObject/Valid/V30/ParameterTest.php @@ -0,0 +1,240 @@ +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()); + } + + 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->append($name, 'Wonderland')), + ['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($name), + $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() + )] + ) + ]; + } +} diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php new file mode 100644 index 0000000..68299a3 --- /dev/null +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -0,0 +1,250 @@ + new Parameter($identifier, $p), $partialExpected); + + $sut = new PathItem($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); + + $pathItem = PartialHelper::createPathItem(parameters: [$param, $param]); + + self::expectExceptionObject(InvalidOpenAPI::duplicateParameters( + $identifier, + $name, + $in + )); + + new PathItem($identifier, $pathItem); + } + + #[Test, DataProvider('provideSimilarNames')] + #[TestDox('It warns that similar names, though valid, may be confusing')] + public function itWarnsAgainstDuplicateNames(string $name1, string $name2): void + { + $sut = new PathItem( + new Identifier('test-path-item'), + PartialHelper::createPathItem(parameters: [ + PartialHelper::createParameter(name: $name1, in:'path'), + PartialHelper::createParameter(name: $name2, in:'query') + ]) + ); + + self::assertSame( + Warning::SIMILAR_NAMES, + $sut->getWarnings()->all()[0]->code + ); + } + + #[Test] + #[TestDox('It invalidates any methods that are not specified by OpenAPI')] + public function itInvalidatesUnrecognisedMethods(): void + { + $path = '/path'; + $identifier = new Identifier($path); + $method = 'upload'; + + $partialPathItem = PartialHelper::createPathItem( + path: $path, + operations: [PartialHelper::createOperation(method: $method)] + ); + + self::expectExceptionObject( + InvalidOpenAPI::unrecognisedMethod($identifier, $method) + ); + + new PathItem($identifier, $partialPathItem); + } + + #[Test, DataProvider('provideRedundantMethods')] + #[TestDox('it warns that options, head and trace are redundant methods for an OpenAPI')] + public function itWarnsAgainstRedundantMethods(string $method): void + { + $path = '/path'; + $identifier = new Identifier($path); + + $partialPathItem = PartialHelper::createPathItem( + path: $path, + operations: [PartialHelper::createOperation(method: $method)] + ); + + $sut = new PathItem($identifier, $partialPathItem); + + self::assertEquals( + new Warning( + "$method is redundant in an OpenAPI Specification.", + Warning::REDUNDANT_METHOD + ), + $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 = new PathItem( + new Identifier('test'), + PartialHelper::createPathItem(operations: $operations) + ); + + self::assertEquals($expected, $sut->getOperations()); + } + + 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 'three parameters' => [ + [$p1, $p2, $p3], + PartialHelper::createPathItem(parameters: [$p1, $p2, $p3]), + ]; + } + + public static function provideSimilarNames(): Generator + { + yield 'two identical names' => ['param', 'param']; + yield 'two names that only differ in case' => ['param', 'PARAM']; + } + + 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( + method: $method, + operationId: "$method-id" + ); + + $validOperation = fn($method) => new Operation( + $identifier, + [], + $partialOperation($method), + ); + + yield '"get" operation' => $case( + [ + 'get' => $validOperation('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'), + ], + [ + $partialOperation('get'), + $partialOperation('put'), + $partialOperation('post'), + $partialOperation('delete'), + $partialOperation('options'), + $partialOperation('head'), + $partialOperation('patch'), + $partialOperation('trace'), + ] + ); + } +} diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php new file mode 100644 index 0000000..d8f0283 --- /dev/null +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -0,0 +1,160 @@ +canItBeAnObject()); + } + + #[Test, DataProvider('provideSchemasToCheckIfTheyCanBeAnArray')] + public function itKnowsIfItCanBeAnArray( + bool $expected, + Partial\Schema $partialSchema, + ): void { + $sut = new Schema(new Identifier('sut'), $partialSchema); + + self::assertSame($expected, $sut->canItBeAnArray()); + } + + public 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) => [ + InvalidOpenAPI::emptyComplexSchema($exceptionId), + $identifier, + $schema + ]; + + foreach ($xOfs as $keyword => $xOf) { + yield "empty $keyword" => $case($identifier, $xOf()); + + foreach ($xOfs as $otherKeyWord => $otherXOf) { + yield "$keyword with empty $otherKeyWord inside" => $case( + $identifier->append($keyword, '0'), + $xOf($otherXOf()), + ); + } + } + } + + public static function provideSchemasToCheckIfTheyCanBeAnObject(): Generator + { + return self::provideSchemasToCheckIfTheyCanBeAType('object'); + } + + public static function provideSchemasToCheckIfTheyCanBeAnArray(): Generator + { + return self::provideSchemasToCheckIfTheyCanBeAType('array'); + } + + private static function provideSchemasToCheckIfTheyCanBeAType(string $desiredType): Generator + { + $types = ['boolean', 'number', 'integer', 'string', 'array', 'object']; + + foreach ($types as $type) { + yield "top-level type:$type" => [ + $type === $desiredType, + PartialHelper::createSchema(type: $type) + ]; + + yield "no top-level type, allOf MUST be $type" => [ + $type === $desiredType, + PartialHelper::createSchema(allOf: [ + PartialHelper::createSchema(type: $type), + ]) + ]; + + yield "no top-level type, anyOf MUST be $type" => [ + $type === $desiredType, + PartialHelper::createSchema(anyOf: [ + PartialHelper::createSchema(type: $type), + ]) + ]; + + yield "no top-level type, oneOf MUST be $type" => [ + $type === $desiredType, + PartialHelper::createSchema(oneOf: [ + PartialHelper::createSchema(type: $type), + ]) + ]; + + yield "no top-level type, anyOf MAY be $type or string" => [ + $desiredType === $type, + PartialHelper::createSchema(anyOf: [ + PartialHelper::createSchema(type: $type), + PartialHelper::createSchema(type: 'string') + ]) + ]; + + yield "no top-level type, oneOf MAY be $type or boolean" => [ + $desiredType === $type, + PartialHelper::createSchema(oneOf: [ + PartialHelper::createSchema(type: $type), + PartialHelper::createSchema(type: 'boolean') + ]) + ]; + + yield "no top-level type, allOf contains oneOf that may be $type or integer" => [ + $desiredType === $type, + PartialHelper::createSchema(allOf: [ + PartialHelper::createSchema(oneOf: [ + PartialHelper::createSchema(type: $type), + PartialHelper::createSchema(type: 'integer') + ]), + ]) + ]; + + } + } +} diff --git a/tests/ValueObject/WarningsTest.php b/tests/ValueObject/WarningsTest.php new file mode 100644 index 0000000..798e89c --- /dev/null +++ b/tests/ValueObject/WarningsTest.php @@ -0,0 +1,54 @@ +all()); + } + + #[Test, DataProvider('provideWarnings')] + public function itCanAddAWarning(Warning ...$warnings): void + { + $newWarning = new Warning('this should be added', 'add-warning'); + $expected = [...$warnings, $newWarning]; + + $sut = new Warnings(new Identifier('test'), ...$warnings); + + $sut->add($newWarning->message, $newWarning->code); + + self::assertEquals($expected, $sut->all()); + } + + + public static function provideWarnings(): Generator + { + yield 'no warnings' => []; + yield 'one warning' => [new Warning('think fast!', 'too-slow')]; + yield 'three warnings' => [ + new Warning('think fast!', 'too-slow'), + new Warning('watch out!', 'clock-in'), + new Warning('duck!', 'quack'), + ]; + } +} diff --git a/tests/fixtures/Helper/PartialHelper.php b/tests/fixtures/Helper/PartialHelper.php new file mode 100644 index 0000000..4120424 --- /dev/null +++ b/tests/fixtures/Helper/PartialHelper.php @@ -0,0 +1,104 @@ + Date: Tue, 16 Jan 2024 09:58:10 +0000 Subject: [PATCH 02/56] Document what validation is performed --- docs/validation.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/validation.md b/docs/validation.md index e69de29..846cbc2 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -0,0 +1,58 @@ +# 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). From 801351a11a980cd9125f880b7baee7b3e49cb4ea Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 16 Jan 2024 10:16:30 +0000 Subject: [PATCH 03/56] Appease PHPStan --- composer.json | 2 +- src/Exception/CannotSupport.php | 2 +- src/Exception/InvalidOpenAPI.php | 19 +---- src/ValueObject/Valid/V30/OpenAPI.php | 6 +- src/ValueObject/Valid/V30/Operation.php | 2 +- src/ValueObject/Valid/Validated.php | 8 +- src/ValueObject/Valid/Warnings.php | 5 ++ .../{ => Valid}/IdentifierTest.php | 2 +- tests/ValueObject/Valid/V30/OpenAPITest.php | 17 ++++ tests/ValueObject/Valid/ValidatedTest.php | 81 +++++++++++++++++++ .../ValueObject/{ => Valid}/WarningsTest.php | 12 ++- 11 files changed, 126 insertions(+), 30 deletions(-) rename tests/ValueObject/{ => Valid}/IdentifierTest.php (98%) create mode 100644 tests/ValueObject/Valid/ValidatedTest.php rename tests/ValueObject/{ => Valid}/WarningsTest.php (85%) diff --git a/composer.json b/composer.json index facc647..989ce3c 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ }, "require-dev": { "phpunit/phpunit": "^10.1", - "phpstan/phpstan": "^1.10.19", + "phpstan/phpstan": "^1.10.56", "squizlabs/php_codesniffer": "^3.7", "mikey179/vfsstream": "^1.6.7", "infection/infection": "^0.27.0" diff --git a/src/Exception/CannotSupport.php b/src/Exception/CannotSupport.php index 8320bc8..786ba75 100644 --- a/src/Exception/CannotSupport.php +++ b/src/Exception/CannotSupport.php @@ -52,7 +52,7 @@ public static function missingOperationId(string $pathUrl, string $method): self return new self($message, self::MISSING_OPERATION_ID); } - public static function undeclaredType(Identifier $identifier) + public static function undeclaredType(Identifier $identifier): self { $message = << Media Type Object pairs TEXT; - return new self ($message); + return new self($message); } public static function failedCebeValidation(string ...$errors): self @@ -222,8 +211,6 @@ public static function emptyComplexSchema(Identifier $identifier): self 'complex schemas MUST have atleast one sub-schema TEXT; - return new self ($message); + return new self($message); } - - } diff --git a/src/ValueObject/Valid/V30/OpenAPI.php b/src/ValueObject/Valid/V30/OpenAPI.php index 080f997..578d3eb 100644 --- a/src/ValueObject/Valid/V30/OpenAPI.php +++ b/src/ValueObject/Valid/V30/OpenAPI.php @@ -16,7 +16,7 @@ final class OpenAPI extends Validated * The PathItem's relative endpoint key mapped to the PathItem * @var array */ - private readonly array $paths; + public readonly array $paths; public function __construct(Partial\OpenAPI $openAPI) { @@ -123,9 +123,7 @@ private function getPathRegex(string $path): string { $pattern = preg_replace('#{[^/]+}#', '{([^/]+)}', $path); - if (!is_string($pattern)) { - throw InvalidOpenAPI::malformedUrl($this->getIdentifier(), $path); - } + assert(is_string($pattern)); return $pattern; } diff --git a/src/ValueObject/Valid/V30/Operation.php b/src/ValueObject/Valid/V30/Operation.php index 3529136..0757604 100644 --- a/src/ValueObject/Valid/V30/Operation.php +++ b/src/ValueObject/Valid/V30/Operation.php @@ -29,7 +29,7 @@ public function __construct( ) { $this->operationId = $operation->operationId ?? throw CannotSupport::missingOperationId( - $parentIdentifier->fromEnd(0), + $parentIdentifier->fromEnd(0) ?? '', $operation->method, ); diff --git a/src/ValueObject/Valid/Validated.php b/src/ValueObject/Valid/Validated.php index 785dfb5..36811c5 100644 --- a/src/ValueObject/Valid/Validated.php +++ b/src/ValueObject/Valid/Validated.php @@ -9,20 +9,20 @@ abstract class Validated implements HasIdentifier, HasWarnings private Warnings $warnings; public function __construct( - private readonly Identifier $parentIdentifier, + private readonly Identifier $identifier, ) { } public function getIdentifier(): Identifier { - return $this->parentIdentifier; + return $this->identifier; } protected function appendedIdentifier( string $primaryId, string $secondaryId = '' ): Identifier { - return $this->parentIdentifier->append($primaryId, $secondaryId); + return $this->identifier->append($primaryId, $secondaryId); } @@ -34,7 +34,7 @@ public function hasWarnings(): bool public function getWarnings(): Warnings { if (!isset($this->warnings)) { - $this->warnings = new Warnings($this->parentIdentifier); + $this->warnings = new Warnings($this->identifier); } return $this->warnings; diff --git a/src/ValueObject/Valid/Warnings.php b/src/ValueObject/Valid/Warnings.php index 3fcade4..913e12e 100644 --- a/src/ValueObject/Valid/Warnings.php +++ b/src/ValueObject/Valid/Warnings.php @@ -16,6 +16,11 @@ public function __construct( $this->warnings = $warnings; } + public function getIdentifier(): Identifier + { + return $this->identifier; + } + public function add(string $message, string $code): void { $this->warnings[] = new Warning($message, $code); diff --git a/tests/ValueObject/IdentifierTest.php b/tests/ValueObject/Valid/IdentifierTest.php similarity index 98% rename from tests/ValueObject/IdentifierTest.php rename to tests/ValueObject/Valid/IdentifierTest.php index 3655716..11da73e 100644 --- a/tests/ValueObject/IdentifierTest.php +++ b/tests/ValueObject/Valid/IdentifierTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Membrane\OpenAPIReader\Tests\ValueObject; +namespace Membrane\OpenAPIReader\Tests\ValueObject\Valid; use Generator; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; diff --git a/tests/ValueObject/Valid/V30/OpenAPITest.php b/tests/ValueObject/Valid/V30/OpenAPITest.php index 22d7270..6b038da 100644 --- a/tests/ValueObject/Valid/V30/OpenAPITest.php +++ b/tests/ValueObject/Valid/V30/OpenAPITest.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; @@ -45,6 +46,22 @@ public function itValidatesOpenAPIObjects( new OpenAPI($partialOpenAPI); } + #[Test] + #[TestDox('no "paths" is technically valid, but it does not leave much for Membrane to validate.')] + public function itWarnsAgainstEmptyPaths(): void + { + $expected = new Warning('No Paths in OpenAPI', Warning::EMPTY_PATHS); + $title = 'My API'; + $version = '1.2.1'; + $sut = new OpenAPI(PartialHelper::createOpenAPI( + title: $title, + version: $version, + paths: [], + )); + + self::assertEquals($expected, $sut->getWarnings()->all()[0]); + } + public static function providePartialOpenAPIs(): Generator { $title = 'Test OpenAPI'; diff --git a/tests/ValueObject/Valid/ValidatedTest.php b/tests/ValueObject/Valid/ValidatedTest.php new file mode 100644 index 0000000..3e093c2 --- /dev/null +++ b/tests/ValueObject/Valid/ValidatedTest.php @@ -0,0 +1,81 @@ +getIdentifier()); + } + + #[Test] + public function itAppendsIdentifiers(): void + { + $identifier = new Identifier('test'); + $expected = $identifier->append('appended'); + + + $sut = new class ($identifier) extends Validated { + public function testAppendedIdentifier() { + return $this->appendedIdentifier('appended'); + } + }; + + self::assertEquals($expected, $sut->testAppendedIdentifier()); + } + + #[Test] + public function itDoesNotStartWithWarnings(): void + { + $sut = new class (new Identifier('test')) extends Validated { + }; + + self::assertFalse($sut->hasWarnings()); + } + + #[Test] + public function itAddsWarnings(): void + { + $identifier = new Identifier('test'); + $message = 'duck!'; + $code = 'quack'; + $expected = new Warnings( + $identifier, + new Warning($message, $code), + ); + + $sut = new class ($identifier) extends Validated { + public function testAddWarning (string $message, string $code) { + $this->addWarning($message, $code); + } + }; + + self::assertFalse($sut->hasWarnings()); + + $sut->testAddWarning($message, $code); + + self::assertTrue($sut->hasWarnings()); + self::assertEquals($expected, $sut->getWarnings()); + } +} diff --git a/tests/ValueObject/WarningsTest.php b/tests/ValueObject/Valid/WarningsTest.php similarity index 85% rename from tests/ValueObject/WarningsTest.php rename to tests/ValueObject/Valid/WarningsTest.php index 798e89c..0900fbb 100644 --- a/tests/ValueObject/WarningsTest.php +++ b/tests/ValueObject/Valid/WarningsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Membrane\OpenAPIReader\Tests\ValueObject; +namespace Membrane\OpenAPIReader\Tests\ValueObject\Valid; use Generator; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; @@ -19,6 +19,15 @@ #[UsesClass(Identifier::class)] class WarningsTest extends TestCase { + #[Test] + public function itGetsIdentifier(): void + { + $expected = new Identifier('test'); + $sut = new Warnings($expected); + + self::assertEquals($expected, $sut->getIdentifier()); + } + #[Test, DataProvider('provideWarnings')] public function itCanGetAllWarnings(Warning ...$warnings): void { @@ -40,7 +49,6 @@ public function itCanAddAWarning(Warning ...$warnings): void self::assertEquals($expected, $sut->all()); } - public static function provideWarnings(): Generator { yield 'no warnings' => []; From c10dbf02030aad150890b52e620b1ced86cc7e18 Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 16 Jan 2024 12:31:02 +0000 Subject: [PATCH 04/56] Change fields on Partial PathItem - Partial Operations must now be passed explicitly to appropriate method --- src/Exception/InvalidOpenAPI.php | 11 --- src/Factory/V30/FromCebe.php | 37 ++++---- src/ValueObject/Partial/Operation.php | 1 - src/ValueObject/Partial/PathItem.php | 10 ++- src/ValueObject/Valid/V30/MediaType.php | 4 +- src/ValueObject/Valid/V30/OpenAPI.php | 4 +- src/ValueObject/Valid/V30/Operation.php | 6 +- src/ValueObject/Valid/V30/PathItem.php | 88 ++++++++++--------- src/ValueObject/Valid/V30/Schema.php | 4 +- tests/Factory/V30/FromCebeTest.php | 53 ++++++----- tests/ValueObject/Valid/V30/OpenAPITest.php | 15 ++-- tests/ValueObject/Valid/V30/OperationTest.php | 27 +++--- tests/ValueObject/Valid/V30/PathItemTest.php | 56 ++++-------- tests/fixtures/Helper/PartialHelper.php | 20 ++++- 14 files changed, 163 insertions(+), 173 deletions(-) diff --git a/src/Exception/InvalidOpenAPI.php b/src/Exception/InvalidOpenAPI.php index 9a82005..4df71d9 100644 --- a/src/Exception/InvalidOpenAPI.php +++ b/src/Exception/InvalidOpenAPI.php @@ -146,17 +146,6 @@ public static function equivalentTemplates(Identifier $firstPath, Identifier $se return new self($message); } - public static function unrecognisedMethod(Identifier $identifier, string $method): self - { - return new self( - << $pathItem) { $result[] = new PathItem( - $path, - self::createParameters($pathItem->parameters), - self::createOperations($pathItem->getOperations()), + path: $path, + 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), ); } @@ -121,22 +128,16 @@ private static function createContent(array $mediaTypes): array return $result; } - /** - * @param array $operations - * @return Operation[] - */ - private static function createOperations(array $operations): array - { - $result = []; - - foreach ($operations as $method => $operation) { - $result[] = new Operation( - $method, - $operation->operationId, - self::createParameters($operation->parameters) - ); + private static function createOperation( + ?Cebe\Operation $operation + ): ?Operation { + if (is_null($operation)) { + return null; } - return $result; + return new Operation( + $operation->operationId, + self::createParameters($operation->parameters) + ); } } diff --git a/src/ValueObject/Partial/Operation.php b/src/ValueObject/Partial/Operation.php index 978d081..8e4dbc1 100644 --- a/src/ValueObject/Partial/Operation.php +++ b/src/ValueObject/Partial/Operation.php @@ -10,7 +10,6 @@ final class Operation * @param Parameter[] $parameters */ public function __construct( - public string $method = '', public ?string $operationId = null, public array $parameters = [], ) { diff --git a/src/ValueObject/Partial/PathItem.php b/src/ValueObject/Partial/PathItem.php index 66d733e..053f08d 100644 --- a/src/ValueObject/Partial/PathItem.php +++ b/src/ValueObject/Partial/PathItem.php @@ -9,12 +9,18 @@ final class PathItem /** * @param ?string $path to PathItem * @param Parameter[] $parameters specified on PathItem - * @param Operation[] $operations specified on PathItem */ public function __construct( public ?string $path = null, public array $parameters = [], - public array $operations = [] + public ?Operation $get = null, + public ?Operation $put = null, + public ?Operation $post = null, + public ?Operation $delete = null, + public ?Operation $options = null, + public ?Operation $head = null, + public ?Operation $patch = null, + public ?Operation $trace = null, ) { } } diff --git a/src/ValueObject/Valid/V30/MediaType.php b/src/ValueObject/Valid/V30/MediaType.php index 1083517..2387fa1 100644 --- a/src/ValueObject/Valid/V30/MediaType.php +++ b/src/ValueObject/Valid/V30/MediaType.php @@ -13,10 +13,10 @@ final class MediaType extends Validated public readonly ?Schema $schema; public function __construct( - Identifier $parentIdentifier, + Identifier $identifier, Partial\MediaType $mediaType ) { - parent::__construct($parentIdentifier); + parent::__construct($identifier); $this->schema = isset($mediaType->schema) ? new Schema($this->getIdentifier()->append('schema'), $mediaType->schema) : diff --git a/src/ValueObject/Valid/V30/OpenAPI.php b/src/ValueObject/Valid/V30/OpenAPI.php index 578d3eb..9acf277 100644 --- a/src/ValueObject/Valid/V30/OpenAPI.php +++ b/src/ValueObject/Valid/V30/OpenAPI.php @@ -34,14 +34,14 @@ public function __construct(Partial\OpenAPI $openAPI) $this->addWarning('No Paths in OpenAPI', Warning::EMPTY_PATHS); } - $this->paths = $this->getPaths($openAPI->paths); + $this->paths = $this->validatePaths($openAPI->paths); } /** * @param Partial\PathItem[] $pathItems * @return array */ - private function getPaths(array $pathItems): array + private function validatePaths(array $pathItems): array { $result = []; foreach ($pathItems as $pathItem) { diff --git a/src/ValueObject/Valid/V30/Operation.php b/src/ValueObject/Valid/V30/Operation.php index 0757604..cbbc437 100644 --- a/src/ValueObject/Valid/V30/Operation.php +++ b/src/ValueObject/Valid/V30/Operation.php @@ -7,6 +7,7 @@ use Membrane\OpenAPIReader\Exception\CannotSupport; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\In; +use Membrane\OpenAPIReader\Method; use Membrane\OpenAPIReader\Style; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; @@ -25,15 +26,16 @@ final class Operation extends Validated public function __construct( Identifier $parentIdentifier, array $pathParameters, + Method $method, Partial\Operation $operation, ) { $this->operationId = $operation->operationId ?? throw CannotSupport::missingOperationId( $parentIdentifier->fromEnd(0) ?? '', - $operation->method, + $method->value, ); - parent::__construct($parentIdentifier->append("$this->operationId($operation->method)")); + parent::__construct($parentIdentifier->append("$this->operationId($method->value)")); $this->parameters = $this->mergeParameters( $pathParameters, diff --git a/src/ValueObject/Valid/V30/PathItem.php b/src/ValueObject/Valid/V30/PathItem.php index 1da2d65..588a5e4 100644 --- a/src/ValueObject/Valid/V30/PathItem.php +++ b/src/ValueObject/Valid/V30/PathItem.php @@ -16,11 +16,6 @@ final class PathItem extends Validated /** @var Parameter[] */ public readonly array $parameters; - /** - * Operation "method" keys mapped to Operation values - * @var array - */ - private readonly array $operations; public readonly ?Operation $get; public readonly ?Operation $put; public readonly ?Operation $post; @@ -31,22 +26,23 @@ final class PathItem extends Validated public readonly ?Operation $trace; public function __construct( - Identifier $parentIdentifier, + Identifier $identifier, Partial\PathItem $pathItem, ) { - parent::__construct($parentIdentifier); + parent::__construct($identifier); $this->parameters = $this->validateParameters($pathItem->parameters); - $this->operations = $this->validateOperations($pathItem->operations); - $this->get = $this->operations[Method::GET->value] ?? null; - $this->put = $this->operations[Method::PUT->value] ?? null; - $this->post = $this->operations[Method::POST->value] ?? null; - $this->delete = $this->operations[Method::DELETE->value] ?? null; - $this->options = $this->operations[Method::OPTIONS->value] ?? null; - $this->head = $this->operations[Method::HEAD->value] ?? null; - $this->patch = $this->operations[Method::PATCH->value] ?? null; - $this->trace = $this->operations[Method::TRACE->value] ?? null; + $this->get = $this->validateOperation(Method::GET, $pathItem->get); + $this->put = $this->validateOperation(Method::PUT, $pathItem->put); + $this->post = $this->validateOperation(Method::POST, $pathItem->post); + $this->delete = $this->validateOperation(Method::DELETE, $pathItem->delete); + $this->options = $this->validateOperation(Method::OPTIONS, $pathItem->options); + $this->head = $this->validateOperation(Method::HEAD, $pathItem->head); + $this->patch = $this->validateOperation(Method::PATCH, $pathItem->patch); + $this->trace = $this->validateOperation(Method::TRACE, $pathItem->trace); + + $this->checkOperations($this->getOperations()); } /** @@ -55,7 +51,19 @@ public function __construct( */ public function getOperations(): array { - return $this->operations; + 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) + ); } /** @@ -97,41 +105,39 @@ private function validateParameters(array $parameters): array return $result; } + private function validateOperation( + Method $method, + ?Partial\Operation $operation + ): ?Operation { + if (is_null($operation)) { + return null; + } + + return new Operation( + $this->getIdentifier(), + $this->parameters, + $method, + $operation + ); + } + /** - * @param Partial\Operation[] $partialOperations - * @return array + * Operation "method" keys mapped to Operation values + * @param array $operations */ - private function validateOperations(array $partialOperations): array + private function checkOperations(array $operations): void { - $result = []; - - if (empty($partialOperations)) { + if (empty($operations)) { $this->addWarning('No Operations on Path', Warning::EMPTY_PATH); } - foreach ($partialOperations as $partialOperation) { - $method = Method::tryFrom($partialOperation->method); - if ($method === null) { - throw InvalidOpenAPI::unrecognisedMethod( - $this->getIdentifier(), - $partialOperation->method - ); - } - - if ($method->isRedundant()) { + 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, ); } - - $result[$method->value] = new Operation( - $this->getIdentifier(), - $this->parameters, - $partialOperation - ); } - - return $result; } } diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index cdf5d93..17514b3 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -20,10 +20,10 @@ final class Schema extends Validated public readonly ?array $oneOf; public function __construct( - Identifier $parentIdentifier, + Identifier $identifier, Partial\Schema $schema ) { - parent::__construct($parentIdentifier); + parent::__construct($identifier); $this->type = $schema->type ?? null; diff --git a/tests/Factory/V30/FromCebeTest.php b/tests/Factory/V30/FromCebeTest.php index 80c04c8..20cd68c 100644 --- a/tests/Factory/V30/FromCebeTest.php +++ b/tests/Factory/V30/FromCebeTest.php @@ -88,35 +88,32 @@ public static function provideCebeOpenAPIObjects(): Generator ) ), ], - operations: [ - PartialHelper::createOperation( - operationId: 'test-id', - method: 'get', - parameters: [ - PartialHelper::createParameter( - name: 'pet', - in: 'header', - required: true, - schema: null, - content: [ - PartialHelper::createMediaType( - mediaType: 'application/json', - schema: PartialHelper::createSchema( - allOf: [ - PartialHelper::createSchema( - type: 'integer' - ), - PartialHelper::createSchema( - type: 'number' - ) - ] - ) + get: PartialHelper::createOperation( + operationId: 'test-id', + parameters: [ + PartialHelper::createParameter( + name: 'pet', + in: 'header', + required: true, + schema: null, + content: [ + PartialHelper::createMediaType( + mediaType: 'application/json', + schema: PartialHelper::createSchema( + allOf: [ + PartialHelper::createSchema( + type: 'integer' + ), + PartialHelper::createSchema( + type: 'number' + ) + ] ) - ] - ) - ] - ) - ] + ) + ] + ) + ] + ) ) ] )), diff --git a/tests/ValueObject/Valid/V30/OpenAPITest.php b/tests/ValueObject/Valid/V30/OpenAPITest.php index 6b038da..3918f6c 100644 --- a/tests/ValueObject/Valid/V30/OpenAPITest.php +++ b/tests/ValueObject/Valid/V30/OpenAPITest.php @@ -131,10 +131,8 @@ public static function providePartialOpenAPIs(): Generator 'paths' => [ PartialHelper::createPathItem( path: '/path', - operations: [ - PartialHelper::createOperation(operationId: 'duplicate-id', method: 'get'), - PartialHelper::createOperation(operationId: 'duplicate-id', method: 'post') - ], + get: PartialHelper::createOperation(operationId: 'duplicate-id'), + post: PartialHelper::createOperation(operationId: 'duplicate-id'), ) ] ] @@ -146,15 +144,12 @@ public static function providePartialOpenAPIs(): Generator 'paths' => [ PartialHelper::createPathItem( path: '/first', - operations: [ - PartialHelper::createOperation(operationId: 'duplicate-id', method: 'get'), - ], + get: PartialHelper::createOperation(operationId: 'duplicate-id') + ), PartialHelper::createPathItem( path: '/second', - operations: [ - PartialHelper::createOperation(operationId: 'duplicate-id', method: 'get'), - ], + get: PartialHelper::createOperation(operationId: 'duplicate-id'), ), ] ] diff --git a/tests/ValueObject/Valid/V30/OperationTest.php b/tests/ValueObject/Valid/V30/OperationTest.php index deefef4..83ec216 100644 --- a/tests/ValueObject/Valid/V30/OperationTest.php +++ b/tests/ValueObject/Valid/V30/OperationTest.php @@ -7,6 +7,7 @@ use Generator; use Membrane\OpenAPIReader\Exception\CannotSupport; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; +use Membrane\OpenAPIReader\Method; use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; @@ -40,7 +41,7 @@ public function itRequiresAnOperationId(): void self::expectException(CannotSupport::class); self::expectExceptionCode(CannotSupport::MISSING_OPERATION_ID); - new Operation(new Identifier(''), [], $partialOperation); + new Operation(new Identifier(''), [], Method::GET, $partialOperation); } /** @@ -52,9 +53,10 @@ public function itOverridesPathParametersOfTheSameName( Identifier $parentIdentifier, array $expected, array $pathParameters, + Method $method, Partial\Operation $partialOperation ): void { - $sut = new Operation($parentIdentifier, $pathParameters, $partialOperation); + $sut = new Operation($parentIdentifier, $pathParameters, $method, $partialOperation); self::assertEquals($expected, $sut->parameters); } @@ -65,8 +67,8 @@ public function itCannotSupportConflictingParameters(): void $parentIdentifier = new Identifier('test-path'); $operationId = 'test-id'; - $method = 'get'; - $identifier = $parentIdentifier->append($operationId, $method); + $method = Method::GET; + $identifier = $parentIdentifier->append($operationId, $method->value); $parameterNames = ['param1', 'param2']; $parameterIdentifiers = array_map( @@ -75,7 +77,6 @@ public function itCannotSupportConflictingParameters(): void ); $partialOperation = PartialHelper::createOperation( - method: $method, operationId: $operationId, parameters: [ PartialHelper::createParameter( @@ -97,30 +98,33 @@ public function itCannotSupportConflictingParameters(): void self::expectExceptionObject(CannotSupport::conflictingParameterStyles(...$parameterIdentifiers)); - new Operation($parentIdentifier, [], $partialOperation); + new Operation($parentIdentifier, [], $method, $partialOperation); } #[Test, DataProvider('provideOperationsToValidate')] public function itValidatesOperations( InvalidOpenAPI $expected, Identifier $parentIdentifier, + Method $method, Partial\Operation $partialOperation, ): void { self::expectExceptionObject($expected); - new Operation($parentIdentifier, [], $partialOperation); + new Operation($parentIdentifier, [], $method, $partialOperation); } public static function provideParameters(): Generator { $parentIdentifier = new Identifier('/path'); $operationId = 'test-operation'; - $identifier = $parentIdentifier->append("$operationId(get)"); + $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 @@ -174,14 +178,15 @@ public static function provideOperationsToValidate(): Generator { $parentIdentifier = new Identifier('test'); $operationId = 'test-id'; - $method = 'get'; - $identifier = $parentIdentifier->append($operationId, $method); + $method = Method::GET; + $identifier = $parentIdentifier->append($operationId, $method->value); $case = fn($expected, $data) => [ $expected, $parentIdentifier, + $method, PartialHelper::createOperation(...array_merge( - ['operationId' => $operationId, 'method' => $method], + ['operationId' => $operationId], $data )) ]; diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index 68299a3..1f4ae2c 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -96,39 +96,15 @@ public function itWarnsAgainstDuplicateNames(string $name1, string $name2): void ); } - #[Test] - #[TestDox('It invalidates any methods that are not specified by OpenAPI')] - public function itInvalidatesUnrecognisedMethods(): void - { - $path = '/path'; - $identifier = new Identifier($path); - $method = 'upload'; - - $partialPathItem = PartialHelper::createPathItem( - path: $path, - operations: [PartialHelper::createOperation(method: $method)] - ); - - self::expectExceptionObject( - InvalidOpenAPI::unrecognisedMethod($identifier, $method) - ); - - new PathItem($identifier, $partialPathItem); - } - #[Test, DataProvider('provideRedundantMethods')] #[TestDox('it warns that options, head and trace are redundant methods for an OpenAPI')] public function itWarnsAgainstRedundantMethods(string $method): void { - $path = '/path'; - $identifier = new Identifier($path); + $operations = [$method => PartialHelper::createOperation()]; - $partialPathItem = PartialHelper::createPathItem( - path: $path, - operations: [PartialHelper::createOperation(method: $method)] - ); + $partialPathItem = PartialHelper::createPathItem(...$operations); - $sut = new PathItem($identifier, $partialPathItem); + $sut = new PathItem(new Identifier('test'), $partialPathItem); self::assertEquals( new Warning( @@ -151,8 +127,10 @@ public function itCanGetAllOperations( array $operations, ): void { $sut = new PathItem( - new Identifier('test'), - PartialHelper::createPathItem(operations: $operations) + $identifier, + PartialHelper::createPathItem( + ...$operations + ) ); self::assertEquals($expected, $sut->getOperations()); @@ -207,13 +185,13 @@ public static function provideOperationsToGet(): Generator yield 'no operations' => $case([], []); $partialOperation = fn($method) => PartialHelper::createOperation( - method: $method, operationId: "$method-id" ); $validOperation = fn($method) => new Operation( $identifier, [], + Method::from($method), $partialOperation($method), ); @@ -221,7 +199,7 @@ public static function provideOperationsToGet(): Generator [ 'get' => $validOperation('get') ], - [$partialOperation('get')] + ['get' => $partialOperation('get')] ); yield 'every operation' => $case( @@ -236,14 +214,14 @@ public static function provideOperationsToGet(): Generator 'trace' => $validOperation('trace'), ], [ - $partialOperation('get'), - $partialOperation('put'), - $partialOperation('post'), - $partialOperation('delete'), - $partialOperation('options'), - $partialOperation('head'), - $partialOperation('patch'), - $partialOperation('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'), ] ); } diff --git a/tests/fixtures/Helper/PartialHelper.php b/tests/fixtures/Helper/PartialHelper.php index 4120424..a73355a 100644 --- a/tests/fixtures/Helper/PartialHelper.php +++ b/tests/fixtures/Helper/PartialHelper.php @@ -30,12 +30,26 @@ public static function createOpenAPI( public static function createPathItem( ?string $path = '/path', array $parameters = [], - array $operations = [], + ?Operation $get = null, + ?Operation $put = null, + ?Operation $post = null, + ?Operation $delete = null, + ?Operation $options = null, + ?Operation $head = null, + ?Operation $patch = null, + ?Operation $trace = null, ): PathItem { return new PathItem( $path, $parameters, - $operations + $get, + $put, + $post, + $delete, + $options, + $head, + $patch, + $trace, ); } @@ -61,12 +75,10 @@ public static function createParameter( } public static function createOperation( - string $method = 'get', ?string $operationId = 'test-id', array $parameters = [] ): Operation { return new Operation( - $method, $operationId, $parameters ); From aee5d2c2b60df1c5d3f399ddf0ff3af8f6e34b12 Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 16 Jan 2024 14:25:36 +0000 Subject: [PATCH 05/56] Validate "paths" is set on V3.0 OpenAPI Objects --- src/Exception/InvalidOpenAPI.php | 10 ++++++++++ src/ValueObject/Partial/OpenAPI.php | 2 +- src/ValueObject/Valid/V30/OpenAPI.php | 4 ++++ tests/ValueObject/Valid/V30/OpenAPITest.php | 5 +++++ tests/fixtures/Helper/PartialHelper.php | 2 +- 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Exception/InvalidOpenAPI.php b/src/Exception/InvalidOpenAPI.php index 4df71d9..836085a 100644 --- a/src/Exception/InvalidOpenAPI.php +++ b/src/Exception/InvalidOpenAPI.php @@ -33,6 +33,16 @@ public static function missingOpenAPIVersion(Identifier $identifier): self return new self($message); } + public static function missingPaths(Identifier $identifier): self + { + $message = <<getIdentifier()); } + if (!isset($openAPI->paths)) { + throw InvalidOpenAPI::missingPaths($this->getIdentifier()); + } + if (empty($openAPI->paths)) { $this->addWarning('No Paths in OpenAPI', Warning::EMPTY_PATHS); } diff --git a/tests/ValueObject/Valid/V30/OpenAPITest.php b/tests/ValueObject/Valid/V30/OpenAPITest.php index 3918f6c..94e824e 100644 --- a/tests/ValueObject/Valid/V30/OpenAPITest.php +++ b/tests/ValueObject/Valid/V30/OpenAPITest.php @@ -92,6 +92,11 @@ public static function providePartialOpenAPIs(): Generator ['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)]] diff --git a/tests/fixtures/Helper/PartialHelper.php b/tests/fixtures/Helper/PartialHelper.php index a73355a..f88eb67 100644 --- a/tests/fixtures/Helper/PartialHelper.php +++ b/tests/fixtures/Helper/PartialHelper.php @@ -17,7 +17,7 @@ public static function createOpenAPI( ?string $openapi = '3.0.0', ?string $title = 'Test API', ?string $version = '1.0.0', - array $paths = [], + ?array $paths = [], ): OpenAPI { return new OpenAPI( $openapi, From 1a158b8605e78ea6a85b35b0130614062bcb18ba Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 17 Jan 2024 16:31:27 +0000 Subject: [PATCH 06/56] Add convenience methods to Warnings --- src/ValueObject/Valid/Warning.php | 33 ++++++++ src/ValueObject/Valid/Warnings.php | 20 ++++- tests/ValueObject/Valid/WarningsTest.php | 103 +++++++++++++++++++++-- 3 files changed, 148 insertions(+), 8 deletions(-) diff --git a/src/ValueObject/Valid/Warning.php b/src/ValueObject/Valid/Warning.php index 94ba2be..695f122 100644 --- a/src/ValueObject/Valid/Warning.php +++ b/src/ValueObject/Valid/Warning.php @@ -6,9 +6,42 @@ final class Warning { + /** + * Server Variable: "enum" SHOULD NOT be empty + * Schema: "enum" SHOULD have at least one element + */ + public const EMPTY_ENUM = 'empty-enum'; + + /** + * Path Item: "path" can be empty due to ACL constraints, but Membrane can't do much without any operations + */ public const EMPTY_PATH = 'empty-path'; + + /** + * OpenAPI: "paths" can be empty due to ACL constraints, but Membrane can't do much without any paths + */ public const EMPTY_PATHS = 'empty-paths'; + + /** + * Server Variable: If the "enum" is defined, the value SHOULD exist in the enum's values. + */ + public const IMPOSSIBLE_DEFAULT = 'impossible-default'; + + /** + * OpenAPI, Path Item, Operation: If "servers" are specified, and they're all impossible. Your OpenAPI is unusable. + */ + public const NO_VALID_SERVERS = 'no-valid-servers'; + + /** + * Path Item: "head" + * Path Item: "options" specifies what HTTP methods are available, this is what your OpenAPI already does. + * Path Item: "trace" + */ public const REDUNDANT_METHOD = 'redundant-method'; + + /** + * Path Item, Operation: "parameters" can have identical/similar names, but this could be quite confusing. + */ public const SIMILAR_NAMES = 'similar-names'; public function __construct( diff --git a/src/ValueObject/Valid/Warnings.php b/src/ValueObject/Valid/Warnings.php index 913e12e..7dee198 100644 --- a/src/ValueObject/Valid/Warnings.php +++ b/src/ValueObject/Valid/Warnings.php @@ -4,7 +4,7 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid; -final class Warnings +final class Warnings implements HasIdentifier { /** @var Warning[] */ private array $warnings; @@ -31,4 +31,22 @@ public function all(): array { return $this->warnings; } + + public function hasWarnings(): bool + { + return !empty($this->warnings); + } + + public function hasWarningCodes(string $code, string ...$codes): bool + { + $codes = [$code, ...$codes]; + + foreach ($this->warnings as $warning) { + if (in_array($warning->code, $codes)) { + return true; + } + } + + return false; + } } diff --git a/tests/ValueObject/Valid/WarningsTest.php b/tests/ValueObject/Valid/WarningsTest.php index 0900fbb..9a133eb 100644 --- a/tests/ValueObject/Valid/WarningsTest.php +++ b/tests/ValueObject/Valid/WarningsTest.php @@ -29,26 +29,48 @@ public function itGetsIdentifier(): void } #[Test, DataProvider('provideWarnings')] - public function itCanGetAllWarnings(Warning ...$warnings): void + public function itAddsWarnings(Warning ...$warnings): void { + $newWarning = new Warning('this should be added', 'add-warning'); + $expected = [...$warnings, $newWarning]; + $sut = new Warnings(new Identifier('test'), ...$warnings); - self::assertSame($warnings, $sut->all()); + $sut->add($newWarning->message, $newWarning->code); + + self::assertEquals($expected, $sut->all()); } #[Test, DataProvider('provideWarnings')] - public function itCanAddAWarning(Warning ...$warnings): void + public function itChecksItHasWarnings(Warning ...$warnings): void { - $newWarning = new Warning('this should be added', 'add-warning'); - $expected = [...$warnings, $newWarning]; + $sut = new Warnings(new Identifier('test'), ...$warnings); + + self::assertSame(!empty($warnings), $sut->hasWarnings()); + } + #[Test, DataProvider('provideCodesToCheck')] + public function itChecksItHasWarningCodes( + bool $expected, + array $codes, + array $warnings, + ): void { $sut = new Warnings(new Identifier('test'), ...$warnings); - $sut->add($newWarning->message, $newWarning->code); + self::assertSame($expected, $sut->hasWarningCodes(...$codes)); + } - self::assertEquals($expected, $sut->all()); + #[Test, DataProvider('provideWarnings')] + public function itGetsAllWarnings(Warning ...$warnings): void + { + $sut = new Warnings(new Identifier('test'), ...$warnings); + + self::assertSame($warnings, $sut->all()); } + /** + * @return Generator + */ public static function provideWarnings(): Generator { yield 'no warnings' => []; @@ -59,4 +81,71 @@ public static function provideWarnings(): Generator new Warning('duck!', 'quack'), ]; } + + + public static function provideCodesToCheck(): Generator + { + foreach (self::provideWarnings() as $case => $warnings) { + yield "$case, single, not contained, code" => [ + false, + ['This code is most definitely not contained anywhere'], + $warnings + ]; + + yield "$case, multiple, not contained, codes" => [ + false, + [ + 'This code is most definitely not contained anywhere', + 'This code is almost certainly not contained anywhere', + 'This code is guaranteed not to be contained somewhere', + ], + $warnings + ]; + + if (!empty($warnings)) { + $codes = array_map(fn($w) => $w->code, $warnings); + + yield "$case, single, contained, code" => [ + true, + [$codes[0]], + $warnings + ]; + + yield "$case, one contained code, duplicated three times" => [ + true, + [$codes[0], $codes[0], $codes[0]], + $warnings + ]; + + yield "$case, one contained code, one that is not" => [ + true, + ['This code is certainly not contained', $codes[0]], + $warnings + ]; + + if (count($warnings) > 1) { + yield "$case, all contained codes" => [ + true, + $codes, + $warnings + ]; + + $mixedCodes = []; + foreach ($codes as $code) { + $mixedCodes[] = 'This code is certainly not contained'; + $mixedCodes[] = $code; + } + + yield "$case, all contained codes, several that aren't" => [ + true, + $mixedCodes, + $warnings, + ]; + } + + + } + + } + } } From a71d20aa49ad9b023470711ecceaca320debd66d Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 17 Jan 2024 16:56:13 +0000 Subject: [PATCH 07/56] Invalidate duplicate Parameters in Path Item V3.0 --- src/Exception/InvalidOpenAPI.php | 17 ++-- src/ValueObject/Valid/V30/Operation.php | 78 ++++++++++++------- src/ValueObject/Valid/V30/PathItem.php | 40 +++++++++- tests/ValueObject/Valid/V30/OperationTest.php | 10 ++- tests/ValueObject/Valid/V30/PathItemTest.php | 5 +- 5 files changed, 107 insertions(+), 43 deletions(-) diff --git a/src/Exception/InvalidOpenAPI.php b/src/Exception/InvalidOpenAPI.php index 836085a..14107b6 100644 --- a/src/Exception/InvalidOpenAPI.php +++ b/src/Exception/InvalidOpenAPI.php @@ -63,16 +63,17 @@ public static function pathMissingEndPoint(Identifier $identifier): self return new self($message); } - public static function duplicateParameters(Identifier $identifier, string $name, string $in): self - { + public static function duplicateParameters( + Identifier $identifier, + Identifier $parameter, + Identifier $otherParameter + ): self { $message = <<append("$this->operationId($method->value)")); - $this->parameters = $this->mergeParameters( + $this->parameters = $this->validateParameters( $pathParameters, $operation->parameters ); @@ -55,57 +56,76 @@ public function __construct( * @param Partial\Parameter[] $operationParameters * @return Parameter[] */ - private function mergeParameters( + private function validateParameters( array $pathParameters, array $operationParameters ): array { + $result = $this->mergeParameters($operationParameters, $pathParameters); + + foreach ($result as $index => $parameter) { + foreach (array_slice($result, $index + 1) as $otherParameter) { + if ($this->areParametersSimilar($parameter, $otherParameter)) { + $this->addWarning( + <<name + $otherParameter->name + TEXT, + Warning::SIMILAR_NAMES + ); + + if ($this->areParametersIdentical($parameter, $otherParameter)) { + throw InvalidOpenAPI::duplicateParameters( + $this->getIdentifier(), + $parameter->getIdentifier(), + $otherParameter->getIdentifier(), + ); + } + } + } + } + + return $result; + } + + /** + * @param Partial\Parameter[] $operationParameters + * @param Parameter[] $pathParameters + * @return array + */ + private function mergeParameters(array $operationParameters, array $pathParameters): array + { $result = array_map( fn($p) => new Parameter($this->getIdentifier(), $p), $operationParameters ); foreach ($pathParameters as $pathParameter) { - if (!$this->isIdenticalParameterInList($pathParameter, $result)) { + foreach ($result as $operationParameter) { + if ($this->areParametersIdentical($pathParameter, $operationParameter)) { + break; + } $result[] = $pathParameter; } } - - foreach (array_values($result) as $index => $parameter) { - if ($this->isIdenticalParameterInList($parameter, array_slice(array_values($result), $index + 1))) { - throw InvalidOpenAPI::duplicateParameters( - $this->getIdentifier(), - $parameter->name, - $parameter->in->value - ); - } - } - - return $result; + return array_values($result); } - /** @param Parameter[] $otherParameters */ - private function isIdenticalParameterInList( + private function areParametersIdentical( Parameter $parameter, - array $otherParameters + Parameter $otherParameter ): bool { - foreach ($otherParameters as $otherParameter) { - if (!$this->isParameterUnique($parameter, $otherParameter)) { - return true; - } - } - return false; + return $parameter->name === $otherParameter->name && + $parameter->in === $otherParameter->in; } - private function isParameterUnique( + private function areParametersSimilar( Parameter $parameter, Parameter $otherParameter ): bool { - return $parameter->name !== $otherParameter->name || - $parameter->in !== $otherParameter->in; + return strcasecmp($parameter->name, $otherParameter->name) === 0; } - - private function canParameterConflict(Parameter $parameter): bool { if ($parameter->in !== In::Query) { diff --git a/src/ValueObject/Valid/V30/PathItem.php b/src/ValueObject/Valid/V30/PathItem.php index 588a5e4..51632b5 100644 --- a/src/ValueObject/Valid/V30/PathItem.php +++ b/src/ValueObject/Valid/V30/PathItem.php @@ -79,14 +79,33 @@ private function validateParameters(array $parameters): array foreach (array_values($result) as $index => $parameter) { foreach (array_slice($result, $index + 1) as $otherParameter) { + if ($this->areParametersSimilar($parameter, $otherParameter)) { + $this->addWarning( + <<name + $otherParameter->name + TEXT, + Warning::SIMILAR_NAMES + ); + + if ($this->areParametersIdentical($parameter, $otherParameter)) { + throw InvalidOpenAPI::duplicateParameters( + $this->getIdentifier(), + $parameter->getIdentifier(), + $otherParameter->getIdentifier(), + ); + } + } + if ( - strcmp($parameter->name, $otherParameter->name) === 0 && + $parameter->name === $otherParameter->name && $parameter->in === $otherParameter->in ) { throw InvalidOpenAPI::duplicateParameters( $this->getIdentifier(), - $parameter->name, - $parameter->in->value + $parameter->getIdentifier(), + $otherParameter->getIdentifier(), ); } @@ -105,6 +124,21 @@ private function validateParameters(array $parameters): array return $result; } + private function areParametersIdentical( + Parameter $parameter, + Parameter $otherParameter + ): bool { + return $parameter->name === $otherParameter->name && + $parameter->in === $otherParameter->in; + } + + private function areParametersSimilar( + Parameter $parameter, + Parameter $otherParameter + ): bool { + return strcasecmp($parameter->name, $otherParameter->name) === 0; + } + private function validateOperation( Method $method, ?Partial\Operation $operation diff --git a/tests/ValueObject/Valid/V30/OperationTest.php b/tests/ValueObject/Valid/V30/OperationTest.php index 83ec216..676dc88 100644 --- a/tests/ValueObject/Valid/V30/OperationTest.php +++ b/tests/ValueObject/Valid/V30/OperationTest.php @@ -15,6 +15,8 @@ use Membrane\OpenAPIReader\ValueObject\Valid\V30\Parameter; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Schema; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; +use Membrane\OpenAPIReader\ValueObject\Valid\Warning; +use Membrane\OpenAPIReader\ValueObject\Valid\Warnings; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -31,6 +33,8 @@ #[UsesClass(Parameter::class)] #[UsesClass(Schema::class)] #[UsesClass(Validated::class)] +#[UsesClass(Warning::class)] +#[UsesClass(Warnings::class)] class OperationTest extends TestCase { #[Test] @@ -192,7 +196,11 @@ public static function provideOperationsToValidate(): Generator ]; yield 'duplicate parameters' => $case( - InvalidOpenAPI::duplicateParameters($identifier, 'duplicate', 'path'), + InvalidOpenAPI::duplicateParameters( + $identifier, + $identifier->append('duplicate', 'path'), + $identifier->append('duplicate', 'path'), + ), [ 'parameters' => array_pad([], 2, PartialHelper::createParameter( name: 'duplicate', diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index 1f4ae2c..9262d0e 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -66,13 +66,14 @@ public function itInvalidatesDuplicateParameters(): void $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, - $name, - $in + $paramIdentifier, + $paramIdentifier, )); new PathItem($identifier, $pathItem); From c82bd2f757b62041d8be67cf5ae977a845b52084 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 17 Jan 2024 18:11:13 +0000 Subject: [PATCH 08/56] Add DocBlocks for V3.0. Value Object fields --- src/ValueObject/Valid/V30/OpenAPI.php | 2 ++ src/ValueObject/Valid/V30/Operation.php | 10 +++++++++- src/ValueObject/Valid/V30/Parameter.php | 21 +++++++++++++++++++-- src/ValueObject/Valid/V30/PathItem.php | 6 +++++- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/ValueObject/Valid/V30/OpenAPI.php b/src/ValueObject/Valid/V30/OpenAPI.php index 0d6bd52..a78f451 100644 --- a/src/ValueObject/Valid/V30/OpenAPI.php +++ b/src/ValueObject/Valid/V30/OpenAPI.php @@ -13,6 +13,8 @@ final class OpenAPI extends Validated { /** + * REQUIRED + * It may be empty due to ACL constraints * The PathItem's relative endpoint key mapped to the PathItem * @var array */ diff --git a/src/ValueObject/Valid/V30/Operation.php b/src/ValueObject/Valid/V30/Operation.php index 9812781..51c67c1 100644 --- a/src/ValueObject/Valid/V30/Operation.php +++ b/src/ValueObject/Valid/V30/Operation.php @@ -16,9 +16,17 @@ final class Operation extends Validated { - /** @var Parameter[] */ + /** + * The list MUST NOT include duplicated parameters. + * A unique parameter is defined by a combination of a name and location. + * @var Parameter[] + */ public readonly array $parameters; + /** + * Required by Membrane + * MUST be unique, value is case-sensitive. + */ public readonly string $operationId; /** diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php index e1590d6..a65ff7c 100644 --- a/src/ValueObject/Valid/V30/Parameter.php +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -13,19 +13,34 @@ final class Parameter extends Validated { + /** REQUIRED */ public readonly string $name; + /** REQUIRED */ public readonly In $in; + /** + * If "in":"path" + * - "required" MUST be defined + * - "required" MUST be set to true + */ + public readonly bool $required; + public readonly Style $style; public readonly bool $explode; /** - * A Parameter MUST have one of the following, but not both. + * A Parameter MUST define a "schema" or "content" but not both */ public readonly ?Schema $schema; - /** @var array */ + /** + * A Parameter MUST have a "schema" or "content" but not both + * If "content" is defined: + * - It MUST contain only one Media Type. + * - That MediaType MUST define a schema. + * @var array + */ public readonly array $content; public function __construct(Identifier $parentIdentifier, Partial\Parameter $parameter) @@ -47,6 +62,8 @@ public function __construct(Identifier $parentIdentifier, Partial\Parameter $par if ($this->in === In::Path && $parameter->required !== true) { throw InvalidOpenAPI::parameterMissingRequired($this->getIdentifier()); } + + $this->required = $parameter->required ?? false; if (!isset($parameter->style)) { $this->style = $this->defaultStyle($this->in); diff --git a/src/ValueObject/Valid/V30/PathItem.php b/src/ValueObject/Valid/V30/PathItem.php index 51632b5..007d2ab 100644 --- a/src/ValueObject/Valid/V30/PathItem.php +++ b/src/ValueObject/Valid/V30/PathItem.php @@ -13,7 +13,11 @@ final class PathItem extends Validated { - /** @var Parameter[] */ + /** + * The list MUST NOT include duplicated parameters. + * A unique parameter is defined by a combination of a name and location. + * @var Parameter[] + */ public readonly array $parameters; public readonly ?Operation $get; From 2f94f3bc761aa24874df9d2176fb0c4ef669c5a3 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 17 Jan 2024 18:48:13 +0000 Subject: [PATCH 09/56] Refactor V3.0. Parameter --- src/ValueObject/Valid/V30/Parameter.php | 92 +++++++++++++------ tests/ValueObject/Valid/V30/ParameterTest.php | 2 +- 2 files changed, 63 insertions(+), 31 deletions(-) diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php index a65ff7c..e218352 100644 --- a/src/ValueObject/Valid/V30/Parameter.php +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -45,37 +45,28 @@ final class Parameter extends Validated public function __construct(Identifier $parentIdentifier, Partial\Parameter $parameter) { - if (!isset($parameter->name)) { + $this->name = $parameter->name ?? throw InvalidOpenAPI::parameterMissingName($parentIdentifier); - } - $this->name = $parameter->name; - - if (!isset($parameter->in)) { - throw InvalidOpenAPI::parameterMissingLocation($parentIdentifier); - } - - parent::__construct($parentIdentifier->append($parameter->name, $parameter->in)); - $this->in = In::tryFrom($parameter->in) ?? - throw InvalidOpenAPI::parameterInvalidLocation($this->getIdentifier()); + $this->in = $this->validateIn( + $parentIdentifier, + $parameter->in, + ); - if ($this->in === In::Path && $parameter->required !== true) { - throw InvalidOpenAPI::parameterMissingRequired($this->getIdentifier()); - } - - $this->required = $parameter->required ?? false; + $identifier = $parentIdentifier->append($this->name, $this->in->value); + parent::__construct($identifier); - if (!isset($parameter->style)) { - $this->style = $this->defaultStyle($this->in); - } elseif (Style::tryFrom($parameter->style) === null) { - throw InvalidOpenAPI::parameterInvalidStyle($this->getIdentifier()); - } else { - $this->style = Style::from($parameter->style); - } + $this->required = $this->validateRequired( + $identifier, + $this->in, + $parameter->required + ); - if (!$this->styleIsValid($this->in, $this->style)) { - throw InvalidOpenAPI::parameterIncompatibleStyle($this->getIdentifier()); - } + $this->style = $this->validateStyle( + $identifier, + $this->in, + $parameter->style, + ); $this->explode = $parameter->explode ?? $this->defaultExplode($this->style); @@ -84,15 +75,15 @@ public function __construct(Identifier $parentIdentifier, Partial\Parameter $par } if (isset($parameter->schema)) { + $this->content = []; $this->schema = new Schema( $this->appendedIdentifier('schema'), $parameter->schema ); - $this->content = []; } else { $this->schema = null; - $this->content = $this->getContent( + $this->content = $this->validateContent( $this->getIdentifier(), $parameter->name, $parameter->content @@ -120,6 +111,47 @@ public function getMediaType(): ?string return array_key_first($this->content); } + 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, + In $in, + ?string $style + ): Style { + if (is_null($style)) { + return $this->defaultStyle($in); + } + + $style = Style::tryFrom($style) ?? + throw InvalidOpenAPI::parameterInvalidStyle($identifier); + + if (!$this->styleIsValidForLocation($in, $style)) { + throw InvalidOpenAPI::parameterIncompatibleStyle($identifier); + } + + return $style; + } + private function defaultStyle(In $in): Style { return match ($in) { @@ -133,7 +165,7 @@ private function defaultExplode(Style $style): bool return $style === Style::Form; } - private function styleIsValid(In $in, Style $style): bool + private function styleIsValidForLocation(In $in, Style $style): bool { return in_array( $style, @@ -151,7 +183,7 @@ private function styleIsValid(In $in, Style $style): bool * @param array $content * @return array */ - private function getContent( + private function validateContent( Identifier $identifier, string $name, array $content, diff --git a/tests/ValueObject/Valid/V30/ParameterTest.php b/tests/ValueObject/Valid/V30/ParameterTest.php index 56cbb3e..8d6e8b7 100644 --- a/tests/ValueObject/Valid/V30/ParameterTest.php +++ b/tests/ValueObject/Valid/V30/ParameterTest.php @@ -93,7 +93,7 @@ public static function provideInvalidPartialParameters(): Generator ); yield 'invalid "in"' => $case( - InvalidOpenAPI::parameterInvalidLocation($parentIdentifier->append($name, 'Wonderland')), + InvalidOpenAPI::parameterInvalidLocation($parentIdentifier), ['in' => 'Wonderland'] ); From 03f386f1ecb97125772a598ff68e9dcd68469d25 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 18 Jan 2024 17:46:34 +0000 Subject: [PATCH 10/56] Add Server Objects - Add Server Variable Objects - Add Membrane Reader - Add CebeReader which takes original Reader logic - Refactor Reader to extend CebeReader --- infection.json5 => infection.json | 8 +- src/CebeReader.php | 95 +++ src/Exception/CannotSupport.php | 6 + src/Exception/InvalidOpenAPI.php | 82 ++ src/Factory/V30/FromCebe.php | 46 +- src/MembraneReader.php | 99 +++ src/Reader.php | 80 +- src/ValueObject/Partial/OpenAPI.php | 2 + src/ValueObject/Partial/Operation.php | 2 + src/ValueObject/Partial/PathItem.php | 2 + src/ValueObject/Partial/Server.php | 17 + src/ValueObject/Partial/ServerVariable.php | 16 + src/ValueObject/Valid/V30/OpenAPI.php | 63 +- src/ValueObject/Valid/V30/Operation.php | 34 + src/ValueObject/Valid/V30/PathItem.php | 39 +- src/ValueObject/Valid/V30/Server.php | 146 ++++ src/ValueObject/Valid/V30/ServerVariable.php | 56 ++ src/ValueObject/Valid/Warning.php | 11 +- tests/Factory/V30/FromCebeTest.php | 102 +-- tests/MembraneReaderTest.php | 732 ++++++++++++++++++ tests/ReaderTest.php | 46 +- tests/ValueObject/Valid/V30/OpenAPITest.php | 22 + tests/ValueObject/Valid/V30/OperationTest.php | 71 +- tests/ValueObject/Valid/V30/PathItemTest.php | 64 +- tests/ValueObject/Valid/V30/ServerTest.php | 236 ++++++ .../Valid/V30/ServerVariableTest.php | 88 +++ tests/fixtures/Helper/OpenAPIProvider.php | 388 ++++++++++ tests/fixtures/Helper/PartialHelper.php | 30 + 28 files changed, 2376 insertions(+), 207 deletions(-) rename infection.json5 => infection.json (69%) create mode 100644 src/CebeReader.php create mode 100644 src/MembraneReader.php create mode 100644 src/ValueObject/Partial/Server.php create mode 100644 src/ValueObject/Partial/ServerVariable.php create mode 100644 src/ValueObject/Valid/V30/Server.php create mode 100644 src/ValueObject/Valid/V30/ServerVariable.php create mode 100644 tests/MembraneReaderTest.php create mode 100644 tests/ValueObject/Valid/V30/ServerTest.php create mode 100644 tests/ValueObject/Valid/V30/ServerVariableTest.php create mode 100644 tests/fixtures/Helper/OpenAPIProvider.php diff --git a/infection.json5 b/infection.json similarity index 69% rename from infection.json5 rename to infection.json index 9794798..771b5b6 100644 --- a/infection.json5 +++ b/infection.json @@ -5,9 +5,9 @@ "src" ] }, - minCoveredMsi: 90, - minMsi: 80, + "minCoveredMsi": 90, + "minMsi": 80, "mutators": { - "@default": true, - }, + "@default": true + } } diff --git a/src/CebeReader.php b/src/CebeReader.php new file mode 100644 index 0000000..086f5a1 --- /dev/null +++ b/src/CebeReader.php @@ -0,0 +1,95 @@ +supportedVersions)) { + throw CannotSupport::noSupportedVersions(); + } + (fn (OpenAPIVersion ...$versions) => null)(...$this->supportedVersions); + } + + public function readFromAbsoluteFilePath(string $absoluteFilePath, ?FileFormat $fileFormat = null): CebeSpec\OpenApi + { + file_exists($absoluteFilePath) ?: throw CannotRead::fileNotFound($absoluteFilePath); + + $fileFormat ??= FileFormat::fromFileExtension(pathinfo($absoluteFilePath, PATHINFO_EXTENSION)); + + try { + $openAPI = $this->getCebeObject(match ($fileFormat) { + FileFormat::Json => fn() => Cebe\Reader::readFromJsonFile($absoluteFilePath), + FileFormat::Yaml => fn() => Cebe\Reader::readFromYamlFile($absoluteFilePath), + default => throw CannotRead::unrecognizedFileFormat($absoluteFilePath) + }); + } catch (CebeException\UnresolvableReferenceException $e) { + throw CannotRead::unresolvedReference($e); + } + + $this->validate($openAPI); + + return $openAPI; + } + + public function readFromString(string $openAPI, FileFormat $fileFormat): CebeSpec\OpenApi + { + if (preg_match('#\s*[\'\"]?\$ref[\'\"]?\s*:\s*[\'\"]?[^\s\'\"\#]#', $openAPI)) { + throw CannotRead::cannotResolveExternalReferencesFromString(); + } + + $openAPI = $this->getCebeObject(match ($fileFormat) { + FileFormat::Json => fn() => Cebe\Reader::readFromJson($openAPI), + FileFormat::Yaml => fn() => Cebe\Reader::readFromYaml($openAPI), + }); + + try { + $openAPI->resolveReferences(new Cebe\ReferenceContext($openAPI, '/tmp')); + } catch (CebeException\UnresolvableReferenceException $e) { + throw CannotRead::unresolvedReference($e); + } + + $this->validate($openAPI); + + return $openAPI; + } + + /** @param Closure():CebeSpec\OpenApi $readOpenAPI */ + private function getCebeObject(Closure $readOpenAPI): CebeSpec\OpenApi + { + try { + return $readOpenAPI(); + } catch (TypeError | CebeException\TypeErrorException | ParseException $e) { + throw CannotRead::invalidFormatting($e); + } + } + + private function validate(CebeSpec\OpenApi $openAPI): void + { + $this->isVersionSupported($openAPI->openapi) ?: throw CannotSupport::unsupportedVersion($openAPI->openapi); + + /** Currently only 3.0 Validated Objects exist */ + if (OpenAPIVersion::fromString($openAPI->openapi) === OpenAPIVersion::Version_3_0) { + FromCebe::createOpenAPI($openAPI); + } + + $openAPI->validate() ?: throw InvalidOpenAPI::failedCebeValidation(...$openAPI->getErrors()); + } + + private function isVersionSupported(string $version): bool + { + return in_array(OpenAPIVersion::fromString($version), $this->supportedVersions, true); + } +} diff --git a/src/Exception/CannotSupport.php b/src/Exception/CannotSupport.php index 786ba75..65a05d6 100644 --- a/src/Exception/CannotSupport.php +++ b/src/Exception/CannotSupport.php @@ -29,6 +29,12 @@ public static function unsupportedMethod(string $pathUrl, string $method): self return new self($message, self::UNSUPPORTED_METHOD); } + public static function membraneReaderOnlySupportsv30(): self + { + $message = 'MembraneReader currently only supports Version 3.0.X'; + return new self($message, self::UNSUPPORTED_VERSION); + } + public static function noSupportedVersions(): self { $message = 'Reader cannot be constructed without any OpenAPI versions to support'; diff --git a/src/Exception/InvalidOpenAPI.php b/src/Exception/InvalidOpenAPI.php index 14107b6..b04dce4 100644 --- a/src/Exception/InvalidOpenAPI.php +++ b/src/Exception/InvalidOpenAPI.php @@ -63,6 +63,88 @@ public static function pathMissingEndPoint(Identifier $identifier): self return new self($message); } + public static function serverMissingUrl(Identifier $identifier): self + { + $message = << sprintf('- "%s"', $v), $variables) + ); + $message = <<openapi, $openApi->info?->title, // @phpstan-ignore-line $openApi->info?->version, // @phpstan-ignore-line + self::createServers($openApi->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[] @@ -44,6 +84,7 @@ private static function createPaths(?Cebe\Paths $paths): array 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), @@ -136,8 +177,9 @@ private static function createOperation( } return new Operation( - $operation->operationId, - self::createParameters($operation->parameters) + operationId: $operation->operationId, + servers: self::createServers($operation->servers), + parameters: self::createParameters($operation->parameters) ); } } diff --git a/src/MembraneReader.php b/src/MembraneReader.php new file mode 100644 index 0000000..a7c5207 --- /dev/null +++ b/src/MembraneReader.php @@ -0,0 +1,99 @@ +supportedVersions)) { + throw CannotSupport::noSupportedVersions(); + } + + if ($this->supportedVersions !== [OpenAPIVersion::Version_3_0]) { + throw CannotSupport::membraneReaderOnlySupportsv30(); + } + } + + public function readFromAbsoluteFilePath(string $absoluteFilePath, ?FileFormat $fileFormat = null): OpenAPI + { + file_exists($absoluteFilePath) ?: throw CannotRead::fileNotFound($absoluteFilePath); + + $fileFormat ??= FileFormat::fromFileExtension(pathinfo($absoluteFilePath, PATHINFO_EXTENSION)); + + try { + $openAPI = $this->getCebeObject(match ($fileFormat) { + FileFormat::Json => fn() => Cebe\Reader::readFromJsonFile($absoluteFilePath), + FileFormat::Yaml => fn() => Cebe\Reader::readFromYamlFile($absoluteFilePath), + default => throw CannotRead::unrecognizedFileFormat($absoluteFilePath) + }); + } catch (CebeException\UnresolvableReferenceException $e) { + throw CannotRead::unresolvedReference($e); + } + + return $this->getValidatedObject($openAPI); + } + + public function readFromString(string $openAPI, FileFormat $fileFormat): OpenAPI + { + if (preg_match('#\s*[\'\"]?\$ref[\'\"]?\s*:\s*[\'\"]?[^\s\'\"\#]#', $openAPI)) { + throw CannotRead::cannotResolveExternalReferencesFromString(); + } + + $openAPI = $this->getCebeObject(match ($fileFormat) { + FileFormat::Json => fn() => Cebe\Reader::readFromJson($openAPI), + FileFormat::Yaml => fn() => Cebe\Reader::readFromYaml($openAPI), + }); + + try { + $openAPI->resolveReferences(new Cebe\ReferenceContext($openAPI, '/tmp')); + } catch (CebeException\UnresolvableReferenceException $e) { + throw CannotRead::unresolvedReference($e); + } + + return $this->getValidatedObject($openAPI); + } + + /** @param Closure():CebeSpec\OpenApi $readOpenAPI */ + private function getCebeObject(Closure $readOpenAPI): CebeSpec\OpenApi + { + try { + return $readOpenAPI(); + } catch (TypeError | CebeException\TypeErrorException | ParseException $e) { + throw CannotRead::invalidFormatting($e); + } + } + + private function getValidatedObject(CebeSpec\OpenApi $openAPI): OpenAPI + { + $this->isVersionSupported($openAPI->openapi) ?: throw CannotSupport::unsupportedVersion($openAPI->openapi); + + /** todo create 3.1 validated objects */ + if (OpenAPIVersion::fromString($openAPI->openapi) !== OpenAPIVersion::Version_3_0) { + throw CannotSupport::membraneReaderOnlySupportsv30(); + } + + $validatedObject = FromCebe::createOpenAPI($openAPI); + + $openAPI->validate() ?: throw InvalidOpenAPI::failedCebeValidation(...$openAPI->getErrors()); + + return $validatedObject; + } + + private function isVersionSupported(string $version): bool + { + return in_array(OpenAPIVersion::fromString($version), $this->supportedVersions, true); + } +} diff --git a/src/Reader.php b/src/Reader.php index fbc9daf..58316fa 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -4,84 +4,6 @@ namespace Membrane\OpenAPIReader; -use cebe\{openapi as Cebe, openapi\exceptions as CebeException, openapi\spec as CebeSpec}; -use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; -use Membrane\OpenAPIReader\Factory\V30\FromCebe; -use Symfony\Component\Yaml\Exception\ParseException; -use TypeError; - -final class Reader +final class Reader extends CebeReader { - /** @param OpenAPIVersion[] $supportedVersions */ - public function __construct( - private readonly array $supportedVersions, - ) { - if (empty($this->supportedVersions)) { - throw CannotSupport::noSupportedVersions(); - } - (fn (OpenAPIVersion ...$versions) => null)(...$this->supportedVersions); - } - - public function readFromAbsoluteFilePath(string $absoluteFilePath, ?FileFormat $fileFormat = null): CebeSpec\OpenApi - { - file_exists($absoluteFilePath) ?: throw CannotRead::fileNotFound($absoluteFilePath); - - $fileFormat ??= FileFormat::fromFileExtension(pathinfo($absoluteFilePath, PATHINFO_EXTENSION)); - - try { - $openAPI = match ($fileFormat) { - FileFormat::Json => Cebe\Reader::readFromJsonFile($absoluteFilePath), - FileFormat::Yaml => Cebe\Reader::readFromYamlFile($absoluteFilePath), - default => throw CannotRead::unrecognizedFileFormat($absoluteFilePath) - }; - } catch (TypeError | CebeException\TypeErrorException | ParseException $e) { - throw CannotRead::invalidFormatting($e); - } catch (CebeException\UnresolvableReferenceException $e) { - throw CannotRead::unresolvedReference($e); - } - - $this->validate($openAPI); - - return $openAPI; - } - - public function readFromString(string $openAPI, FileFormat $fileFormat): CebeSpec\OpenApi - { - if (preg_match('#\s*[\'\"]?\$ref[\'\"]?\s*:\s*[\'\"]?[^\s\'\"\#]#', $openAPI)) { - throw CannotRead::cannotResolveExternalReferencesFromString(); - } - - try { - $openAPI = match ($fileFormat) { - FileFormat::Json => Cebe\Reader::readFromJson($openAPI), - FileFormat::Yaml => Cebe\Reader::readFromYaml($openAPI), - }; - } catch (TypeError | CebeException\TypeErrorException | ParseException $e) { - throw CannotRead::invalidFormatting($e); - } - - try { - $openAPI->resolveReferences(new Cebe\ReferenceContext($openAPI, '/tmp')); - } catch (CebeException\UnresolvableReferenceException $e) { - throw CannotRead::unresolvedReference($e); - } - - $this->validate($openAPI); - - return $openAPI; - } - - private function validate(CebeSpec\OpenApi $openAPI): void - { - $this->isVersionSupported($openAPI->openapi) ?: throw CannotSupport::unsupportedVersion($openAPI->openapi); - - FromCebe::createOpenAPI($openAPI); - - $openAPI->validate() ?: throw InvalidOpenAPI::failedCebeValidation(...$openAPI->getErrors()); - } - - private function isVersionSupported(string $version): bool - { - return in_array(OpenAPIVersion::fromString($version), $this->supportedVersions, true); - } } diff --git a/src/ValueObject/Partial/OpenAPI.php b/src/ValueObject/Partial/OpenAPI.php index 4c485ff..751cc50 100644 --- a/src/ValueObject/Partial/OpenAPI.php +++ b/src/ValueObject/Partial/OpenAPI.php @@ -10,12 +10,14 @@ final class OpenAPI * @param ?string $openAPI Specification implementation * @param ?string $title of document * @param ?string $version of document + * @param Server[] $servers * @param PathItem[] $paths */ public function __construct( public ?string $openAPI = null, public ?string $title = null, public ?string $version = null, + public array $servers = [], public ?array $paths = null, ) { } diff --git a/src/ValueObject/Partial/Operation.php b/src/ValueObject/Partial/Operation.php index 8e4dbc1..370f9fa 100644 --- a/src/ValueObject/Partial/Operation.php +++ b/src/ValueObject/Partial/Operation.php @@ -7,10 +7,12 @@ final class Operation { /** + * @param Server[] $servers * @param Parameter[] $parameters */ public function __construct( public ?string $operationId = null, + public array $servers = [], public array $parameters = [], ) { } diff --git a/src/ValueObject/Partial/PathItem.php b/src/ValueObject/Partial/PathItem.php index 053f08d..fd4d651 100644 --- a/src/ValueObject/Partial/PathItem.php +++ b/src/ValueObject/Partial/PathItem.php @@ -8,10 +8,12 @@ final class PathItem { /** * @param ?string $path to PathItem + * @param Server[] $servers * @param Parameter[] $parameters specified on PathItem */ public function __construct( public ?string $path = null, + public array $servers = [], public array $parameters = [], public ?Operation $get = null, public ?Operation $put = null, diff --git a/src/ValueObject/Partial/Server.php b/src/ValueObject/Partial/Server.php new file mode 100644 index 0000000..d5eeedc --- /dev/null +++ b/src/ValueObject/Partial/Server.php @@ -0,0 +1,17 @@ + + */ + public readonly array $servers; + /** * REQUIRED * It may be empty due to ACL constraints @@ -32,32 +40,60 @@ public function __construct(Partial\OpenAPI $openAPI) throw InvalidOpenAPI::missingOpenAPIVersion($this->getIdentifier()); } - if (!isset($openAPI->paths)) { - throw InvalidOpenAPI::missingPaths($this->getIdentifier()); - } + $this->servers = $this->validateServers( + $this->getIdentifier(), + $openAPI->servers + ); - if (empty($openAPI->paths)) { - $this->addWarning('No Paths in OpenAPI', Warning::EMPTY_PATHS); + $this->paths = $this->validatePaths( + $this->getIdentifier(), + $openAPI->paths, + ); + } + + /** + * @param Partial\Server[] $servers + * @return array> + */ + private function validateServers( + Identifier $identifier, + array $servers + ): array { + if (empty($servers)) { + $servers = [new Partial\Server('/')]; } - $this->paths = $this->validatePaths($openAPI->paths); + return array_values( + array_map(fn($s) => new Server($identifier, $s), $servers) + ); } - /** - * @param Partial\PathItem[] $pathItems + /** + * @param null|Partial\PathItem[] $pathItems * @return array */ - private function validatePaths(array $pathItems): array - { + private function validatePaths( + Identifier $identifier, + ?array $pathItems + ): array { + if (is_null($pathItems)) { + throw InvalidOpenAPI::missingPaths($identifier); + } + + if (empty($pathItems)) { + $this->addWarning('No Paths in OpenAPI', Warning::EMPTY_PATHS); + } + $result = []; + foreach ($pathItems as $pathItem) { if (!isset($pathItem->path)) { - throw InvalidOpenAPI::pathMissingEndPoint($this->getIdentifier()); + throw InvalidOpenAPI::pathMissingEndPoint($identifier); } if (!str_starts_with($pathItem->path, '/')) { throw InvalidOpenAPI::forwardSlashMustPrecedePath( - $this->getIdentifier(), + $identifier, $pathItem->path ); } @@ -68,7 +104,8 @@ private function validatePaths(array $pathItems): array } $result[$pathItem->path] = new PathItem( - $this->getIdentifier()->append($pathItem->path), + $identifier->append($pathItem->path), + $this->servers, $pathItem ); } diff --git a/src/ValueObject/Valid/V30/Operation.php b/src/ValueObject/Valid/V30/Operation.php index 51c67c1..7ce6288 100644 --- a/src/ValueObject/Valid/V30/Operation.php +++ b/src/ValueObject/Valid/V30/Operation.php @@ -16,6 +16,13 @@ final class Operation extends Validated { + /** + * Optional, may be left empty. + * If empty or unspecified, the array will contain the Path level servers + * @var array + */ + public readonly array $servers; + /** * The list MUST NOT include duplicated parameters. * A unique parameter is defined by a combination of a name and location. @@ -30,10 +37,12 @@ final class Operation extends Validated public readonly string $operationId; /** + * @param Server[] $pathServers * @param Parameter[] $pathParameters */ public function __construct( Identifier $parentIdentifier, + array $pathServers, array $pathParameters, Method $method, Partial\Operation $operation, @@ -46,6 +55,12 @@ public function __construct( parent::__construct($parentIdentifier->append("$this->operationId($method->value)")); + $this->servers = $this->validateServers( + $this->getIdentifier(), + $pathServers, + $operation->servers, + ); + $this->parameters = $this->validateParameters( $pathParameters, $operation->parameters @@ -59,6 +74,25 @@ public function __construct( } } + /** + * @param array $pathServers + * @param Partial\Server[] $operationServers + * @return array> + */ + private function validateServers( + Identifier $identifier, + array $pathServers, + array $operationServers + ): array { + if (empty($operationServers)) { + return $pathServers; + } + + return array_values( + array_map(fn($s) => new Server($identifier, $s), $operationServers) + ); + } + /** * @param Parameter[] $pathParameters * @param Partial\Parameter[] $operationParameters diff --git a/src/ValueObject/Valid/V30/PathItem.php b/src/ValueObject/Valid/V30/PathItem.php index 007d2ab..c14450b 100644 --- a/src/ValueObject/Valid/V30/PathItem.php +++ b/src/ValueObject/Valid/V30/PathItem.php @@ -13,10 +13,17 @@ final class PathItem extends Validated { + /** + * Optional, may be left empty. + * If empty or unspecified, the array will contain the OpenAPI level servers + * @var array + */ + public readonly array $servers; + /** * The list MUST NOT include duplicated parameters. * A unique parameter is defined by a combination of a name and location. - * @var Parameter[] + * @var array */ public readonly array $parameters; @@ -29,12 +36,22 @@ final class PathItem extends Validated public readonly ?Operation $patch; public readonly ?Operation $trace; + /** + * @param array $openAPIServers + */ public function __construct( Identifier $identifier, + array $openAPIServers, Partial\PathItem $pathItem, ) { parent::__construct($identifier); + $this->servers = $this->validateServers( + $identifier, + $openAPIServers, + $pathItem->servers, + ); + $this->parameters = $this->validateParameters($pathItem->parameters); $this->get = $this->validateOperation(Method::GET, $pathItem->get); @@ -70,6 +87,25 @@ public function getOperations(): array ); } + /** + * @param array $openAPIServers + * @param Partial\Server[] $pathServers + * @return array> + */ + private 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 Partial\Parameter[] $parameters * @return Parameter[] @@ -153,6 +189,7 @@ private function validateOperation( return new Operation( $this->getIdentifier(), + $this->servers, $this->parameters, $method, $operation diff --git a/src/ValueObject/Valid/V30/Server.php b/src/ValueObject/Valid/V30/Server.php new file mode 100644 index 0000000..fec065a --- /dev/null +++ b/src/ValueObject/Valid/V30/Server.php @@ -0,0 +1,146 @@ + + */ + public readonly array $variables; + + public function __construct( + Identifier $parentIdentifier, + Partial\Server $server, + ) { + if (!isset($server->url)) { + throw InvalidOpenAPI::serverMissingUrl($parentIdentifier); + } + + $this->url = $this->validateUrl($parentIdentifier, $server->url); + + parent::__construct($parentIdentifier->append($server->url)); + + $this->variables = $this->validateVariables( + $this->getIdentifier(), + $this->getVariableNames(), + $server->variables, + ); + } + + public function hasVariables(): bool + { + return preg_match('#{[^/]+}#', $this->url) === 1; + } + + /** + * Returns the list of variable names in order of appearance within the URL. + * @return array + */ + public function getVariableNames(): array + { + preg_match_all('#{[^/]+}#', $this->url, $result); + + return array_map(fn($v) => trim($v, '{}'), $result[0]); + } + + /** + * Returns the regex of the URL + */ + public function getPattern(): string + { + $regex = preg_replace('#{[^/]+}#', '([^/]+)', $this->url); + assert(is_string($regex)); + return $regex; + } + + private function validateUrl(Identifier $identifier, string $url): string + { + $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; + } + } + + if ($insideVariable) { + throw InvalidOpenAPI::urlUnclosedVariable($identifier->append($url)); + } + + return $url; + } + + /** + * @param array $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/V30/ServerVariable.php b/src/ValueObject/Valid/V30/ServerVariable.php new file mode 100644 index 0000000..1d9e33e --- /dev/null +++ b/src/ValueObject/Valid/V30/ServerVariable.php @@ -0,0 +1,56 @@ +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/src/ValueObject/Valid/Warning.php b/src/ValueObject/Valid/Warning.php index 695f122..b2b4dcd 100644 --- a/src/ValueObject/Valid/Warning.php +++ b/src/ValueObject/Valid/Warning.php @@ -33,12 +33,17 @@ final class Warning public const NO_VALID_SERVERS = 'no-valid-servers'; /** - * Path Item: "head" - * Path Item: "options" specifies what HTTP methods are available, this is what your OpenAPI already does. - * Path Item: "trace" + * Path Item: + * - "head", "options" and "trace" are not particularly valuable in an OpenAPI. + * - For example: "options" specifies what HTTP methods are available, this is what your OpenAPI already does. */ public const REDUNDANT_METHOD = 'redundant-method'; + /** + * Server: If the "url" does not name the variable, it cannot be provided. + */ + public const REDUNDANT_VARIABLE = 'redundant-variable'; + /** * Path Item, Operation: "parameters" can have identical/similar names, but this could be quite confusing. */ diff --git a/tests/Factory/V30/FromCebeTest.php b/tests/Factory/V30/FromCebeTest.php index 20cd68c..11d0db0 100644 --- a/tests/Factory/V30/FromCebeTest.php +++ b/tests/Factory/V30/FromCebeTest.php @@ -8,7 +8,7 @@ use Generator; use Membrane\OpenAPIReader\Factory\V30\FromCebe; use Membrane\OpenAPIReader\Method; -use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; +use Membrane\OpenAPIReader\Tests\Fixtures\Helper\OpenAPIProvider; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\V30\MediaType; @@ -17,6 +17,7 @@ use Membrane\OpenAPIReader\ValueObject\Valid\V30\Parameter; use Membrane\OpenAPIReader\ValueObject\Valid\V30\PathItem; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Schema; +use Membrane\OpenAPIReader\ValueObject\Valid\V30\Server; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; use Membrane\OpenAPIReader\ValueObject\Valid\Warning; use Membrane\OpenAPIReader\ValueObject\Valid\Warnings; @@ -29,6 +30,8 @@ #[CoversClass(FromCebe::class)] #[UsesClass(OpenAPI::class)] #[UsesClass(Partial\OpenAPI::class)] +#[UsesClass(Server::class)] +#[UsesClass(Partial\Server::class)] #[UsesClass(PathItem::class)] #[UsesClass(Partial\PathItem::class)] #[UsesClass(Operation::class)] @@ -57,102 +60,13 @@ public function itConstructsValidOpenAPIObjects( public static function provideCebeOpenAPIObjects(): Generator { yield 'minimal OpenAPI' => [ - new OpenAPI(PartialHelper::createOpenAPI( - openapi: '3.0.0', - title: 'Test API', - version: '1.0.1', - paths: [] - )), - new Cebe\OpenApi([ - 'openapi' => '3.0.0', - 'info' => ['title' => 'Test API', 'version' => '1.0.1'], - 'paths' => [], - ]) + OpenAPIProvider::minimalV30MembraneObject(), + OpenAPIProvider::minimalV30CebeObject(), ]; yield 'detailed OpenAPI' => [ - new OpenAPI(PartialHelper::createOpenAPI( - openapi: '3.0.0', - title: 'Test API', - version: '1.0.1', - paths: [ - PartialHelper::createPathItem( - path: '/first', - parameters: [ - PartialHelper::createParameter( - name: 'limit', - in: 'query', - required: false, - schema: PartialHelper::createSchema( - type: 'integer' - ) - ), - ], - get: PartialHelper::createOperation( - operationId: 'test-id', - parameters: [ - PartialHelper::createParameter( - name: 'pet', - in: 'header', - required: true, - schema: null, - content: [ - PartialHelper::createMediaType( - mediaType: 'application/json', - schema: PartialHelper::createSchema( - allOf: [ - PartialHelper::createSchema( - type: 'integer' - ), - PartialHelper::createSchema( - type: 'number' - ) - ] - ) - ) - ] - ) - ] - ) - ) - ] - )), - new Cebe\OpenApi([ - 'openapi' => '3.0.0', - 'info' => ['title' => 'Test API', 'version' => '1.0.1'], - 'paths' => [ - '/first' => [ - 'parameters' => [ - [ - 'name' => 'limit', - 'in' => 'query', - 'required' => false, - 'schema' => ['type' => 'integer'] - ] - ], - 'get' => [ - 'operationId' => 'test-id', - 'parameters' => [ - [ - 'name' => 'pet', - 'in' => 'header', - 'required' => true, - 'content' => [ - 'application/json' => [ - 'schema' => [ - 'allOf' => [ - ['type' => 'integer'], - ['type' => 'number'] - ] - ] - ] - ] - ] - ] - ] - ] - ], - ]) + OpenAPIProvider::detailedV30MembraneObject(), + OpenAPIProvider::detailedV30CebeObject(), ]; } } diff --git a/tests/MembraneReaderTest.php b/tests/MembraneReaderTest.php new file mode 100644 index 0000000..810fa11 --- /dev/null +++ b/tests/MembraneReaderTest.php @@ -0,0 +1,732 @@ +url() . '/openapi'; + + self::assertFalse(file_exists($filePath)); + + self::expectExceptionObject(CannotRead::fileNotFound($filePath)); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath($filePath, FileFormat::Json); + } + + #[Test, TestDox('It cannot resolve references from relative filepaths')] + public function itCannotReadFromRelativeFilePaths(): void + { + $filePath = './tests/fixtures/petstore.yaml'; + + self::assertTrue(file_exists($filePath)); + + self::expectExceptionObject( + CannotRead::unresolvedReference(new CebeException\UnresolvableReferenceException()) + ); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath($filePath); + } + + #[Test] + public function itCannotSupportUnspecifiedOpenAPIVersionsFromAbsoluteFilePath(): void + { + $openAPIArray = ['openapi' => '3.1.0', 'info' => ['title' => '', 'version' => '1.0.0'], 'paths' => []]; + $filePath = vfsStream::setup()->url() . '/openapi'; + file_put_contents($filePath, json_encode($openAPIArray)); + + self::expectExceptionObject(CannotSupport::unsupportedVersion($openAPIArray['openapi'])); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath($filePath, FileFormat::Json); + } + + #[Test] + public function itCannotSupportUnspecifiedOpenAPIVersionsFromString(): void + { + $openAPIArray = ['openapi' => '3.1.0', 'info' => ['title' => '', 'version' => '1.0.0'], 'paths' => []]; + self::expectExceptionObject(CannotSupport::unsupportedVersion($openAPIArray['openapi'])); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromString(json_encode($openAPIArray), FileFormat::Json); + } + + #[Test] + public function itCanSupportSpecifiedOpenAPIVersionsFromAbsoluteFilePath(): void + { + $openAPIArray = ['openapi' => '3.0.0', 'info' => ['title' => '', 'version' => '1.0.0'], 'paths' => []]; + $filePath = vfsStream::setup()->url() . '/openapi'; + file_put_contents($filePath, json_encode($openAPIArray)); + + $openAPIObject = (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath($filePath, FileFormat::Json); + + self::assertInstanceOf(Valid\V30\OpenAPI::class, $openAPIObject); + } + + #[Test] + public function itCanSupportSpecifiedOpenAPIVersionsFromString(): void + { + $openAPIArray = ['openapi' => '3.0.0', 'info' => ['title' => '', 'version' => '1.0.0'], 'paths' => []]; + $openAPIObject = (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromString(json_encode($openAPIArray), FileFormat::Json); + + self::assertInstanceOf(Valid\V30\OpenAPI::class, $openAPIObject); + } + + #[Test] + public function itCannotSupportUnrecognizedFileFormats(): void + { + self::assertNull(FileFormat::fromFileExtension(pathinfo(__FILE__, PATHINFO_EXTENSION))); + + self::expectExceptionObject(CannotRead::unrecognizedFileFormat(__FILE__)); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath(__FILE__); + } + + #[Test] + #[DataProvider('provideInvalidFormatting')] + public function itCannotReadInvalidFormattingFromAbsoluteFilePaths( + string $openAPIString, + FileFormat $fileFormat + ): void { + $filePath = vfsStream::setup()->url() . '/api'; + file_put_contents($filePath, $openAPIString); + + self::expectExceptionObject(CannotRead::invalidFormatting(new TypeError())); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath($filePath, $fileFormat); + } + + #[Test] + #[DataProvider('provideInvalidFormatting')] + public function itCannotReadInvalidFormattingFromString( + string $openAPIString, + FileFormat $fileFormat + ): void { + self::expectExceptionObject(CannotRead::invalidFormatting(new TypeError())); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromString($openAPIString, $fileFormat); + } + + #[Test] + #[DataProvider('provideInvalidOpenAPIs')] + public function itWillNotProcessInvalidOpenAPIFromAbsoluteFilePath( + string $openAPIString, + InvalidOpenAPI $expectedException + ): void { + $filePath = vfsStream::setup()->url() . '/openapi.json'; + file_put_contents($filePath, $openAPIString); + + self::expectExceptionObject($expectedException); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath($filePath, FileFormat::Json); + } + + #[Test] + #[DataProvider('provideInvalidOpenAPIs')] + public function itWillNotProcessInvalidOpenAPIFromString(string $openAPIString, InvalidOpenAPI $expectedException): void + { + self::expectExceptionObject($expectedException); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromString($openAPIString, FileFormat::Json); + } + + #[Test, TestDox('Membrane requires operationIds for caching and routing')] + public function itCannotSupportMissingOperationIds(): void + { + $openAPIString = json_encode([ + 'openapi' => '3.0.0', + 'info' => ['title' => '', 'version' => '1.0.0'], + 'paths' => [ + '/path' => [ + 'get' => [ + // Missing operationId here + 'responses' => [200 => ['description' => ' Successful Response']], + ], + ], + ], + ]); + + self::expectExceptionObject(CannotSupport::missingOperationId('/path', 'get')); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromString($openAPIString, FileFormat::Json); + } + + #[Test, DataProvider('provideOpenAPIWithInternalReference')] + public function itResolvesInternalReferencesFromAbsoluteFilePath(string $openAPIString): void + { + vfsStream::setup(); + file_put_contents(vfsStream::url('root/openapi.json'), $openAPIString); + + $openAPIObject = (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath(vfsStream::url('root/openapi.json'), FileFormat::Json); + + self::assertInstanceOf( + Valid\V30\Schema::class, + $openAPIObject->paths['/path']->get?->parameters[0]?->schema + ); + } + + #[Test, DataProvider('provideOpenAPIWithInternalReference')] + public function itResolvesInternalReferencesFromString(string $openAPIString): void + { + $openAPIObject = (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromString($openAPIString, FileFormat::Yaml); + + self::assertInstanceOf( + Valid\V30\Schema::class, + $openAPIObject->paths['/path']->get?->parameters[0]?->schema + ); + } + + #[Test] + #[DataProvider('provideOpenAPIWithExternalReference')] + public function itResolvesExternalReferencesFromAbsoluteFilePath( + string $openAPIString, + string $externalReference + ): void { + vfsStream::setup(); + file_put_contents(vfsStream::url('root/openapi.json'), $openAPIString); + file_put_contents(vfsStream::url('root/' . $externalReference), '{"type":"integer"}'); + + $openAPIObject = (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath(vfsStream::url('root/openapi.json')); + + self::assertInstanceOf( + Valid\V30\Schema::class, + $openAPIObject->paths['/path']->get?->parameters[0]?->schema + ); + } + + #[Test] + #[DataProvider('provideOpenAPIWithExternalReference')] + public function itCannotResolveExternalReferenceFromString(string $openAPIString): void + { + self::expectExceptionObject(CannotRead::cannotResolveExternalReferencesFromString()); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromString($openAPIString, FileFormat::Json); + } + + #[Test] + #[DataProvider('provideOpenAPIWithInvalidReference')] + public function itCannotResolveInvalidReferenceFromFile(string $openAPIString): void + { + vfsStream::setup(); + file_put_contents(vfsStream::url('root/openapi.json'), $openAPIString); + + self::expectExceptionObject(CannotRead::unresolvedReference(new CebeException\UnresolvableReferenceException())); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath(vfsStream::url('root/openapi.json')); + } + + #[Test] + #[DataProvider('provideOpenAPIWithInvalidReference')] + public function itCannotResolveInvalidReferenceFromString(string $openAPIString,): void + { + self::expectExceptionObject(CannotRead::unresolvedReference(new CebeException\UnresolvableReferenceException())); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromString($openAPIString, FileFormat::Json); + } + + #[Test, DataProvider('provideConflictingParameters')] + #[TestDox('It cannot support multiple parameters with the potential to conflict.')] + public function itCannotSupportAmbiguousResolution( + string $openAPIString, + CannotSupport $expected + ): void { + $filePath = vfsStream::setup()->url() . '/openapi.json'; + file_put_contents($filePath, $openAPIString); + + self::expectExceptionObject($expected); + + (new MembraneReader([OpenAPIVersion::Version_3_0])) + ->readFromAbsoluteFilePath($filePath, FileFormat::Json); + } + + #[Test, DataProvider('provideOpenAPIToRead')] + public function itReadsFromFile(OpenAPI $expected, string $openApi): void + { + $filePath = vfsStream::setup()->url() . '/openapi.json'; + file_put_contents($filePath, $openApi); + + $sut = new MembraneReader([OpenAPIVersion::Version_3_0]); + + $actual = $sut->readFromAbsoluteFilePath($filePath, FileFormat::Json); + + self::assertEquals($expected, $actual); + } + + #[Test, DataProvider('provideOpenAPIToRead')] + public function itReadsFromString(OpenAPI $expected, string $openApi): void + { + $sut = new MembraneReader([OpenAPIVersion::Version_3_0]); + + $actual = $sut->readFromString($openApi, FileFormat::Json); + + self::assertEquals($expected, $actual); + } + + public static function provideInvalidFormatting(): Generator + { + yield 'Empty string to be interpreted as json' => ['', FileFormat::Json]; + yield 'Empty string to be interpreted as yaml' => ['', FileFormat::Yaml]; + yield 'Invalid json format' => ['{openapi: ",', FileFormat::Json]; + yield 'Invalid yaml format' => ['---openapi: ",- title: "invalid"', FileFormat::Yaml]; + yield 'info is a string rather than an array' => [ + json_encode(['openapi' => '3.0.0', 'info' => 'hold on what is this?', 'paths' => []]), + FileFormat::Json + ]; + } + + public static function provideInvalidOpenAPIs(): Generator + { + yield 'Invalid OpenAPI in valid json format' => (function () { + $jsonOpenAPIString = json_encode(['openapi' => '3.0.0']); + return [ + $jsonOpenAPIString, + InvalidOpenAPI::missingInfo(), + ]; + })(); + + $openAPI = ['openapi' => '3.0.0', 'info' => ['title' => '', 'version' => '1.0.0'], 'paths' => []]; + $openAPIPath = fn($operationId) => [ + 'operationId' => $operationId, + 'responses' => [200 => ['description' => ' Successful Response']], + ]; + + yield 'paths with same template' => [ + (function () use ($openAPI, $openAPIPath) { + $openAPIArray = $openAPI; + $openAPIArray['paths']['/path/{param1}'] = [ + 'get' => $openAPIPath('id-1'), + ]; + $openAPIArray['paths']['/path/{param2}'] = [ + 'get' => $openAPIPath('id-2'), + ]; + return json_encode($openAPIArray); + })(), + InvalidOpenAPI::equivalentTemplates( + new Identifier('(1.0.0)', '/path/{param1}'), + new Identifier('(1.0.0)', '/path/{param2}'), + ), + ]; + + yield 'duplicate operationIds on the same path' => [ + (function () use ($openAPI, $openAPIPath) { + $openAPIArray = $openAPI; + $openAPIArray['paths']['/path'] = [ + 'get' => $openAPIPath('duplicate-id'), + 'post' => $openAPIPath('duplicate-id'), + ]; + return json_encode($openAPIArray); + })(), + InvalidOpenAPI::duplicateOperationIds('duplicate-id', '/path', 'get', '/path', 'post'), + ]; + + yield 'duplicate operationIds on separate paths' => [ + (function () use ($openAPI, $openAPIPath) { + $openAPIArray = $openAPI; + $openAPIArray['paths'] = [ + '/firstpath' => ['get' => $openAPIPath('duplicate-id')], + '/secondpath' => ['get' => $openAPIPath('duplicate-id')], + ]; + return json_encode($openAPIArray); + })(), + InvalidOpenAPI::duplicateOperationIds('duplicate-id', '/firstpath', 'get', '/secondpath', 'get'), + ]; + + yield 'path with parameter missing both schema and content' => [ + (function () use ($openAPI, $openAPIPath) { + $openAPIArray = $openAPI; + $path = $openAPIPath('get-first-path'); + $path['parameters'] = [['name' => 'param', 'in' => 'query']]; + $openAPIArray['paths'] = ['/firstpath' => ['get' => $path]]; + return json_encode($openAPIArray); + })(), + InvalidOpenAPI::mustHaveSchemaXorContent('param'), + ]; + + yield 'path with parameter both schema and content' => [ + (function () use ($openAPI, $openAPIPath) { + $openAPIArray = $openAPI; + $openAPIArray['paths'] = ['/firstpath' => [ + 'parameters' => [[ + 'name' => 'param', + 'in' => 'query', + 'schema' => ['type' => 'string'], + 'content' => ['application/json' => ['type' => 'string']] + ]], + 'get' => $openAPIPath('get-first-path') + ],]; + return json_encode($openAPIArray); + })(), + InvalidOpenAPI::mustHaveSchemaXorContent('param'), + ]; + + yield 'path with operation missing both schema and content' => [ + (function () use ($openAPI, $openAPIPath) { + $openAPIArray = $openAPI; + $openAPIArray['paths'] = ['/firstpath' => [ + 'parameters' => [['name' => 'param', 'in' => 'query']], + 'get' => $openAPIPath('get-first-path') + ]]; + return json_encode($openAPIArray); + })(), + InvalidOpenAPI::mustHaveSchemaXorContent('param'), + ]; + + yield 'path with operation both schema and content' => [ + (function () use ($openAPI, $openAPIPath) { + $openAPIArray = $openAPI; + $path = $openAPIPath('get-first-path'); + $path['parameters'] = [[ + 'name' => 'param', + 'in' => 'query', + 'schema' => ['type' => 'string'], + 'content' => ['application/json' => ['type' => 'string']] + ]]; + $openAPIArray['paths'] = ['/firstpath' => ['get' => $path],]; + return json_encode($openAPIArray); + })(), + InvalidOpenAPI::mustHaveSchemaXorContent('param'), + ]; + } + + public static function provideOpenAPIWithInternalReference(): Generator + { + yield (function () { + return [ + json_encode([ + 'openapi' => '3.0.0', + 'info' => ['title' => 'API With Reference Object', 'version' => '1.0.0'], + 'paths' => [ + '/path' => [ + 'get' => [ + 'operationId' => 'get-path', + 'parameters' => [ + [ + 'name' => 'param', + 'in' => 'query', + 'schema' => [ + '$ref' => '#/components/schemas/object', + ] + ] + ], + 'responses' => [ + 200 => [ + 'description' => 'Successful Response', + ], + ], + ], + ], + ], + 'components' => [ + 'schemas' => [ + 'object' => [ + 'type' => 'object' + ] + ] + ] + ]), + ]; + })(); + } + + public static function provideOpenAPIWithExternalReference(): Generator + { + yield (function () { + $externalRef = 'schema.json'; + return [ + json_encode([ + 'openapi' => '3.0.0', + 'info' => ['title' => 'API With Reference Object', 'version' => '1.0.0'], + 'paths' => [ + '/path' => [ + 'get' => [ + 'operationId' => 'get-path', + 'parameters' => [ + [ + 'name' => 'param', + 'in' => 'query', + 'schema' => [ + '$ref' => $externalRef + ] + ] + ], + 'responses' => [ + 200 => [ + 'description' => 'Successful Response', + ], + ], + ], + ], + ], + ]), + $externalRef, + ]; + })(); + } + + public static function provideOpenAPIWithInvalidReference(): Generator + { + yield 'missing forward slash after hash' => [ + json_encode([ + 'openapi' => '3.0.0', + 'info' => ['title' => 'API With Reference Object', 'version' => '1.0.0'], + 'paths' => [ + '/path' => [ + 'get' => [ + 'operationId' => 'get-path', + 'responses' => [ + 200 => [ + 'description' => 'Successful Response', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#components/schemas/Test', + ], + ], + ], + ], + ], + ], + ], + ], + 'components' => [ + 'schemas' => [ + 'Test' => [ + 'type' => 'integer', + ] + ] + ] + ]), + ]; + } + + public static function provideConflictingParameters(): Generator + { + $openAPI = fn(array $path) => [ + 'openapi' => '3.0.0', + 'info' => ['title' => 'test-api', 'version' => '1.0.0'], + 'paths' => ['/path' => $path] + ]; + + $path = fn(array $parameters, array $operation) => [ + 'parameters' => $parameters, + 'get' => $operation, + ]; + + $operation = fn(array $data) => [ + ...$data, + 'responses' => [200 => ['description' => ' Successful Response']] + ]; + + yield 'operation with two spaceDelimited exploding arrays' => [ + json_encode( + $openAPI($path( + [], + $operation([ + 'operationId' => 'test-op', + 'parameters' => [ + [ + 'name' => 'param1', + 'in' => 'query', + 'explode' => true, + 'style' => 'spaceDelimited', + 'schema' => ['type' => 'array'], + ], + [ + 'name' => 'param2', + 'in' => 'query', + 'explode' => true, + 'style' => 'spaceDelimited', + 'schema' => ['type' => 'array'], + ] + ], + ]) + )) + ), + CannotSupport::conflictingParameterStyles( + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param1(query)"]', + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', + ) + ]; + + yield 'operation with spaceDelimited and pipeDelimited exploding arrays' => [ + json_encode( + $openAPI($path( + [], + $operation([ + 'operationId' => 'test-op', + 'parameters' => [ + [ + 'name' => 'param1', + 'in' => 'query', + 'explode' => true, + 'style' => 'spaceDelimited', + 'schema' => ['type' => 'array'], + ], + [ + 'name' => 'param2', + 'in' => 'query', + 'explode' => true, + 'style' => 'pipeDelimited', + 'schema' => ['type' => 'array'], + ] + ], + ]) + )) + ), + CannotSupport::conflictingParameterStyles( + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param1(query)"]', + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', + ) + ]; + + yield 'spaceDelimited exploding in path, pipeDelimited exploding in query' => [ + json_encode( + $openAPI($path( + [ + [ + 'name' => 'param1', + 'in' => 'query', + 'explode' => true, + 'style' => 'spaceDelimited', + 'schema' => ['type' => 'array'], + ], + ], + $operation([ + 'operationId' => 'test-op', + 'parameters' => [ + [ + 'name' => 'param2', + 'in' => 'query', + 'explode' => true, + 'style' => 'pipeDelimited', + 'schema' => ['type' => 'array'], + ] + ], + ]) + )) + ), + CannotSupport::conflictingParameterStyles( + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', + '["test-api(1.0.0)"]["/path"]["param1(query)"]', + ) + ]; + + yield 'form exploding object in path, pipeDelimited exploding in query' => [ + json_encode( + $openAPI($path( + [ + [ + 'name' => 'param1', + 'in' => 'query', + 'explode' => true, + 'style' => 'form', + 'schema' => ['type' => 'object'], + ], + ], + $operation([ + 'operationId' => 'test-op', + 'parameters' => [ + [ + 'name' => 'param2', + 'in' => 'query', + 'explode' => true, + 'style' => 'pipeDelimited', + 'schema' => ['type' => 'array'], + ] + ], + ]) + )) + ), + CannotSupport::conflictingParameterStyles( + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', + '["test-api(1.0.0)"]["/path"]["param1(query)"]', + ) + ]; + } + + public static function provideOpenAPIToRead(): Generator + { + yield 'minimal OpenAPI' => [ + OpenAPIProvider::minimalV30MembraneObject(), + OpenAPIProvider::minimalV30String(), + ]; + + yield 'detailed OpenAPI' => [ + OpenAPIProvider::detailedV30MembraneObject(), + OpenAPIProvider::detailedV30String(), + ]; + } +} diff --git a/tests/ReaderTest.php b/tests/ReaderTest.php index b4e0090..a6b67fb 100644 --- a/tests/ReaderTest.php +++ b/tests/ReaderTest.php @@ -6,10 +6,11 @@ use cebe\{openapi\exceptions as CebeException, openapi\spec as CebeSpec}; use Generator; -use Membrane\OpenAPIReader\{FileFormat, Method, OpenAPIVersion}; +use Membrane\OpenAPIReader\{CebeReader, FileFormat, Method, OpenAPIVersion}; use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; use Membrane\OpenAPIReader\Factory\V30\FromCebe; use Membrane\OpenAPIReader\Reader; +use Membrane\OpenAPIReader\Tests\Fixtures\Helper\OpenAPIProvider; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; @@ -19,19 +20,23 @@ use TypeError; #[CoversClass(Reader::class)] +#[CoversClass(CebeReader::class)] #[CoversClass(CannotRead::class), CoversClass(CannotSupport::class), CoversClass(InvalidOpenAPI::class)] #[UsesClass(FileFormat::class), UsesClass(Method::class), UsesClass(OpenAPIVersion::class)] #[UsesClass(FromCebe::class)] #[UsesClass(Identifier::class)] #[UsesClass(Partial\OpenAPI::class)] #[UsesClass(Valid\V30\OpenAPI::class)] -#[UsesClass(Partial\Operation::class)] -#[UsesClass(Valid\V30\Operation::class)] +#[UsesClass(Partial\Server::class)] +#[UsesClass(Valid\V30\Server::class)] #[UsesClass(Partial\PathItem::class)] #[UsesClass(Valid\V30\PathItem::class)] +#[UsesClass(Partial\Operation::class)] +#[UsesClass(Valid\V30\Operation::class)] #[UsesClass(Partial\Parameter::class)] #[UsesClass(Valid\V30\Parameter::class)] #[UsesClass(Partial\MediaType::class)] +#[UsesClass(Valid\V30\MediaType::class)] #[UsesClass(Partial\Schema::class)] #[UsesClass(Valid\V30\Schema::class)] #[UsesClass(Valid\Validated::class)] @@ -651,7 +656,42 @@ public function itCannotSupportAmbiguousResolution( (new Reader([OpenAPIVersion::Version_3_0])) ->readFromAbsoluteFilePath($filePath, FileFormat::Json); + } + + public static function provideOpenAPIToRead(): Generator + { + yield 'minimal OpenAPI' => [ + OpenAPIProvider::minimalV30CebeObject(), + OpenAPIProvider::minimalV30String(), + ]; + + yield 'detailed OpenAPI' => [ + OpenAPIProvider::detailedV30CebeObject(), + OpenAPIProvider::detailedV30String(), + ]; + } + +// The cebe object created has different json pointers +// #[Test, DataProvider('provideOpenAPIToRead')] +// public function itReadsFromFile(CebeSpec\OpenApi $expected, string $openApi): void +// { +// $filePath = vfsStream::setup()->url() . '/openapi.json'; +// file_put_contents($filePath, $openApi); +// +// $sut = new Reader([OpenAPIVersion::Version_3_0]); +// +// $actual = $sut->readFromAbsoluteFilePath($filePath, FileFormat::Json); +// +// self::assertEquals($expected, $actual); +// } + + #[Test, DataProvider('provideOpenAPIToRead')] + public function itReadsFromString(CebeSpec\OpenApi $expected, string $openApi): void + { + $sut = new Reader([OpenAPIVersion::Version_3_0]); + $actual = $sut->readFromString($openApi, FileFormat::Json); + self::assertEquals($expected, $actual); } } diff --git a/tests/ValueObject/Valid/V30/OpenAPITest.php b/tests/ValueObject/Valid/V30/OpenAPITest.php index 94e824e..0d5bbce 100644 --- a/tests/ValueObject/Valid/V30/OpenAPITest.php +++ b/tests/ValueObject/Valid/V30/OpenAPITest.php @@ -13,6 +13,7 @@ use Membrane\OpenAPIReader\ValueObject\Valid\V30\OpenAPI; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Operation; use Membrane\OpenAPIReader\ValueObject\Valid\V30\PathItem; +use Membrane\OpenAPIReader\ValueObject\Valid\V30\Server; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; use Membrane\OpenAPIReader\ValueObject\Valid\Warning; use Membrane\OpenAPIReader\ValueObject\Valid\Warnings; @@ -26,6 +27,8 @@ #[CoversClass(OpenAPI::class)] #[CoversClass(Partial\OpenAPI::class)] // DTO #[CoversClass(InvalidOpenAPI::class)] +#[UsesClass(Server::class)] +#[UsesClass(Partial\Server::class)] #[UsesClass(PathItem::class)] #[UsesClass(Partial\PathItem::class)] #[UsesClass(Operation::class)] @@ -62,6 +65,25 @@ public function itWarnsAgainstEmptyPaths(): void self::assertEquals($expected, $sut->getWarnings()->all()[0]); } + #[Test] + public function itHasADefaultServer(): void + { + $title = 'My API'; + $version = '1.2.1'; + $sut = new OpenAPI(PartialHelper::createOpenAPI( + title: $title, + version: $version, + servers: [], + )); + + $expected = [new Server( + $sut->getIdentifier(), + PartialHelper::createServer(url: '/') + )]; + + self::assertEquals($expected, $sut->servers); + } + public static function providePartialOpenAPIs(): Generator { $title = 'Test OpenAPI'; diff --git a/tests/ValueObject/Valid/V30/OperationTest.php b/tests/ValueObject/Valid/V30/OperationTest.php index 676dc88..2c815d1 100644 --- a/tests/ValueObject/Valid/V30/OperationTest.php +++ b/tests/ValueObject/Valid/V30/OperationTest.php @@ -14,12 +14,14 @@ use Membrane\OpenAPIReader\ValueObject\Valid\V30\Operation; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Parameter; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Schema; +use Membrane\OpenAPIReader\ValueObject\Valid\V30\Server; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; use Membrane\OpenAPIReader\ValueObject\Valid\Warning; use Membrane\OpenAPIReader\ValueObject\Valid\Warnings; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; @@ -27,6 +29,8 @@ #[CoversClass(Partial\Operation::class)] // DTO #[CoversClass(InvalidOpenAPI::class)] #[CoversClass(CannotSupport::class)] +#[UsesClass(Server::class)] +#[UsesClass(Partial\Server::class)] #[UsesClass(Partial\Parameter::class)] #[UsesClass(Partial\Schema::class)] #[UsesClass(Identifier::class)] @@ -45,7 +49,7 @@ public function itRequiresAnOperationId(): void self::expectException(CannotSupport::class); self::expectExceptionCode(CannotSupport::MISSING_OPERATION_ID); - new Operation(new Identifier(''), [], Method::GET, $partialOperation); + new Operation(new Identifier(''), [], [], Method::GET, $partialOperation); } /** @@ -60,7 +64,7 @@ public function itOverridesPathParametersOfTheSameName( Method $method, Partial\Operation $partialOperation ): void { - $sut = new Operation($parentIdentifier, $pathParameters, $method, $partialOperation); + $sut = new Operation($parentIdentifier, [], $pathParameters, $method, $partialOperation); self::assertEquals($expected, $sut->parameters); } @@ -102,7 +106,7 @@ public function itCannotSupportConflictingParameters(): void self::expectExceptionObject(CannotSupport::conflictingParameterStyles(...$parameterIdentifiers)); - new Operation($parentIdentifier, [], $method, $partialOperation); + new Operation($parentIdentifier, [], [], $method, $partialOperation); } #[Test, DataProvider('provideOperationsToValidate')] @@ -114,7 +118,36 @@ public function itValidatesOperations( ): void { self::expectExceptionObject($expected); - new Operation($parentIdentifier, [], $method, $partialOperation); + new Operation($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 = new Operation( + $parentIdentifier, + $pathServers, + [], + $method, + PartialHelper::createOperation( + operationId: $operationId, + servers: $operationServers, + ) + ); + + self::assertEquals($expected, $sut->servers); } public static function provideParameters(): Generator @@ -209,4 +242,34 @@ public static function provideOperationsToValidate(): Generator ] ); } + + 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') + ]); + } } diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index 9262d0e..e433a98 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -14,6 +14,7 @@ use Membrane\OpenAPIReader\ValueObject\Valid\V30\Parameter; use Membrane\OpenAPIReader\ValueObject\Valid\V30\PathItem; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Schema; +use Membrane\OpenAPIReader\ValueObject\Valid\V30\Server; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; use Membrane\OpenAPIReader\ValueObject\Valid\Warning; use Membrane\OpenAPIReader\ValueObject\Valid\Warnings; @@ -27,13 +28,15 @@ #[CoversClass(PathItem::class)] #[CoversClass(Partial\PathItem::class)] // DTO #[CoversClass(InvalidOpenAPI::class)] -#[UsesClass(Identifier::class)] +#[UsesClass(Server::class)] +#[UsesClass(Partial\Server::class)] #[UsesClass(Operation::class)] #[UsesClass(Partial\Operation::class)] #[UsesClass(Parameter::class)] #[UsesClass(Partial\Parameter::class)] #[UsesClass(Schema::class)] #[UsesClass(Partial\Schema::class)] +#[UsesClass(Identifier::class)] #[UsesClass(Validated::class)] #[UsesClass(Warning::class)] #[UsesClass(Warnings::class)] @@ -52,7 +55,7 @@ public function itValidatesParameters( $identifier = new Identifier('test-path-item'); $expected = array_map(fn($p) => new Parameter($identifier, $p), $partialExpected); - $sut = new PathItem($identifier, $partialPathItem); + $sut = new PathItem($identifier, [], $partialPathItem); self::assertEquals($expected, $sut->parameters); } @@ -76,7 +79,7 @@ public function itInvalidatesDuplicateParameters(): void $paramIdentifier, )); - new PathItem($identifier, $pathItem); + new PathItem($identifier, [], $pathItem); } #[Test, DataProvider('provideSimilarNames')] @@ -85,6 +88,7 @@ public function itWarnsAgainstDuplicateNames(string $name1, string $name2): void { $sut = new PathItem( new Identifier('test-path-item'), + [], PartialHelper::createPathItem(parameters: [ PartialHelper::createParameter(name: $name1, in:'path'), PartialHelper::createParameter(name: $name2, in:'query') @@ -105,7 +109,7 @@ public function itWarnsAgainstRedundantMethods(string $method): void $partialPathItem = PartialHelper::createPathItem(...$operations); - $sut = new PathItem(new Identifier('test'), $partialPathItem); + $sut = new PathItem(new Identifier('test'), [], $partialPathItem); self::assertEquals( new Warning( @@ -129,6 +133,7 @@ public function itCanGetAllOperations( ): void { $sut = new PathItem( $identifier, + [], PartialHelper::createPathItem( ...$operations ) @@ -137,6 +142,30 @@ public function itCanGetAllOperations( 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 = new PathItem( + $identifier, + $openapiServers, + PartialHelper::createPathItem( + servers: $pathItemServers + ) + ); + + self::assertEquals($expected, $sut->servers); + } + public static function providePartialPathItems(): Generator { $p1 = PartialHelper::createParameter(name: 'p1'); @@ -192,6 +221,7 @@ public static function provideOperationsToGet(): Generator $validOperation = fn($method) => new Operation( $identifier, [], + [], Method::from($method), $partialOperation($method), ); @@ -226,4 +256,30 @@ public static function provideOperationsToGet(): Generator ] ); } + + public static function provideServers(): Generator + { + $parentIdentifier = new Identifier('test-api'); + $identifier = $parentIdentifier->append('test-path'); + + $openAPIServers = [ + new Server($parentIdentifier, PartialHelper::createServer(url: '/')) + ]; + + $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' => $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') + ]); + } } diff --git a/tests/ValueObject/Valid/V30/ServerTest.php b/tests/ValueObject/Valid/V30/ServerTest.php new file mode 100644 index 0000000..dc63ea0 --- /dev/null +++ b/tests/ValueObject/Valid/V30/ServerTest.php @@ -0,0 +1,236 @@ +getPattern()); + } + + /** + * @param string[] $expected + */ + #[Test, DataProvider('provideUrlsToGetVariablesFrom')] + #[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 Warning[] $expected + */ + #[Test, DataProvider('provideServersWithWarnings')] + #[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()); + } + + + + 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 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 provideUrlsToGetVariablesFrom(): 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 provideServersWithWarnings(): 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']); + } +} diff --git a/tests/ValueObject/Valid/V30/ServerVariableTest.php b/tests/ValueObject/Valid/V30/ServerVariableTest.php new file mode 100644 index 0000000..7f49a1c --- /dev/null +++ b/tests/ValueObject/Valid/V30/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' => []], + ); + } +} diff --git a/tests/fixtures/Helper/OpenAPIProvider.php b/tests/fixtures/Helper/OpenAPIProvider.php new file mode 100644 index 0000000..b50f45e --- /dev/null +++ b/tests/fixtures/Helper/OpenAPIProvider.php @@ -0,0 +1,388 @@ + '3.0.0', + 'info' => ['title' => 'My Minimal OpenAPI', 'version' => '1.0.0'], + 'paths' => [] + ]); + + assert(is_string($string)); + + return $string; + } + + /** + * This will return a "minimal" cebe library, OpenAPI object + * Functions prefixed with "minimalV30" return equivalent OpenAPI + */ + public static function minimalV30CebeObject(): Cebe\OpenApi + { + return new Cebe\OpenApi([ + 'openapi' => '3.0.0', + 'info' => ['title' => 'My Minimal OpenAPI', 'version' => '1.0.0'], + 'paths' => [] + ]); + } + + /** + * This will return a "minimal" Membrane OpenAPI object + * Functions prefixed with "minimalV30" return equivalent OpenAPI + */ + public static function minimalV30MembraneObject(): OpenAPI + { + return new OpenAPI(PartialHelper::createOpenAPI( + openapi: '3.0.0', + title: 'My Minimal OpenAPI', + version: '1.0.0', + paths: [] + )); + } + + /** + * This will return a "detailed" JSON string OpenAPI + * Functions prefixed with "detailedV30" return equivalent OpenAPI + */ + public static function detailedV30String(): string + { + $string = json_encode([ + 'openapi' => '3.0.0', + 'info' => ['title' => 'My Detailed OpenAPI', 'version' => '1.0.1'], + 'servers' => [ + ['url' => 'https://server.net'] + ], + 'paths' => [ + '/first' => [ + 'parameters' => [ + [ + 'name' => 'limit', + 'in' => 'query', + 'required' => false, + 'schema' => ['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" cebe library, OpenAPI object + * Functions prefixed with "detailedV30" return equivalent OpenAPI + */ + public static function detailedV30CebeObject(): Cebe\OpenApi + { + return new Cebe\OpenApi([ + 'openapi' => '3.0.0', + 'info' => ['title' => 'My Detailed OpenAPI', 'version' => '1.0.1'], + 'servers' => [ + ['url' => 'https://server.net'] + ], + 'paths' => [ + '/first' => [ + 'parameters' => [ + [ + 'name' => 'limit', + 'in' => 'query', + 'required' => false, + 'schema' => ['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' + ] + ] + ] + ] + ], + ]); + } + + /** + * This will return a "detailed" Membrane\OpenAPIReader\ValueObject\Valid\V30\OpenAPI object + * Functions prefixed with "detailedV30" return equivalent OpenAPI + */ + public static function detailedV30MembraneObject(): OpenAPI + { + return new OpenAPI(PartialHelper::createOpenAPI( + openapi: '3.0.0', + title: 'My Detailed OpenAPI', + version: '1.0.1', + servers: [ + PartialHelper::createServer(url: 'https://server.net'), + ], + paths: [ + PartialHelper::createPathItem( + path: '/first', + parameters: [ + PartialHelper::createParameter( + name: 'limit', + in: 'query', + required: false, + schema: PartialHelper::createSchema( + type: 'integer' + ) + ), + ], + get: PartialHelper::createOperation( + operationId: 'first-get', + parameters: [ + PartialHelper::createParameter( + name: 'pet', + in: 'header', + required: true, + schema: null, + content: [ + PartialHelper::createMediaType( + mediaType: 'application/json', + schema: PartialHelper::createSchema( + allOf: [ + PartialHelper::createSchema( + type: 'integer' + ), + PartialHelper::createSchema( + type: 'number' + ) + ] + ) + ) + ] + ) + ] + ) + ), + PartialHelper::createPathItem( + path: '/second', + servers: [ + PartialHelper::createServer( + url: 'https://second-server.co.uk' + ), + ], + parameters: [ + PartialHelper::createParameter( + name: 'limit', + in: 'query', + required: false, + schema: PartialHelper::createSchema( + type: 'integer' + ) + ), + ], + get: PartialHelper::createOperation( + operationId: 'second-get', + parameters: [ + PartialHelper::createParameter( + name: 'pet', + in: 'header', + required: true, + schema: null, + content: [ + PartialHelper::createMediaType( + mediaType: 'application/json', + schema: PartialHelper::createSchema( + allOf: [ + PartialHelper::createSchema( + type: 'integer' + ), + PartialHelper::createSchema( + type: 'number' + ) + ] + ) + ) + ] + ) + ] + ), + put: PartialHelper::createOperation( + operationId: 'second-put', + servers: [ + PartialHelper::createServer(url: 'https://second-put.com') + ], + parameters: [ + PartialHelper::createParameter( + name: 'user', + in: 'cookie', + required: false, + schema: PartialHelper::createSchema( + type: 'object' + ) + ) + ] + ) + ) + ] + )); + } +} diff --git a/tests/fixtures/Helper/PartialHelper.php b/tests/fixtures/Helper/PartialHelper.php index f88eb67..bee127e 100644 --- a/tests/fixtures/Helper/PartialHelper.php +++ b/tests/fixtures/Helper/PartialHelper.php @@ -10,6 +10,8 @@ use Membrane\OpenAPIReader\ValueObject\Partial\Parameter; use Membrane\OpenAPIReader\ValueObject\Partial\PathItem; use Membrane\OpenAPIReader\ValueObject\Partial\Schema; +use Membrane\OpenAPIReader\ValueObject\Partial\Server; +use Membrane\OpenAPIReader\ValueObject\Partial\ServerVariable; final class PartialHelper { @@ -17,18 +19,43 @@ public static function createOpenAPI( ?string $openapi = '3.0.0', ?string $title = 'Test API', ?string $version = '1.0.0', + array $servers = [], ?array $paths = [], ): OpenAPI { return new OpenAPI( $openapi, $title, $version, + $servers, $paths, ); } + public static function createServer( + ?string $url = 'https://www.server.net', + ?array $variables = [], + ): Server{ + return new Server( + $url, + $variables, + ); + } + + public static function createServerVariable( + ?string $name = 'default-name', + ?string $default = 'default-value', + ?array $enum = null, + ): ServerVariable { + return new ServerVariable( + $name, + $default, + $enum + ); + } + public static function createPathItem( ?string $path = '/path', + array $servers = [], array $parameters = [], ?Operation $get = null, ?Operation $put = null, @@ -41,6 +68,7 @@ public static function createPathItem( ): PathItem { return new PathItem( $path, + $servers, $parameters, $get, $put, @@ -76,10 +104,12 @@ public static function createParameter( public static function createOperation( ?string $operationId = 'test-id', + array $servers = [], array $parameters = [] ): Operation { return new Operation( $operationId, + $servers, $parameters ); } From c842ddf40afd4ddb098c480f744232202b4ec925 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 7 Mar 2024 12:22:13 +0000 Subject: [PATCH 11/56] Rename infection.json to infection.json5 - Code coverage driver not currently found on Github --- infection.json => infection.json5 | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename infection.json => infection.json5 (100%) diff --git a/infection.json b/infection.json5 similarity index 100% rename from infection.json rename to infection.json5 From 3aef647be036e9bfe6c8c4d800a964ad348a4c14 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 7 Mar 2024 14:51:38 +0000 Subject: [PATCH 12/56] Warn against equivalent parameter names in different case --- src/ValueObject/Valid/V30/Operation.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/ValueObject/Valid/V30/Operation.php b/src/ValueObject/Valid/V30/Operation.php index 7ce6288..53b01f9 100644 --- a/src/ValueObject/Valid/V30/Operation.php +++ b/src/ValueObject/Valid/V30/Operation.php @@ -106,6 +106,14 @@ private function validateParameters( foreach ($result as $index => $parameter) { foreach (array_slice($result, $index + 1) as $otherParameter) { + if ($this->areParametersIdentical($parameter, $otherParameter)) { + throw InvalidOpenAPI::duplicateParameters( + $this->getIdentifier(), + $parameter->getIdentifier(), + $otherParameter->getIdentifier(), + ); + } + if ($this->areParametersSimilar($parameter, $otherParameter)) { $this->addWarning( <<areParametersIdentical($parameter, $otherParameter)) { - throw InvalidOpenAPI::duplicateParameters( - $this->getIdentifier(), - $parameter->getIdentifier(), - $otherParameter->getIdentifier(), - ); - } } } } @@ -165,7 +165,8 @@ private function areParametersSimilar( Parameter $parameter, Parameter $otherParameter ): bool { - return strcasecmp($parameter->name, $otherParameter->name) === 0; + return $parameter->name !== $otherParameter->name && + strcasecmp($parameter->name, $otherParameter->name) === 0; } private function canParameterConflict(Parameter $parameter): bool From c9655564c633a545c3cb55c7416999dce8223641 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 7 Mar 2024 14:52:31 +0000 Subject: [PATCH 13/56] Add todo for guidance on how server urls should be treated --- src/ValueObject/Valid/V30/Server.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ValueObject/Valid/V30/Server.php b/src/ValueObject/Valid/V30/Server.php index fec065a..50fb929 100644 --- a/src/ValueObject/Valid/V30/Server.php +++ b/src/ValueObject/Valid/V30/Server.php @@ -12,7 +12,10 @@ final class Server extends Validated { - /** REQUIRED */ + /** + * REQUIRED + * todo https://github.com/OAI/OpenAPI-Specification/discussions/3512#discussioncomment-8234689 + */ public readonly string $url; /** From 29dd360c97cb691858fb5b9c4a90f4cfb0bba1f6 Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 26 Mar 2024 11:43:13 +0000 Subject: [PATCH 14/56] Test V3.0 PathItem warns against having no Operations - Membrane has nothing to validate on an empty PathItem --- tests/ValueObject/Valid/V30/PathItemTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index e433a98..e3546d7 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -120,6 +120,20 @@ public function itWarnsAgainstRedundantMethods(string $method): void ); } + #[Test] + #[TestDox('it warns that there are no operations specified on this path')] + public function itWarnsAgainstHavingNoOperations(): void + { + $partialPathItem = PartialHelper::createPathItem(); + + $sut = new PathItem(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 From 867388faee2e619ff4dead328e2fb6354dd97bf2 Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 26 Mar 2024 11:44:18 +0000 Subject: [PATCH 15/56] Rename V3.0. PathItem test for clarity - It doesn't warn against identical Parameters, only similar --- tests/ValueObject/Valid/V30/PathItemTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index e3546d7..4d4c112 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -84,7 +84,7 @@ public function itInvalidatesDuplicateParameters(): void #[Test, DataProvider('provideSimilarNames')] #[TestDox('It warns that similar names, though valid, may be confusing')] - public function itWarnsAgainstDuplicateNames(string $name1, string $name2): void + public function itWarnsAgainstSimilarNames(string $name1, string $name2): void { $sut = new PathItem( new Identifier('test-path-item'), From 6409213fc7f1e0b873addfdf0a459ac05131a070 Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 26 Mar 2024 11:45:34 +0000 Subject: [PATCH 16/56] Remove warning on V3.0. PathItem - Identical named parameters in different locations MAY serve a purpose (allowing the same input to be specified in different places) - Refactored the nested if statements for clarity --- src/ValueObject/Valid/V30/PathItem.php | 31 +++++--------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/src/ValueObject/Valid/V30/PathItem.php b/src/ValueObject/Valid/V30/PathItem.php index c14450b..2ea091f 100644 --- a/src/ValueObject/Valid/V30/PathItem.php +++ b/src/ValueObject/Valid/V30/PathItem.php @@ -119,29 +119,7 @@ private function validateParameters(array $parameters): array foreach (array_values($result) as $index => $parameter) { foreach (array_slice($result, $index + 1) as $otherParameter) { - if ($this->areParametersSimilar($parameter, $otherParameter)) { - $this->addWarning( - <<name - $otherParameter->name - TEXT, - Warning::SIMILAR_NAMES - ); - - if ($this->areParametersIdentical($parameter, $otherParameter)) { - throw InvalidOpenAPI::duplicateParameters( - $this->getIdentifier(), - $parameter->getIdentifier(), - $otherParameter->getIdentifier(), - ); - } - } - - if ( - $parameter->name === $otherParameter->name && - $parameter->in === $otherParameter->in - ) { + if ($this->areParametersIdentical($parameter, $otherParameter)) { throw InvalidOpenAPI::duplicateParameters( $this->getIdentifier(), $parameter->getIdentifier(), @@ -149,11 +127,12 @@ private function validateParameters(array $parameters): array ); } - if (strcasecmp($parameter->name, $otherParameter->name) === 0) { + if ($this->areParametersSimilar($parameter, $otherParameter)) { $this->addWarning( <<name - this may lead to confusion.', + 'This contains confusingly similar parameter names: + $parameter->name + $otherParameter->name TEXT, Warning::SIMILAR_NAMES ); From 76965e60afe43de15250d9620a94f65f45089a9d Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 26 Mar 2024 11:47:29 +0000 Subject: [PATCH 17/56] Test V3.0. Server contains correct ServerVariables --- tests/ValueObject/Valid/V30/ServerTest.php | 46 +++++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/ValueObject/Valid/V30/ServerTest.php b/tests/ValueObject/Valid/V30/ServerTest.php index dc63ea0..8aea7be 100644 --- a/tests/ValueObject/Valid/V30/ServerTest.php +++ b/tests/ValueObject/Valid/V30/ServerTest.php @@ -56,7 +56,7 @@ public function itGetsTheUrlPattern( /** * @param string[] $expected */ - #[Test, DataProvider('provideUrlsToGetVariablesFrom')] + #[Test, DataProvider('provideUrlsToGetVariableNamesFrom')] #[TestDox('It returns a list of variable names in order of appearance within the URL')] public function itGetsVariableNames( array $expected, @@ -67,6 +67,30 @@ public function itGetsVariableNames( 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 @@ -196,7 +220,7 @@ public static function provideUrlPatterns(): Generator /** * @return Generator */ - public static function provideUrlsToGetVariablesFrom(): Generator + public static function provideUrlsToGetVariableNamesFrom(): Generator { $case = fn(array $variables) => [ $variables, @@ -208,6 +232,24 @@ public static function provideUrlsToGetVariablesFrom(): Generator 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 */ From 9190a06c485ae8e14fb3149efb00b15e0cede90d Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 26 Mar 2024 12:31:09 +0000 Subject: [PATCH 18/56] Ensure properties on V3.0. PathItem match OpenAPI behaviour --- src/ValueObject/Valid/V30/PathItem.php | 8 ++-- tests/ValueObject/Valid/V30/PathItemTest.php | 42 +++++++++++++++----- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/ValueObject/Valid/V30/PathItem.php b/src/ValueObject/Valid/V30/PathItem.php index 2ea091f..3041ad2 100644 --- a/src/ValueObject/Valid/V30/PathItem.php +++ b/src/ValueObject/Valid/V30/PathItem.php @@ -108,16 +108,16 @@ private function validateServers( /** * @param Partial\Parameter[] $parameters - * @return Parameter[] + * @return array */ private function validateParameters(array $parameters): array { - $result = array_map( + $result = array_values(array_map( fn($p) => new Parameter($this->getIdentifier(), $p), $parameters - ); + )); - foreach (array_values($result) as $index => $parameter) { + foreach ($result as $index => $parameter) { foreach (array_slice($result, $index + 1) as $otherParameter) { if ($this->areParametersIdentical($parameter, $otherParameter)) { throw InvalidOpenAPI::duplicateParameters( diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index 4d4c112..1496a31 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -53,7 +53,10 @@ public function itValidatesParameters( Partial\PathItem $partialPathItem ): void { $identifier = new Identifier('test-path-item'); - $expected = array_map(fn($p) => new Parameter($identifier, $p), $partialExpected); + $expected = array_values(array_map( + fn($p) => new Parameter($identifier, $p), + $partialExpected + )); $sut = new PathItem($identifier, [], $partialPathItem); @@ -177,7 +180,7 @@ public function itOverridesOpenAPILevelServers( ) ); - self::assertEquals($expected, $sut->servers); + self::assertEquals(array_values($expected), $sut->servers); } public static function providePartialPathItems(): Generator @@ -193,15 +196,27 @@ public static function providePartialPathItems(): Generator yield 'one parameter' => [ [$p1], - PartialHelper::createPathItem(parameters: [ - $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 @@ -277,7 +292,8 @@ public static function provideServers(): Generator $identifier = $parentIdentifier->append('test-path'); $openAPIServers = [ - new Server($parentIdentifier, PartialHelper::createServer(url: '/')) + new Server($parentIdentifier, PartialHelper::createServer(url: '/')), + new Server($parentIdentifier, PartialHelper::createServer(url: '/petstore.io')), ]; $case = fn($pathServers) => [ @@ -289,11 +305,17 @@ public static function provideServers(): Generator ]; yield 'no Path Item Servers' => $case([]); - yield 'one Path Item Server' => $case([PartialHelper::createServer()]); + 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([ - PartialHelper::createServer(url: 'https://server-one.io'), - PartialHelper::createServer(url: 'https://server-two.co.uk'), - PartialHelper::createServer(url: 'https://server-three.net') + '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') ]); } } From df0876d375cad4cca1c518ca707fac33ac3cf9c2 Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 26 Mar 2024 17:29:11 +0000 Subject: [PATCH 19/56] Add findWarningsByCode to Warnings --- src/ValueObject/Valid/Warning.php | 11 ++- src/ValueObject/Valid/Warnings.php | 21 ++--- tests/ValueObject/Valid/WarningsTest.php | 107 ++++++++++++++++++++--- 3 files changed, 116 insertions(+), 23 deletions(-) diff --git a/src/ValueObject/Valid/Warning.php b/src/ValueObject/Valid/Warning.php index b2b4dcd..bb3b778 100644 --- a/src/ValueObject/Valid/Warning.php +++ b/src/ValueObject/Valid/Warning.php @@ -22,15 +22,21 @@ final class Warning */ public const EMPTY_PATHS = 'empty-paths'; + /** + * OpenAPI, Path, Operation: Servers with identical urls only serve to confuse + */ + public const IDENTICAL_SERVER_URLS = 'identical-server-urls'; + /** * Server Variable: If the "enum" is defined, the value SHOULD exist in the enum's values. */ public const IMPOSSIBLE_DEFAULT = 'impossible-default'; /** - * OpenAPI, Path Item, Operation: If "servers" are specified, and they're all impossible. Your OpenAPI is unusable. + * Server: paths begin with a forward slash, so servers need not end in one + * - membrane will ignore trailing forward slashes on server urls */ - public const NO_VALID_SERVERS = 'no-valid-servers'; + public const REDUNDANT_FORWARD_SLASH = 'redundant-forward-slash'; /** * Path Item: @@ -49,6 +55,7 @@ final class Warning */ public const SIMILAR_NAMES = 'similar-names'; + public function __construct( public readonly string $message, public readonly string $code diff --git a/src/ValueObject/Valid/Warnings.php b/src/ValueObject/Valid/Warnings.php index 7dee198..cbb73a5 100644 --- a/src/ValueObject/Valid/Warnings.php +++ b/src/ValueObject/Valid/Warnings.php @@ -32,21 +32,22 @@ public function all(): array return $this->warnings; } - public function hasWarnings(): bool + /** @return Warning[] */ + public function findByWarningCodes(string $code, string ...$codes): array { - return !empty($this->warnings); + return array_filter( + $this->warnings, + fn($w) => in_array($w->code, [$code, ...$codes]) + ); } public function hasWarningCodes(string $code, string ...$codes): bool { - $codes = [$code, ...$codes]; - - foreach ($this->warnings as $warning) { - if (in_array($warning->code, $codes)) { - return true; - } - } + return !empty($this->findByWarningCodes($code, ...$codes)); + } - return false; + public function hasWarnings(): bool + { + return !empty($this->warnings); } } diff --git a/tests/ValueObject/Valid/WarningsTest.php b/tests/ValueObject/Valid/WarningsTest.php index 9a133eb..7a2ea2e 100644 --- a/tests/ValueObject/Valid/WarningsTest.php +++ b/tests/ValueObject/Valid/WarningsTest.php @@ -14,6 +14,8 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +//todo test findByWarningCodes + #[CoversClass(Warnings::class)] #[CoversClass(Warning::class)] #[UsesClass(Identifier::class)] @@ -41,6 +43,30 @@ public function itAddsWarnings(Warning ...$warnings): void self::assertEquals($expected, $sut->all()); } + #[Test, DataProvider('provideWarnings')] + public function itGetsAllWarnings(Warning ...$warnings): void + { + $sut = new Warnings(new Identifier('test'), ...$warnings); + + self::assertSame($warnings, $sut->all()); + } + + /** + * @param Warning[] $expected + * @param Warning[] $warnings + * @param string[] $codes + */ + #[Test, DataProvider('provideWarningsToFind')] + public function itFindsWarningsByCodes( + array $expected, + array $warnings, + array $codes, + ): void { + $sut = new Warnings(new Identifier('test'), ...$warnings); + + self::assertEquals($expected, $sut->findByWarningCodes(...$codes)); + } + #[Test, DataProvider('provideWarnings')] public function itChecksItHasWarnings(Warning ...$warnings): void { @@ -60,14 +86,6 @@ public function itChecksItHasWarningCodes( self::assertSame($expected, $sut->hasWarningCodes(...$codes)); } - #[Test, DataProvider('provideWarnings')] - public function itGetsAllWarnings(Warning ...$warnings): void - { - $sut = new Warnings(new Identifier('test'), ...$warnings); - - self::assertSame($warnings, $sut->all()); - } - /** * @return Generator */ @@ -82,6 +100,76 @@ public static function provideWarnings(): Generator ]; } + /** + * @return Generator + */ + public static function provideWarningsToFind(): Generator + { + foreach (self::provideWarnings() as $case => $warnings) { + yield "$case, single, not contained, code" => [ + [], + $warnings, + ['This code is most definitely not contained anywhere'], + ]; + + yield "$case, multiple, not contained, codes" => [ + [], + $warnings, + [ + 'This code is most definitely not contained anywhere', + 'This code is almost certainly not contained anywhere', + 'This code is guaranteed not to be contained somewhere', + ], + ]; + + if (!empty($warnings)) { + $codes = array_map(fn($w) => $w->code, $warnings); + + yield "$case, single, contained, code" => [ + [$warnings[0]], + $warnings, + [$codes[0]], + ]; + + yield "$case, one contained code, duplicated three times" => [ + [$warnings[0]], + $warnings, + [$codes[0], $codes[0], $codes[0]], + ]; + + yield "$case, one contained code, one that is not" => [ + [$warnings[0]], + $warnings, + ['This code is certainly not contained', $codes[0]], + ]; + + if (count($warnings) > 1) { + yield "$case, all contained codes" => [ + $warnings, + $warnings, + $codes, + ]; + + $mixedCodes = []; + foreach ($codes as $code) { + $mixedCodes[] = 'This code is certainly not contained'; + $mixedCodes[] = $code; + } + + yield "$case, all contained codes, several that aren't" => [ + $warnings, + $warnings, + $mixedCodes, + ]; + } + } + } + } + public static function provideCodesToCheck(): Generator { @@ -142,10 +230,7 @@ public static function provideCodesToCheck(): Generator $warnings, ]; } - - } - } } } From cfe70b3ca8c6fa6051b6893e18e5cf8a0dfec81c Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 26 Mar 2024 17:29:36 +0000 Subject: [PATCH 20/56] Warn against trailing "/" in Server urls - Warn against duplicate servers (which would include ones that are identical apart from a different number of trailing "/") --- src/Factory/V30/FromCebe.php | 5 +- src/ValueObject/Valid/V30/OpenAPI.php | 21 +++++-- src/ValueObject/Valid/V30/Operation.php | 17 ++++- src/ValueObject/Valid/V30/PathItem.php | 17 ++++- src/ValueObject/Valid/V30/Server.php | 14 +++-- tests/ValueObject/Valid/V30/OpenAPITest.php | 66 +++++++++++++++++++- tests/ValueObject/Valid/V30/PathItemTest.php | 5 ++ tests/ValueObject/Valid/V30/ServerTest.php | 46 ++++++++++++-- 8 files changed, 168 insertions(+), 23 deletions(-) diff --git a/src/Factory/V30/FromCebe.php b/src/Factory/V30/FromCebe.php index fa1dd2a..f20e99f 100644 --- a/src/Factory/V30/FromCebe.php +++ b/src/Factory/V30/FromCebe.php @@ -20,6 +20,9 @@ final class FromCebe public static function createOpenAPI( Cebe\OpenApi $openApi ): Valid\V30\OpenAPI { + $servers = count($openApi->servers) === 1 && $openApi->servers[0]->url === '/' ? + [] : + $openApi->servers; /** * todo when phpstan 1.11 stable is released @@ -31,7 +34,7 @@ public static function createOpenAPI( $openApi->openapi, $openApi->info?->title, // @phpstan-ignore-line $openApi->info?->version, // @phpstan-ignore-line - self::createServers($openApi->servers), + self::createServers($servers), self::createPaths($openApi->paths) )); } diff --git a/src/ValueObject/Valid/V30/OpenAPI.php b/src/ValueObject/Valid/V30/OpenAPI.php index dda80af..14eb78b 100644 --- a/src/ValueObject/Valid/V30/OpenAPI.php +++ b/src/ValueObject/Valid/V30/OpenAPI.php @@ -53,19 +53,30 @@ public function __construct(Partial\OpenAPI $openAPI) /** * @param Partial\Server[] $servers - * @return array> + * @return array */ private function validateServers( Identifier $identifier, array $servers ): array { if (empty($servers)) { - $servers = [new Partial\Server('/')]; + return [new Server($identifier, new Partial\Server('/'))]; } - return array_values( - array_map(fn($s) => new Server($identifier, $s), $servers) - ); + $result = array_values(array_map( + fn($s) => new Server($identifier, $s), + $servers + )); + + $uniqueURLS = array_unique(array_map(fn($s) => $s->url, $result)); + if (count($result) !== count($uniqueURLS)) { + $this->addWarning( + 'Server URLs are not unique', + Warning::IDENTICAL_SERVER_URLS + ); + } + + return $result; } /** diff --git a/src/ValueObject/Valid/V30/Operation.php b/src/ValueObject/Valid/V30/Operation.php index 53b01f9..aa2c7bf 100644 --- a/src/ValueObject/Valid/V30/Operation.php +++ b/src/ValueObject/Valid/V30/Operation.php @@ -88,9 +88,20 @@ private function validateServers( return $pathServers; } - return array_values( - array_map(fn($s) => new Server($identifier, $s), $operationServers) - ); + $result = array_values(array_map( + fn($s) => new Server($identifier, $s), + $operationServers + )); + + $uniqueURLS = array_unique(array_map(fn($s) => $s->url, $result)); + if (count($result) !== count($uniqueURLS)) { + $this->addWarning( + 'Server URLs are not unique', + Warning::IDENTICAL_SERVER_URLS + ); + } + + return $result; } /** diff --git a/src/ValueObject/Valid/V30/PathItem.php b/src/ValueObject/Valid/V30/PathItem.php index 3041ad2..eee60ab 100644 --- a/src/ValueObject/Valid/V30/PathItem.php +++ b/src/ValueObject/Valid/V30/PathItem.php @@ -101,9 +101,20 @@ private function validateServers( return $openAPIServers; } - return array_values( - array_map(fn($s) => new Server($identifier, $s), $pathServers) - ); + $result = array_values(array_map( + fn($s) => new Server($identifier, $s), + $pathServers + )); + + $uniqueURLS = array_unique(array_map(fn($s) => $s->url, $result)); + if (count($result) !== count($uniqueURLS)) { + $this->addWarning( + 'Server URLs are not unique', + Warning::IDENTICAL_SERVER_URLS + ); + } + + return $result; } /** diff --git a/src/ValueObject/Valid/V30/Server.php b/src/ValueObject/Valid/V30/Server.php index 50fb929..31fb716 100644 --- a/src/ValueObject/Valid/V30/Server.php +++ b/src/ValueObject/Valid/V30/Server.php @@ -14,7 +14,6 @@ final class Server extends Validated { /** * REQUIRED - * todo https://github.com/OAI/OpenAPI-Specification/discussions/3512#discussioncomment-8234689 */ public readonly string $url; @@ -35,10 +34,10 @@ public function __construct( throw InvalidOpenAPI::serverMissingUrl($parentIdentifier); } - $this->url = $this->validateUrl($parentIdentifier, $server->url); - parent::__construct($parentIdentifier->append($server->url)); + $this->url = $this->validateUrl($parentIdentifier, $server->url); + $this->variables = $this->validateVariables( $this->getIdentifier(), $this->getVariableNames(), @@ -99,7 +98,14 @@ private function validateUrl(Identifier $identifier, string $url): string throw InvalidOpenAPI::urlUnclosedVariable($identifier->append($url)); } - return $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, '/'); } /** diff --git a/tests/ValueObject/Valid/V30/OpenAPITest.php b/tests/ValueObject/Valid/V30/OpenAPITest.php index 0d5bbce..32d3ab5 100644 --- a/tests/ValueObject/Valid/V30/OpenAPITest.php +++ b/tests/ValueObject/Valid/V30/OpenAPITest.php @@ -53,7 +53,7 @@ public function itValidatesOpenAPIObjects( #[TestDox('no "paths" is technically valid, but it does not leave much for Membrane to validate.')] public function itWarnsAgainstEmptyPaths(): void { - $expected = new Warning('No Paths in OpenAPI', Warning::EMPTY_PATHS); + $expected = [new Warning('No Paths in OpenAPI', Warning::EMPTY_PATHS)]; $title = 'My API'; $version = '1.2.1'; $sut = new OpenAPI(PartialHelper::createOpenAPI( @@ -62,7 +62,28 @@ public function itWarnsAgainstEmptyPaths(): void paths: [], )); - self::assertEquals($expected, $sut->getWarnings()->all()[0]); + self::assertEquals( + $expected, + $sut->getWarnings()->findByWarningCodes(Warning::EMPTY_PATHS) + ); + } + + /** + * @param Warning[] $expected + */ + #[Test, DataProvider('provideDuplicateServers')] + public function itWarnsAgainstDuplicateServers( + array $expected, + Partial\OpenAPI $openAPI, + ): void { + $sut = new OpenAPI($openAPI); + + self::assertEquals( + $expected, + $sut + ->getWarnings() + ->findByWarningCodes(Warning::IDENTICAL_SERVER_URLS) + ); } #[Test] @@ -84,6 +105,12 @@ public function itHasADefaultServer(): void self::assertEquals($expected, $sut->servers); } + /** + * @return Generator + */ public static function providePartialOpenAPIs(): Generator { $title = 'Test OpenAPI'; @@ -172,7 +199,6 @@ public static function providePartialOpenAPIs(): Generator PartialHelper::createPathItem( path: '/first', get: PartialHelper::createOperation(operationId: 'duplicate-id') - ), PartialHelper::createPathItem( path: '/second', @@ -182,4 +208,38 @@ public static function providePartialOpenAPIs(): Generator ] ); } + + /** + * @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('/'), + ]); + } } diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index 1496a31..e186e2f 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -318,4 +318,9 @@ public static function provideServers(): Generator PartialHelper::createServer(url: 'https://server-three.net') ]); } + + public static function provideServersToWarnAgainst(): Generator + { + + } } diff --git a/tests/ValueObject/Valid/V30/ServerTest.php b/tests/ValueObject/Valid/V30/ServerTest.php index 8aea7be..346bd67 100644 --- a/tests/ValueObject/Valid/V30/ServerTest.php +++ b/tests/ValueObject/Valid/V30/ServerTest.php @@ -95,8 +95,8 @@ public function itContainsServerVariables( /** * @param Warning[] $expected */ - #[Test, DataProvider('provideServersWithWarnings')] - #[TestDox('It warns against having a variable defined that does not appear in the "url')] + #[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 @@ -106,7 +106,19 @@ public function itWarnsAgainstRedundantVariables( 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 { @@ -202,7 +214,7 @@ private static function createServerWithVariables(string ...$variables): Partial public static function provideUrlPatterns(): Generator { yield 'url without variables' => [ - 'https://server.net/', + 'https://server.net', self::createServerWithVariables(), ]; @@ -253,7 +265,7 @@ public static function provideUrlsToGetVariablesFrom(): Generator /** * @return Generator */ - public static function provideServersWithWarnings(): Generator + public static function provideServersWithRedundantVariables(): Generator { $redundantVariables = fn($variables) => [ array_map( @@ -275,4 +287,30 @@ public static function provideServersWithWarnings(): Generator 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///'), + ]; + } } From 179c95e1e7f35b2bb0fa00da5b5162c6613304b4 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 09:41:43 +0000 Subject: [PATCH 21/56] Remove redundant exception in MembraneReader - It already throws an exception in the constructor if V3.1. is "supported", then it checks that you only provide a "supported" version. So there is no need to check it's not V3.1. twice. --- src/MembraneReader.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/MembraneReader.php b/src/MembraneReader.php index a7c5207..0f0e545 100644 --- a/src/MembraneReader.php +++ b/src/MembraneReader.php @@ -21,7 +21,8 @@ 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(); } @@ -80,11 +81,6 @@ private function getValidatedObject(CebeSpec\OpenApi $openAPI): OpenAPI { $this->isVersionSupported($openAPI->openapi) ?: throw CannotSupport::unsupportedVersion($openAPI->openapi); - /** todo create 3.1 validated objects */ - if (OpenAPIVersion::fromString($openAPI->openapi) !== OpenAPIVersion::Version_3_0) { - throw CannotSupport::membraneReaderOnlySupportsv30(); - } - $validatedObject = FromCebe::createOpenAPI($openAPI); $openAPI->validate() ?: throw InvalidOpenAPI::failedCebeValidation(...$openAPI->getErrors()); From 8f9f5f5c5b164c2e987376a7475f8d94a532337d Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 09:44:26 +0000 Subject: [PATCH 22/56] Simplify test for V30 OpenAPI --- tests/ValueObject/Valid/V30/OpenAPITest.php | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/tests/ValueObject/Valid/V30/OpenAPITest.php b/tests/ValueObject/Valid/V30/OpenAPITest.php index 32d3ab5..fd3fb3a 100644 --- a/tests/ValueObject/Valid/V30/OpenAPITest.php +++ b/tests/ValueObject/Valid/V30/OpenAPITest.php @@ -50,22 +50,15 @@ public function itValidatesOpenAPIObjects( } #[Test] - #[TestDox('no "paths" is technically valid, but it does not leave much for Membrane to validate.')] + #[TestDox('"paths" can be empty, but it does not leave much for Membrane to validate.')] public function itWarnsAgainstEmptyPaths(): void { $expected = [new Warning('No Paths in OpenAPI', Warning::EMPTY_PATHS)]; - $title = 'My API'; - $version = '1.2.1'; - $sut = new OpenAPI(PartialHelper::createOpenAPI( - title: $title, - version: $version, - paths: [], - )); + $sut = new OpenAPI(PartialHelper::createOpenAPI(paths: [])); - self::assertEquals( - $expected, - $sut->getWarnings()->findByWarningCodes(Warning::EMPTY_PATHS) - ); + $actual = $sut->getWarnings()->findByWarningCodes(Warning::EMPTY_PATHS); + + self::assertEquals($expected, $actual); } /** From 19f64a7ef81fa75f314c139483825959e7a60abf Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 09:44:41 +0000 Subject: [PATCH 23/56] Correct namespace for MembraneReaderTest --- tests/MembraneReaderTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/MembraneReaderTest.php b/tests/MembraneReaderTest.php index 810fa11..e39b728 100644 --- a/tests/MembraneReaderTest.php +++ b/tests/MembraneReaderTest.php @@ -2,8 +2,10 @@ declare(strict_types=1); +namespace Membrane\OpenAPIReader\Tests; use cebe\{openapi\exceptions as CebeException}; +use Generator; use Membrane\OpenAPIReader\{FileFormat, Method, OpenAPIVersion}; use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; use Membrane\OpenAPIReader\Factory\V30\FromCebe; @@ -16,6 +18,7 @@ use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test, TestDox, UsesClass}; use PHPUnit\Framework\TestCase; +use TypeError; #[CoversClass(MembraneReader::class)] #[CoversClass(CannotRead::class), CoversClass(CannotSupport::class), CoversClass(InvalidOpenAPI::class)] From 64dbb4ed5feb34a3c4fb0e8d7c5969523f36497a Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 09:44:56 +0000 Subject: [PATCH 24/56] Test isRedundant() on Method Enum --- tests/MethodTest.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/MethodTest.php diff --git a/tests/MethodTest.php b/tests/MethodTest.php new file mode 100644 index 0000000..2c9d037 --- /dev/null +++ b/tests/MethodTest.php @@ -0,0 +1,30 @@ +isRedundant()); + } + + public static function provideRedundantMethods(): Generator + { + yield 'head' => [Method::HEAD]; + yield 'options' => [Method::OPTIONS]; + yield 'trace' => [Method::TRACE]; + } +} From 9b3b3749eac38c02ab98fd5dc47cf47a85885f8b Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 10:19:19 +0000 Subject: [PATCH 25/56] Improve coverage of Readers handling Server Variables --- tests/Factory/V30/FromCebeTest.php | 3 + tests/MembraneReaderTest.php | 2 + tests/ReaderTest.php | 2 + tests/fixtures/Helper/OpenAPIProvider.php | 120 ++++------------------ 4 files changed, 27 insertions(+), 100 deletions(-) diff --git a/tests/Factory/V30/FromCebeTest.php b/tests/Factory/V30/FromCebeTest.php index 11d0db0..1d550d9 100644 --- a/tests/Factory/V30/FromCebeTest.php +++ b/tests/Factory/V30/FromCebeTest.php @@ -18,6 +18,7 @@ use Membrane\OpenAPIReader\ValueObject\Valid\V30\PathItem; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Schema; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Server; +use Membrane\OpenAPIReader\ValueObject\Valid\V30\ServerVariable; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; use Membrane\OpenAPIReader\ValueObject\Valid\Warning; use Membrane\OpenAPIReader\ValueObject\Valid\Warnings; @@ -32,6 +33,8 @@ #[UsesClass(Partial\OpenAPI::class)] #[UsesClass(Server::class)] #[UsesClass(Partial\Server::class)] +#[UsesClass(ServerVariable::class)] +#[UsesClass(Partial\ServerVariable::class)] #[UsesClass(PathItem::class)] #[UsesClass(Partial\PathItem::class)] #[UsesClass(Operation::class)] diff --git a/tests/MembraneReaderTest.php b/tests/MembraneReaderTest.php index e39b728..7c4237c 100644 --- a/tests/MembraneReaderTest.php +++ b/tests/MembraneReaderTest.php @@ -29,6 +29,8 @@ #[UsesClass(Valid\V30\OpenAPI::class)] #[UsesClass(Partial\Server::class)] #[UsesClass(Valid\V30\Server::class)] +#[UsesClass(Partial\ServerVariable::class)] +#[UsesClass(Valid\V30\ServerVariable::class)] #[UsesClass(Partial\PathItem::class)] #[UsesClass(Valid\V30\PathItem::class)] #[UsesClass(Partial\Operation::class)] diff --git a/tests/ReaderTest.php b/tests/ReaderTest.php index a6b67fb..3c9a99c 100644 --- a/tests/ReaderTest.php +++ b/tests/ReaderTest.php @@ -29,6 +29,8 @@ #[UsesClass(Valid\V30\OpenAPI::class)] #[UsesClass(Partial\Server::class)] #[UsesClass(Valid\V30\Server::class)] +#[UsesClass(Partial\ServerVariable::class)] +#[UsesClass(Valid\V30\ServerVariable::class)] #[UsesClass(Partial\PathItem::class)] #[UsesClass(Valid\V30\PathItem::class)] #[UsesClass(Partial\Operation::class)] diff --git a/tests/fixtures/Helper/OpenAPIProvider.php b/tests/fixtures/Helper/OpenAPIProvider.php index b50f45e..13f189b 100644 --- a/tests/fixtures/Helper/OpenAPIProvider.php +++ b/tests/fixtures/Helper/OpenAPIProvider.php @@ -63,7 +63,15 @@ public static function detailedV30String(): string 'openapi' => '3.0.0', 'info' => ['title' => 'My Detailed OpenAPI', 'version' => '1.0.1'], 'servers' => [ - ['url' => 'https://server.net'] + [ + 'url' => 'https://server.net/{version}', + 'variables' => [ + 'version' => [ + 'default' => '2.1', + 'enum' => ['2.0', '2.1', '2.2',] + ] + ] + ] ], 'paths' => [ '/first' => [ @@ -169,104 +177,7 @@ public static function detailedV30String(): string */ public static function detailedV30CebeObject(): Cebe\OpenApi { - return new Cebe\OpenApi([ - 'openapi' => '3.0.0', - 'info' => ['title' => 'My Detailed OpenAPI', 'version' => '1.0.1'], - 'servers' => [ - ['url' => 'https://server.net'] - ], - 'paths' => [ - '/first' => [ - 'parameters' => [ - [ - 'name' => 'limit', - 'in' => 'query', - 'required' => false, - 'schema' => ['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' - ] - ] - ] - ] - ], - ]); + return new Cebe\OpenApi(json_decode(self::detailedV30String(), true)); } /** @@ -280,7 +191,16 @@ public static function detailedV30MembraneObject(): OpenAPI title: 'My Detailed OpenAPI', version: '1.0.1', servers: [ - PartialHelper::createServer(url: 'https://server.net'), + PartialHelper::createServer( + url: 'https://server.net/{version}', + variables: [ + PartialHelper::createServerVariable( + name: 'version', + default: '2.1', + enum: ['2.0', '2.1', '2.2'], + ), + ] + ), ], paths: [ PartialHelper::createPathItem( From d25a6d9fc0cb6c15e1526f168c6bf566e1e056de Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 10:20:06 +0000 Subject: [PATCH 26/56] Remove redundant hasVariables method from Server - empty($server->variables) achieves the same result. --- src/ValueObject/Valid/V30/Server.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/ValueObject/Valid/V30/Server.php b/src/ValueObject/Valid/V30/Server.php index 31fb716..9e6d136 100644 --- a/src/ValueObject/Valid/V30/Server.php +++ b/src/ValueObject/Valid/V30/Server.php @@ -45,11 +45,6 @@ public function __construct( ); } - public function hasVariables(): bool - { - return preg_match('#{[^/]+}#', $this->url) === 1; - } - /** * Returns the list of variable names in order of appearance within the URL. * @return array From bf79bb23589b270999932d185911add72efef243 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 11:25:54 +0000 Subject: [PATCH 27/56] Test Parameter can have any valid style per location --- tests/ValueObject/Valid/V30/ParameterTest.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/ValueObject/Valid/V30/ParameterTest.php b/tests/ValueObject/Valid/V30/ParameterTest.php index 8d6e8b7..4162145 100644 --- a/tests/ValueObject/Valid/V30/ParameterTest.php +++ b/tests/ValueObject/Valid/V30/ParameterTest.php @@ -6,6 +6,8 @@ use Generator; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; +use Membrane\OpenAPIReader\In; +use Membrane\OpenAPIReader\Style; use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; @@ -24,6 +26,7 @@ #[CoversClass(Partial\Parameter::class)] // DTO #[CoversClass(InvalidOpenAPI::class)] #[UsesClass(MediaType::class)] +#[UsesClass(Partial\Schema::class)] #[UsesClass(Schema::class)] #[UsesClass(Identifier::class)] #[UsesClass(Validated::class)] @@ -66,6 +69,21 @@ public function itCanTellIfItHasAMediaType( 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) + ); + + self::assertSame($style, $sut->style); + self::assertSame($in, $sut->in); + } + public static function provideInvalidPartialParameters(): Generator { $parentIdentifier = new Identifier('test'); @@ -237,4 +255,26 @@ public static function provideParametersWithOrWithoutMediaTypes(): Generator ) ]; } + + /** + * @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]; + } } From 84cd6818eca8b0646c1e69047785e8624b288a62 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 11:26:25 +0000 Subject: [PATCH 28/56] Test Server throws Exception for ServerVariables without names --- tests/ValueObject/Valid/V30/ServerTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/ValueObject/Valid/V30/ServerTest.php b/tests/ValueObject/Valid/V30/ServerTest.php index 346bd67..b50dd99 100644 --- a/tests/ValueObject/Valid/V30/ServerTest.php +++ b/tests/ValueObject/Valid/V30/ServerTest.php @@ -159,6 +159,16 @@ public static function provideServersToValidate(): Generator ] ); + 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}'), From b34e870010171da12b6ee70c0133731ca77ed862 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 11:26:59 +0000 Subject: [PATCH 29/56] Test PathItem and Operation warn against duplicate Servers --- tests/ValueObject/Valid/V30/OperationTest.php | 76 +++++++++++++++++++ tests/ValueObject/Valid/V30/PathItemTest.php | 74 +++++++++++++++++- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/tests/ValueObject/Valid/V30/OperationTest.php b/tests/ValueObject/Valid/V30/OperationTest.php index 2c815d1..f3d62a9 100644 --- a/tests/ValueObject/Valid/V30/OperationTest.php +++ b/tests/ValueObject/Valid/V30/OperationTest.php @@ -150,6 +150,48 @@ public function itOverridesPathLevelServers( self::assertEquals($expected, $sut->servers); } + + /** + * @param Warning[] $expected + */ + #[Test, DataProvider('provideDuplicateServers')] + public function itWarnsAgainstDuplicateServers( + array $expected, + Partial\Operation $operation, + ): void { + $sut = new Operation( + new Identifier('test'), + [], + [], + Method::GET, + $operation + ); + + self::assertEquals($expected, $sut + ->getWarnings() + ->findByWarningCodes(Warning::IDENTICAL_SERVER_URLS)); + } + + + #[Test] + #[TestDox('The PathItem object will already warn about its own duplicates')] + public function itWillNotWarnForDuplicatePathLevelServers(): void + { + $identifier = new Identifier('test'); + $server = new Server($identifier, PartialHelper::createServer()); + $sut = new Operation( + $identifier, + [$server, $server], + [], + Method::GET, + PartialHelper::createOperation(), + ); + + self::assertEmpty($sut + ->getWarnings() + ->findByWarningCodes(Warning::IDENTICAL_SERVER_URLS)); + } + public static function provideParameters(): Generator { $parentIdentifier = new Identifier('/path'); @@ -272,4 +314,38 @@ public static function provideServers(): Generator 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('/'), + ]); + } } diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index e186e2f..dfb648c 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -98,10 +98,42 @@ public function itWarnsAgainstSimilarNames(string $name1, string $name2): void ]) ); - self::assertSame( - Warning::SIMILAR_NAMES, - $sut->getWarnings()->all()[0]->code + self::assertNotEmpty($sut + ->getWarnings() + ->findByWarningCodes(Warning::SIMILAR_NAMES)); + } + + /** + * @param Warning[] $expected + */ + #[Test, DataProvider('provideDuplicateServers')] + public function itWarnsAgainstDuplicateServers( + array $expected, + Partial\PathItem $pathItem, + ): void { + $sut = new PathItem(new Identifier('test'), [], $pathItem); + + self::assertEquals($expected, $sut + ->getWarnings() + ->findByWarningCodes(Warning::IDENTICAL_SERVER_URLS)); + } + + + #[Test] + #[TestDox('The OpenAPI object will already warn about its own duplicates')] + public function itWillNotWarnForDuplicateRootLevelServers(): void + { + $identifier = new Identifier('test'); + $server = new Server($identifier, PartialHelper::createServer()); + $sut = new PathItem( + $identifier, + [$server, $server], + PartialHelper::createPathItem() ); + + self::assertEmpty($sut + ->getWarnings() + ->findByWarningCodes(Warning::IDENTICAL_SERVER_URLS)); } #[Test, DataProvider('provideRedundantMethods')] @@ -225,6 +257,40 @@ 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']; @@ -321,6 +387,6 @@ public static function provideServers(): Generator public static function provideServersToWarnAgainst(): Generator { - + } } From 6bc3a4e95fa718020bcbd9479c3e963bf755fe6a Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 12:45:21 +0000 Subject: [PATCH 30/56] Add IsIdentical and isSimilar to V3.0. Parameter --- src/ValueObject/Valid/V30/Parameter.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php index e218352..64dd130 100644 --- a/src/ValueObject/Valid/V30/Parameter.php +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -111,6 +111,17 @@ 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); + } + private function validateIn(Identifier $identifier, ?string $in): In { if (is_null($in)) { From eb9eaf13238f4f48ca34fd00bd69c00d1a13e604 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 16:24:19 +0000 Subject: [PATCH 31/56] Test Parameter will set a default Style --- tests/ValueObject/Valid/V30/ParameterTest.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/ValueObject/Valid/V30/ParameterTest.php b/tests/ValueObject/Valid/V30/ParameterTest.php index 4162145..f0326d1 100644 --- a/tests/ValueObject/Valid/V30/ParameterTest.php +++ b/tests/ValueObject/Valid/V30/ParameterTest.php @@ -84,6 +84,20 @@ public function itCanHaveAnyValidStylePerLocation( self::assertSame($in, $sut->in); } + #[Test] + #[DataProvider('provideDefaultStylesPerLocation')] + public function itWillDefaultStylePerLocation( + Style $expected, + In $in, + ): void { + $sut = new Parameter( + new Identifier('test'), + PartialHelper::createParameter(in: $in->value) + ); + + self::assertSame($expected, $sut->style); + } + public static function provideInvalidPartialParameters(): Generator { $parentIdentifier = new Identifier('test'); @@ -277,4 +291,21 @@ public static function provideValidStylesPerLocation(): Generator 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]; + } } From 8582c5cb59b296cfae3bdee9fb53e5f4df7790b2 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 16:24:43 +0000 Subject: [PATCH 32/56] Test that Parameter can check if it is identical or similary named --- tests/ValueObject/Valid/V30/ParameterTest.php | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/ValueObject/Valid/V30/ParameterTest.php b/tests/ValueObject/Valid/V30/ParameterTest.php index f0326d1..7869b5a 100644 --- a/tests/ValueObject/Valid/V30/ParameterTest.php +++ b/tests/ValueObject/Valid/V30/ParameterTest.php @@ -98,6 +98,38 @@ public function itWillDefaultStylePerLocation( self::assertSame($expected, $sut->style); } + #[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)); + } + public static function provideInvalidPartialParameters(): Generator { $parentIdentifier = new Identifier('test'); @@ -308,4 +340,52 @@ public static function provideDefaultStylesPerLocation(): Generator yield 'form - cookie' => [Style::Form, In::Cookie]; } + + /** + * @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 'not similar - "äöü" and "param"' => [false, 'äöü', 'param']; + } } From 6bcb846b3c1ccee7a099f424c2158ac4e52784e3 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 16:25:27 +0000 Subject: [PATCH 33/56] Refactor Operation and PathItem - Make use of new Parameter methods isIdentical and isSimilar --- src/ValueObject/Valid/V30/Operation.php | 20 ++++++-------------- src/ValueObject/Valid/V30/PathItem.php | 19 ++----------------- 2 files changed, 8 insertions(+), 31 deletions(-) diff --git a/src/ValueObject/Valid/V30/Operation.php b/src/ValueObject/Valid/V30/Operation.php index aa2c7bf..d042dd4 100644 --- a/src/ValueObject/Valid/V30/Operation.php +++ b/src/ValueObject/Valid/V30/Operation.php @@ -117,7 +117,7 @@ private function validateParameters( foreach ($result as $index => $parameter) { foreach (array_slice($result, $index + 1) as $otherParameter) { - if ($this->areParametersIdentical($parameter, $otherParameter)) { + if ($parameter->isIdentical($otherParameter)) { throw InvalidOpenAPI::duplicateParameters( $this->getIdentifier(), $parameter->getIdentifier(), @@ -125,7 +125,7 @@ private function validateParameters( ); } - if ($this->areParametersSimilar($parameter, $otherParameter)) { + if ($parameter->isSimilar($otherParameter)) { $this->addWarning( <<areParametersIdentical($pathParameter, $operationParameter)) { - break; + continue 2; } - $result[] = $pathParameter; } + $result[] = $pathParameter; } + return array_values($result); } @@ -168,16 +169,7 @@ private function areParametersIdentical( Parameter $parameter, Parameter $otherParameter ): bool { - return $parameter->name === $otherParameter->name && - $parameter->in === $otherParameter->in; - } - - private function areParametersSimilar( - Parameter $parameter, - Parameter $otherParameter - ): bool { - return $parameter->name !== $otherParameter->name && - strcasecmp($parameter->name, $otherParameter->name) === 0; + return $parameter->isIdentical($otherParameter); } private function canParameterConflict(Parameter $parameter): bool diff --git a/src/ValueObject/Valid/V30/PathItem.php b/src/ValueObject/Valid/V30/PathItem.php index eee60ab..bb117ad 100644 --- a/src/ValueObject/Valid/V30/PathItem.php +++ b/src/ValueObject/Valid/V30/PathItem.php @@ -130,7 +130,7 @@ private function validateParameters(array $parameters): array foreach ($result as $index => $parameter) { foreach (array_slice($result, $index + 1) as $otherParameter) { - if ($this->areParametersIdentical($parameter, $otherParameter)) { + if ($parameter->isIdentical($otherParameter)) { throw InvalidOpenAPI::duplicateParameters( $this->getIdentifier(), $parameter->getIdentifier(), @@ -138,7 +138,7 @@ private function validateParameters(array $parameters): array ); } - if ($this->areParametersSimilar($parameter, $otherParameter)) { + if ($parameter->isSimilar($otherParameter)) { $this->addWarning( <<name === $otherParameter->name && - $parameter->in === $otherParameter->in; - } - - private function areParametersSimilar( - Parameter $parameter, - Parameter $otherParameter - ): bool { - return strcasecmp($parameter->name, $otherParameter->name) === 0; - } - private function validateOperation( Method $method, ?Partial\Operation $operation From 0bb8abe164a4abd4e4d5daa9276a10fda3598d8a Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 16:26:18 +0000 Subject: [PATCH 34/56] Test Operation and PathItem warn about similar Parameters --- tests/ValueObject/Valid/V30/OperationTest.php | 59 +++++++++++++++++++ tests/ValueObject/Valid/V30/PathItemTest.php | 28 +++++++-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/tests/ValueObject/Valid/V30/OperationTest.php b/tests/ValueObject/Valid/V30/OperationTest.php index f3d62a9..3d160ea 100644 --- a/tests/ValueObject/Valid/V30/OperationTest.php +++ b/tests/ValueObject/Valid/V30/OperationTest.php @@ -192,6 +192,47 @@ public function itWillNotWarnForDuplicatePathLevelServers(): void ->findByWarningCodes(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 = new Operation( + new Identifier('test'), + [], + $pathParameters, + Method::GET, + PartialHelper::createOperation(parameters: $operationParameters), + ); + self::assertNotEmpty($sut + ->getWarnings() + ->findByWarningCodes(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 = new Operation( + new Identifier('test'), + [], + [$parameter, $parameter], + Method::GET, + PartialHelper::createOperation(), + ); + + self::assertEmpty($sut->getWarnings()->findByWarningCodes(Warning::SIMILAR_NAMES)); + } + public static function provideParameters(): Generator { $parentIdentifier = new Identifier('/path'); @@ -348,4 +389,22 @@ public static function provideDuplicateServers(): Generator 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']); + } } diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index dfb648c..25fb22a 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -85,16 +85,34 @@ public function itInvalidatesDuplicateParameters(): void new PathItem($identifier, [], $pathItem); } - #[Test, DataProvider('provideSimilarNames')] + #[Test] + #[TestDox('Identical names in different locations may serve a purpose')] + public function itDoesNotWarnAgainstIdenticalNames(): void + { + $sut = new PathItem( + new Identifier('test-path-item'), + [], + PartialHelper::createPathItem(parameters: [ + PartialHelper::createParameter(name: 'param', in: 'path'), + PartialHelper::createParameter(name: 'param', in: 'query') + ]) + ); + + self::assertEmpty($sut + ->getWarnings() + ->findByWarningCodes(Warning::SIMILAR_NAMES)); + } + + #[Test] #[TestDox('It warns that similar names, though valid, may be confusing')] - public function itWarnsAgainstSimilarNames(string $name1, string $name2): void + public function itWarnsAgainstSimilarNames(): void { $sut = new PathItem( new Identifier('test-path-item'), [], PartialHelper::createPathItem(parameters: [ - PartialHelper::createParameter(name: $name1, in:'path'), - PartialHelper::createParameter(name: $name2, in:'query') + PartialHelper::createParameter(name: 'param'), + PartialHelper::createParameter(name: 'PARAM') ]) ); @@ -253,8 +271,8 @@ public static function providePartialPathItems(): Generator public static function provideSimilarNames(): Generator { - yield 'two identical names' => ['param', 'param']; yield 'two names that only differ in case' => ['param', 'PARAM']; + } /** From 93817636178ac4cf7ff4fe402ebe2d8797211946 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 16:28:42 +0000 Subject: [PATCH 35/56] Remove redundant Exception constructor - Membrane now supports all Methods so "unsupportedMethod" is redundant --- src/Exception/CannotSupport.php | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/Exception/CannotSupport.php b/src/Exception/CannotSupport.php index 65a05d6..4eca0e8 100644 --- a/src/Exception/CannotSupport.php +++ b/src/Exception/CannotSupport.php @@ -13,22 +13,12 @@ final class CannotSupport extends RuntimeException { - public const UNSUPPORTED_METHOD = 0; - public const UNSUPPORTED_VERSION = 1; - public const MISSING_OPERATION_ID = 2; - public const MISSING_TYPE_DECLARATION = 3; - public const AMBIGUOUS_RESOLUTION = 4; + public const UNSUPPORTED_VERSION = 0; + public const MISSING_OPERATION_ID = 1; + public const MISSING_TYPE_DECLARATION = 2; + public const AMBIGUOUS_RESOLUTION = 3; public const CANNOT_PARSE = 4; - public static function unsupportedMethod(string $pathUrl, string $method): self - { - $message = << Date: Wed, 27 Mar 2024 16:53:38 +0000 Subject: [PATCH 36/56] Test Parameter correctly defaults optional fields --- tests/ValueObject/Valid/V30/ParameterTest.php | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/tests/ValueObject/Valid/V30/ParameterTest.php b/tests/ValueObject/Valid/V30/ParameterTest.php index 7869b5a..0991421 100644 --- a/tests/ValueObject/Valid/V30/ParameterTest.php +++ b/tests/ValueObject/Valid/V30/ParameterTest.php @@ -86,18 +86,55 @@ public function itCanHaveAnyValidStylePerLocation( #[Test] #[DataProvider('provideDefaultStylesPerLocation')] - public function itWillDefaultStylePerLocation( + public function itCanDefaultStylePerLocation( Style $expected, In $in, ): void { $sut = new Parameter( new Identifier('test'), - PartialHelper::createParameter(in: $in->value) + 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 + ) + ); + + 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( @@ -341,6 +378,30 @@ public static function provideDefaultStylesPerLocation(): Generator 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 Date: Wed, 27 Mar 2024 17:05:48 +0000 Subject: [PATCH 37/56] Exclude src/Exception from Infection - The Exception folder only has static constructors. All these mutations are doing is changing the Exception message. --- infection.json5 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/infection.json5 b/infection.json5 index 771b5b6..c9078a5 100644 --- a/infection.json5 +++ b/infection.json5 @@ -3,6 +3,9 @@ "source": { "directories": [ "src" + ], + "excludes": [ + "Exception", // We don't need the Exception messages being altered ] }, "minCoveredMsi": 90, From cca9ae9f17f9d989db1bba5e57b9cdaa5b80ee6a Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 17:08:38 +0000 Subject: [PATCH 38/56] Move Warning constructor to top of class --- src/ValueObject/Valid/Warning.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ValueObject/Valid/Warning.php b/src/ValueObject/Valid/Warning.php index bb3b778..5a3659d 100644 --- a/src/ValueObject/Valid/Warning.php +++ b/src/ValueObject/Valid/Warning.php @@ -6,6 +6,12 @@ final class Warning { + public function __construct( + public readonly string $message, + public readonly string $code + ) { + } + /** * Server Variable: "enum" SHOULD NOT be empty * Schema: "enum" SHOULD have at least one element @@ -54,11 +60,4 @@ final class Warning * Path Item, Operation: "parameters" can have identical/similar names, but this could be quite confusing. */ public const SIMILAR_NAMES = 'similar-names'; - - - public function __construct( - public readonly string $message, - public readonly string $code - ) { - } } From 17e7970fe6728af10f0f862df1d75c1271a36914 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Mar 2024 18:02:32 +0000 Subject: [PATCH 39/56] Refactor Operation - Use Parameter methods for Parameter-specific logic --- src/ValueObject/Valid/V30/Operation.php | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/ValueObject/Valid/V30/Operation.php b/src/ValueObject/Valid/V30/Operation.php index d042dd4..2cb00af 100644 --- a/src/ValueObject/Valid/V30/Operation.php +++ b/src/ValueObject/Valid/V30/Operation.php @@ -155,7 +155,7 @@ private function mergeParameters(array $operationParameters, array $pathParamete foreach ($pathParameters as $pathParameter) { foreach ($result as $operationParameter) { - if ($this->areParametersIdentical($pathParameter, $operationParameter)) { + if ($operationParameter->isIdentical($pathParameter)) { continue 2; } } @@ -165,13 +165,6 @@ private function mergeParameters(array $operationParameters, array $pathParamete return array_values($result); } - private function areParametersIdentical( - Parameter $parameter, - Parameter $otherParameter - ): bool { - return $parameter->isIdentical($otherParameter); - } - private function canParameterConflict(Parameter $parameter): bool { if ($parameter->in !== In::Query) { From 54794874de84bddf259a6234f61ad45b9f04efc5 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 28 Mar 2024 10:45:48 +0000 Subject: [PATCH 40/56] Rename Warnings methods --- src/ValueObject/Valid/Warnings.php | 6 +++--- tests/ValueObject/Valid/V30/OpenAPITest.php | 4 ++-- tests/ValueObject/Valid/V30/OperationTest.php | 8 ++++---- tests/ValueObject/Valid/V30/PathItemTest.php | 8 ++++---- tests/ValueObject/Valid/WarningsTest.php | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/ValueObject/Valid/Warnings.php b/src/ValueObject/Valid/Warnings.php index cbb73a5..8bd72d2 100644 --- a/src/ValueObject/Valid/Warnings.php +++ b/src/ValueObject/Valid/Warnings.php @@ -33,7 +33,7 @@ public function all(): array } /** @return Warning[] */ - public function findByWarningCodes(string $code, string ...$codes): array + public function findByWarningCode(string $code, string ...$codes): array { return array_filter( $this->warnings, @@ -41,9 +41,9 @@ public function findByWarningCodes(string $code, string ...$codes): array ); } - public function hasWarningCodes(string $code, string ...$codes): bool + public function hasWarningCode(string $code, string ...$codes): bool { - return !empty($this->findByWarningCodes($code, ...$codes)); + return !empty($this->findByWarningCode($code, ...$codes)); } public function hasWarnings(): bool diff --git a/tests/ValueObject/Valid/V30/OpenAPITest.php b/tests/ValueObject/Valid/V30/OpenAPITest.php index fd3fb3a..d18fd31 100644 --- a/tests/ValueObject/Valid/V30/OpenAPITest.php +++ b/tests/ValueObject/Valid/V30/OpenAPITest.php @@ -56,7 +56,7 @@ public function itWarnsAgainstEmptyPaths(): void $expected = [new Warning('No Paths in OpenAPI', Warning::EMPTY_PATHS)]; $sut = new OpenAPI(PartialHelper::createOpenAPI(paths: [])); - $actual = $sut->getWarnings()->findByWarningCodes(Warning::EMPTY_PATHS); + $actual = $sut->getWarnings()->findByWarningCode(Warning::EMPTY_PATHS); self::assertEquals($expected, $actual); } @@ -75,7 +75,7 @@ public function itWarnsAgainstDuplicateServers( $expected, $sut ->getWarnings() - ->findByWarningCodes(Warning::IDENTICAL_SERVER_URLS) + ->findByWarningCode(Warning::IDENTICAL_SERVER_URLS) ); } diff --git a/tests/ValueObject/Valid/V30/OperationTest.php b/tests/ValueObject/Valid/V30/OperationTest.php index 3d160ea..8c12141 100644 --- a/tests/ValueObject/Valid/V30/OperationTest.php +++ b/tests/ValueObject/Valid/V30/OperationTest.php @@ -169,7 +169,7 @@ public function itWarnsAgainstDuplicateServers( self::assertEquals($expected, $sut ->getWarnings() - ->findByWarningCodes(Warning::IDENTICAL_SERVER_URLS)); + ->findByWarningCode(Warning::IDENTICAL_SERVER_URLS)); } @@ -189,7 +189,7 @@ public function itWillNotWarnForDuplicatePathLevelServers(): void self::assertEmpty($sut ->getWarnings() - ->findByWarningCodes(Warning::IDENTICAL_SERVER_URLS)); + ->findByWarningCode(Warning::IDENTICAL_SERVER_URLS)); } /** @@ -211,7 +211,7 @@ public function itWarnsAgainstSimilarParameters( ); self::assertNotEmpty($sut ->getWarnings() - ->findByWarningCodes(Warning::SIMILAR_NAMES)); + ->findByWarningCode(Warning::SIMILAR_NAMES)); } #[Test, TestDox('The PathItem will already warn about its own parameters')] @@ -230,7 +230,7 @@ public function itWillNotWarnAgainstSimilarPathParameters(): void PartialHelper::createOperation(), ); - self::assertEmpty($sut->getWarnings()->findByWarningCodes(Warning::SIMILAR_NAMES)); + self::assertEmpty($sut->getWarnings()->findByWarningCode(Warning::SIMILAR_NAMES)); } public static function provideParameters(): Generator diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index 25fb22a..39edf0b 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -100,7 +100,7 @@ public function itDoesNotWarnAgainstIdenticalNames(): void self::assertEmpty($sut ->getWarnings() - ->findByWarningCodes(Warning::SIMILAR_NAMES)); + ->findByWarningCode(Warning::SIMILAR_NAMES)); } #[Test] @@ -118,7 +118,7 @@ public function itWarnsAgainstSimilarNames(): void self::assertNotEmpty($sut ->getWarnings() - ->findByWarningCodes(Warning::SIMILAR_NAMES)); + ->findByWarningCode(Warning::SIMILAR_NAMES)); } /** @@ -133,7 +133,7 @@ public function itWarnsAgainstDuplicateServers( self::assertEquals($expected, $sut ->getWarnings() - ->findByWarningCodes(Warning::IDENTICAL_SERVER_URLS)); + ->findByWarningCode(Warning::IDENTICAL_SERVER_URLS)); } @@ -151,7 +151,7 @@ public function itWillNotWarnForDuplicateRootLevelServers(): void self::assertEmpty($sut ->getWarnings() - ->findByWarningCodes(Warning::IDENTICAL_SERVER_URLS)); + ->findByWarningCode(Warning::IDENTICAL_SERVER_URLS)); } #[Test, DataProvider('provideRedundantMethods')] diff --git a/tests/ValueObject/Valid/WarningsTest.php b/tests/ValueObject/Valid/WarningsTest.php index 7a2ea2e..6dd2947 100644 --- a/tests/ValueObject/Valid/WarningsTest.php +++ b/tests/ValueObject/Valid/WarningsTest.php @@ -64,7 +64,7 @@ public function itFindsWarningsByCodes( ): void { $sut = new Warnings(new Identifier('test'), ...$warnings); - self::assertEquals($expected, $sut->findByWarningCodes(...$codes)); + self::assertEquals($expected, $sut->findByWarningCode(...$codes)); } #[Test, DataProvider('provideWarnings')] @@ -83,7 +83,7 @@ public function itChecksItHasWarningCodes( ): void { $sut = new Warnings(new Identifier('test'), ...$warnings); - self::assertSame($expected, $sut->hasWarningCodes(...$codes)); + self::assertSame($expected, $sut->hasWarningCode(...$codes)); } /** From aecf322db9683f2a0d45191944d7183b5123961f Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 28 Mar 2024 10:52:46 +0000 Subject: [PATCH 41/56] Extend canItBeThisType Schema method - Same behaviour but it can check multiple types at once --- src/ValueObject/Valid/V30/Schema.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index 17514b3..d1f7448 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -81,15 +81,15 @@ public function canItBeAnArray(): bool return $this->canItBeThisType('array'); } - private function canItBeThisType(string $type): bool + private function canItBeThisType(string $type, string ...$types): bool { - if ($this->type === $type) { + if (in_array($this->type, [$type, ...$types])) { return true; } return array_reduce( [...($this->allOf ?? []), ...($this->anyOf ?? []), ...($this->oneOf ?? [])], - fn($v, Schema $schema) => $v || $schema->canItBeThisType($type), + fn($v, Schema $schema) => $v || $schema->canItBeThisType($type, ...$types), false ); } From c0a8e3e03ef5a5399f7f262820e709f23bedcc40 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 28 Mar 2024 12:22:49 +0000 Subject: [PATCH 42/56] Update handling of conflicting Styles --- src/ValueObject/Valid/V30/Operation.php | 32 +++---------- src/ValueObject/Valid/V30/Parameter.php | 21 +++++++++ src/ValueObject/Valid/V30/Schema.php | 2 +- tests/MembraneReaderTest.php | 63 ++++++++++--------------- tests/ReaderTest.php | 57 ++++++++-------------- 5 files changed, 73 insertions(+), 102 deletions(-) diff --git a/src/ValueObject/Valid/V30/Operation.php b/src/ValueObject/Valid/V30/Operation.php index 2cb00af..5942491 100644 --- a/src/ValueObject/Valid/V30/Operation.php +++ b/src/ValueObject/Valid/V30/Operation.php @@ -6,9 +6,7 @@ use Membrane\OpenAPIReader\Exception\CannotSupport; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; -use Membrane\OpenAPIReader\In; use Membrane\OpenAPIReader\Method; -use Membrane\OpenAPIReader\Style; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; @@ -65,13 +63,6 @@ public function __construct( $pathParameters, $operation->parameters ); - - $parametersThatCanConflict = array_filter($this->parameters, fn($p) => $this->canParameterConflict($p)); - if (count($parametersThatCanConflict) > 1) { - throw CannotSupport::conflictingParameterStyles( - ...array_map(fn($p) => (string)$p->getIdentifier(), $parametersThatCanConflict) - ); - } } /** @@ -135,6 +126,13 @@ private function validateParameters( Warning::SIMILAR_NAMES ); } + + if ($parameter->canConflict($otherParameter)) { + throw CannotSupport::conflictingParameterStyles( + (string) $parameter->getIdentifier(), + (string) $otherParameter->getIdentifier(), + ); + } } } @@ -164,20 +162,4 @@ private function mergeParameters(array $operationParameters, array $pathParamete return array_values($result); } - - private function canParameterConflict(Parameter $parameter): bool - { - if ($parameter->in !== In::Query) { - return false; - } - - $canBeObject = $parameter->getSchema()->canItBeAnObject(); - $canBeArray = $parameter->getSchema()->canItBeAnArray(); - - return match ($parameter->style) { - Style::Form => $canBeObject && $parameter->explode, - Style::PipeDelimited, Style::SpaceDelimited => $canBeObject || $canBeArray, - default => false, - }; - } } diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php index 64dd130..7e5c370 100644 --- a/src/ValueObject/Valid/V30/Parameter.php +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -122,6 +122,27 @@ public function isSimilar(Parameter $other): bool mb_strtolower($this->name) === mb_strtolower($other->name); } + public function canConflict(Parameter $other): bool + { + if ( + $this->in !== $other->in || // parameter can be identified by differing location + $this->style !== $other->style || // parameter can be identified by differing style + $this->in !== In::Query + ) { + return false; + } + + return match ($this->style) { + Style::Form => $this->explode && $other->explode && $this + ->getSchema() + ->canItBeThisType('object'), + Style::PipeDelimited, Style::SpaceDelimited => $this + ->getSchema() + ->canItBeThisType('array', 'object'), + default => false, + }; + } + private function validateIn(Identifier $identifier, ?string $in): In { if (is_null($in)) { diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index d1f7448..3aaad1a 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -81,7 +81,7 @@ public function canItBeAnArray(): bool return $this->canItBeThisType('array'); } - private function canItBeThisType(string $type, string ...$types): bool + public function canItBeThisType(string $type, string ...$types): bool { if (in_array($this->type, [$type, ...$types])) { return true; diff --git a/tests/MembraneReaderTest.php b/tests/MembraneReaderTest.php index 7c4237c..1c2833e 100644 --- a/tests/MembraneReaderTest.php +++ b/tests/MembraneReaderTest.php @@ -294,9 +294,11 @@ public function itCannotResolveInvalidReferenceFromFile(string $openAPIString): #[Test] #[DataProvider('provideOpenAPIWithInvalidReference')] - public function itCannotResolveInvalidReferenceFromString(string $openAPIString,): void + public function itCannotResolveInvalidReferenceFromString(string $openAPIString): void { - self::expectExceptionObject(CannotRead::unresolvedReference(new CebeException\UnresolvableReferenceException())); + self::expectExceptionObject(CannotRead::unresolvedReference( + new CebeException\UnresolvableReferenceException() + )); (new MembraneReader([OpenAPIVersion::Version_3_0])) ->readFromString($openAPIString, FileFormat::Json); @@ -626,38 +628,7 @@ public static function provideConflictingParameters(): Generator ) ]; - yield 'operation with spaceDelimited and pipeDelimited exploding arrays' => [ - json_encode( - $openAPI($path( - [], - $operation([ - 'operationId' => 'test-op', - 'parameters' => [ - [ - 'name' => 'param1', - 'in' => 'query', - 'explode' => true, - 'style' => 'spaceDelimited', - 'schema' => ['type' => 'array'], - ], - [ - 'name' => 'param2', - 'in' => 'query', - 'explode' => true, - 'style' => 'pipeDelimited', - 'schema' => ['type' => 'array'], - ] - ], - ]) - )) - ), - CannotSupport::conflictingParameterStyles( - '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param1(query)"]', - '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', - ) - ]; - - yield 'spaceDelimited exploding in path, pipeDelimited exploding in query' => [ + yield 'spaceDelimited exploding in path, pipeDelimited exploding in query and spaceDelimited exploding in query' => [ json_encode( $openAPI($path( [ @@ -678,18 +649,25 @@ public static function provideConflictingParameters(): Generator 'explode' => true, 'style' => 'pipeDelimited', 'schema' => ['type' => 'array'], + ], + [ + 'name' => 'param3', + 'in' => 'query', + 'explode' => true, + 'style' => 'spaceDelimited', + 'schema' => ['type' => 'array'], ] ], ]) )) ), CannotSupport::conflictingParameterStyles( - '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param3(query)"]', '["test-api(1.0.0)"]["/path"]["param1(query)"]', ) ]; - yield 'form exploding object in path, pipeDelimited exploding in query' => [ + yield 'form exploding object in path, pipeDelimited object in path, pipeDelimited exploding in query' => [ json_encode( $openAPI($path( [ @@ -700,12 +678,19 @@ public static function provideConflictingParameters(): Generator 'style' => 'form', 'schema' => ['type' => 'object'], ], + [ + 'name' => 'param2', + 'in' => 'query', + 'explode' => true, + 'style' => 'pipeDelimited', + 'schema' => ['type' => 'object'], + ], ], $operation([ 'operationId' => 'test-op', 'parameters' => [ [ - 'name' => 'param2', + 'name' => 'param3', 'in' => 'query', 'explode' => true, 'style' => 'pipeDelimited', @@ -716,8 +701,8 @@ public static function provideConflictingParameters(): Generator )) ), CannotSupport::conflictingParameterStyles( - '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', - '["test-api(1.0.0)"]["/path"]["param1(query)"]', + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param3(query)"]', + '["test-api(1.0.0)"]["/path"]["param2(query)"]', ) ]; } diff --git a/tests/ReaderTest.php b/tests/ReaderTest.php index 3c9a99c..502f18f 100644 --- a/tests/ReaderTest.php +++ b/tests/ReaderTest.php @@ -549,38 +549,7 @@ public static function provideConflictingParameters(): Generator ) ]; - yield 'operation with spaceDelimited and pipeDelimited exploding arrays' => [ - json_encode( - $openAPI($path( - [], - $operation([ - 'operationId' => 'test-op', - 'parameters' => [ - [ - 'name' => 'param1', - 'in' => 'query', - 'explode' => true, - 'style' => 'spaceDelimited', - 'schema' => ['type' => 'array'], - ], - [ - 'name' => 'param2', - 'in' => 'query', - 'explode' => true, - 'style' => 'pipeDelimited', - 'schema' => ['type' => 'array'], - ] - ], - ]) - )) - ), - CannotSupport::conflictingParameterStyles( - '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param1(query)"]', - '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', - ) - ]; - - yield 'spaceDelimited exploding in path, pipeDelimited exploding in query' => [ + yield 'spaceDelimited exploding in path, pipeDelimited exploding in query and spaceDelimited exploding in query' => [ json_encode( $openAPI($path( [ @@ -601,18 +570,25 @@ public static function provideConflictingParameters(): Generator 'explode' => true, 'style' => 'pipeDelimited', 'schema' => ['type' => 'array'], + ], + [ + 'name' => 'param3', + 'in' => 'query', + 'explode' => true, + 'style' => 'spaceDelimited', + 'schema' => ['type' => 'array'], ] ], ]) )) ), CannotSupport::conflictingParameterStyles( - '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param3(query)"]', '["test-api(1.0.0)"]["/path"]["param1(query)"]', ) ]; - yield 'form exploding object in path, pipeDelimited exploding in query' => [ + yield 'form exploding object in path, pipeDelimited object in path, pipeDelimited exploding in query' => [ json_encode( $openAPI($path( [ @@ -623,12 +599,19 @@ public static function provideConflictingParameters(): Generator 'style' => 'form', 'schema' => ['type' => 'object'], ], + [ + 'name' => 'param2', + 'in' => 'query', + 'explode' => true, + 'style' => 'pipeDelimited', + 'schema' => ['type' => 'object'], + ], ], $operation([ 'operationId' => 'test-op', 'parameters' => [ [ - 'name' => 'param2', + 'name' => 'param3', 'in' => 'query', 'explode' => true, 'style' => 'pipeDelimited', @@ -639,8 +622,8 @@ public static function provideConflictingParameters(): Generator )) ), CannotSupport::conflictingParameterStyles( - '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param2(query)"]', - '["test-api(1.0.0)"]["/path"]["param1(query)"]', + '["test-api(1.0.0)"]["/path"]["test-op(get)"]["param3(query)"]', + '["test-api(1.0.0)"]["/path"]["param2(query)"]', ) ]; } From f57fa3bc3eec5af431e96efa3c80ec158b44467a Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 28 Mar 2024 15:52:50 +0000 Subject: [PATCH 43/56] Move Validated Object Enums --- src/{ => ValueObject/Valid/Enum}/In.php | 2 +- src/{ => ValueObject/Valid/Enum}/Method.php | 2 +- src/{ => ValueObject/Valid/Enum}/Style.php | 2 +- src/ValueObject/Valid/Enum/Type.php | 13 ++ src/ValueObject/Valid/V30/Operation.php | 2 +- src/ValueObject/Valid/V30/Parameter.php | 4 +- src/ValueObject/Valid/V30/PathItem.php | 2 +- src/ValueObject/Valid/V30/Schema.php | 1 + tests/Factory/V30/FromCebeTest.php | 2 +- tests/MembraneReaderTest.php | 2 +- tests/MethodTest.php | 2 +- tests/ReaderTest.php | 2 +- tests/ValueObject/Valid/V30/OpenAPITest.php | 2 +- tests/ValueObject/Valid/V30/OperationTest.php | 2 +- tests/ValueObject/Valid/V30/ParameterTest.php | 4 +- tests/ValueObject/Valid/V30/PathItemTest.php | 2 +- tests/ValueObject/Valid/V30/SchemaTest.php | 155 ++++++++++-------- 17 files changed, 113 insertions(+), 88 deletions(-) rename src/{ => ValueObject/Valid/Enum}/In.php (73%) rename src/{ => ValueObject/Valid/Enum}/Method.php (87%) rename src/{ => ValueObject/Valid/Enum}/Style.php (83%) create mode 100644 src/ValueObject/Valid/Enum/Type.php diff --git a/src/In.php b/src/ValueObject/Valid/Enum/In.php similarity index 73% rename from src/In.php rename to src/ValueObject/Valid/Enum/In.php index 8caf5f5..85fb089 100644 --- a/src/In.php +++ b/src/ValueObject/Valid/Enum/In.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Membrane\OpenAPIReader; +namespace Membrane\OpenAPIReader\ValueObject\Valid\Enum; enum In: string { diff --git a/src/Method.php b/src/ValueObject/Valid/Enum/Method.php similarity index 87% rename from src/Method.php rename to src/ValueObject/Valid/Enum/Method.php index 0f3b338..749d029 100644 --- a/src/Method.php +++ b/src/ValueObject/Valid/Enum/Method.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Membrane\OpenAPIReader; +namespace Membrane\OpenAPIReader\ValueObject\Valid\Enum; enum Method: string { diff --git a/src/Style.php b/src/ValueObject/Valid/Enum/Style.php similarity index 83% rename from src/Style.php rename to src/ValueObject/Valid/Enum/Style.php index dda2168..a1eee5c 100644 --- a/src/Style.php +++ b/src/ValueObject/Valid/Enum/Style.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Membrane\OpenAPIReader; +namespace Membrane\OpenAPIReader\ValueObject\Valid\Enum; enum Style: string { diff --git a/src/ValueObject/Valid/Enum/Type.php b/src/ValueObject/Valid/Enum/Type.php new file mode 100644 index 0000000..85fb089 --- /dev/null +++ b/src/ValueObject/Valid/Enum/Type.php @@ -0,0 +1,13 @@ +canItBeAnObject()); - } - - #[Test, DataProvider('provideSchemasToCheckIfTheyCanBeAnArray')] - public function itKnowsIfItCanBeAnArray( - bool $expected, - Partial\Schema $partialSchema, - ): void { - $sut = new Schema(new Identifier('sut'), $partialSchema); - - self::assertSame($expected, $sut->canItBeAnArray()); + self::assertSame($expected, $sut->canItBeThisType(...$types)); } public static function provideInvalidComplexSchemas(): Generator @@ -88,73 +82,90 @@ public static function provideInvalidComplexSchemas(): Generator } } - public static function provideSchemasToCheckIfTheyCanBeAnObject(): Generator - { - return self::provideSchemasToCheckIfTheyCanBeAType('object'); - } - - public static function provideSchemasToCheckIfTheyCanBeAnArray(): Generator - { - return self::provideSchemasToCheckIfTheyCanBeAType('array'); - } - - private static function provideSchemasToCheckIfTheyCanBeAType(string $desiredType): Generator + /** + * @return \Generator + */ + public static function provideSchemasToCheckTypes(): Generator { $types = ['boolean', 'number', 'integer', 'string', 'array', 'object']; - foreach ($types as $type) { - yield "top-level type:$type" => [ - $type === $desiredType, - PartialHelper::createSchema(type: $type) + foreach ($types as $desired) { + yield "it can always be $desired for empty schemas" => [ + true, + [$desired], + PartialHelper::createSchema(), ]; - yield "no top-level type, allOf MUST be $type" => [ - $type === $desiredType, - PartialHelper::createSchema(allOf: [ - PartialHelper::createSchema(type: $type), - ]) - ]; - - yield "no top-level type, anyOf MUST be $type" => [ - $type === $desiredType, - PartialHelper::createSchema(anyOf: [ - PartialHelper::createSchema(type: $type), - ]) - ]; - - yield "no top-level type, oneOf MUST be $type" => [ - $type === $desiredType, - PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: $type), - ]) - ]; - - yield "no top-level type, anyOf MAY be $type or string" => [ - $desiredType === $type, - PartialHelper::createSchema(anyOf: [ - PartialHelper::createSchema(type: $type), - PartialHelper::createSchema(type: 'string') - ]) - ]; + foreach ($types as $type) { + yield "can it be $desired? top level type: $type" => [ + $desired === $type, + [$desired], + PartialHelper::createSchema(type: $type) + ]; + + yield "can it be $desired? allOf MUST be $type" => [ + $desired === $type, + [$desired], + PartialHelper::createSchema(allOf: [ + PartialHelper::createSchema(type: $type), + ]) + ]; - yield "no top-level type, oneOf MAY be $type or boolean" => [ - $desiredType === $type, - PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: $type), - PartialHelper::createSchema(type: 'boolean') - ]) - ]; + yield "can it be $desired? anyOf MUST be $type" => [ + $desired === $type, + [$desired], + PartialHelper::createSchema(anyOf: [ + PartialHelper::createSchema(type: $type), + ]) + ]; - yield "no top-level type, allOf contains oneOf that may be $type or integer" => [ - $desiredType === $type, - PartialHelper::createSchema(allOf: [ + yield "can it be $desired? oneOf MUST be $type" => [ + $desired === $type, + [$desired], PartialHelper::createSchema(oneOf: [ PartialHelper::createSchema(type: $type), - PartialHelper::createSchema(type: 'integer') - ]), - ]) - ]; - + ]) + ]; + + if ($type !== 'string') { + yield "can it be $desired? anyOf MAY be $type|string" => [ + in_array($desired, [$type, 'string'], true), + [$desired], + PartialHelper::createSchema(anyOf: [ + PartialHelper::createSchema(type: 'string'), + PartialHelper::createSchema(type: $type), + ]) + ]; + } + + if ($type !== 'boolean') { + yield "can it be $desired? oneOf MAY be $type|boolean" => [ + in_array($desired, [$type, 'boolean'], true), + [$desired], + PartialHelper::createSchema(oneOf: [ + PartialHelper::createSchema(type: $type), + PartialHelper::createSchema(type: 'boolean'), + ]) + ]; + } + + if ($type !== 'integer') { + yield "can it be $desired? allOf contains oneOf that may be $type|integer" => [ + in_array($desired, [$type, 'integer'], true), + [$desired], + PartialHelper::createSchema(allOf: [ + PartialHelper::createSchema(oneOf: [ + PartialHelper::createSchema(type: $type), + PartialHelper::createSchema(type: 'integer') + ]), + ]) + ]; + } + } } } } From 19a26e349f35b0dc22c6374599588d858dd8208f Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 28 Mar 2024 15:54:27 +0000 Subject: [PATCH 44/56] Annotate that complex subSchemas cannot be empty --- src/ValueObject/Valid/V30/Schema.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index 9ee83b7..2034122 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -14,10 +14,22 @@ final class Schema extends Validated { public readonly ?string $type; /** @var self[]|null */ + /** + * This keyword's value MUST be a non-empty array. + * @var ?array + */ public readonly ?array $allOf; - /** @var self[]|null */ + + /** + * This keyword's value MUST be a non-empty array. + * @var ?array + */ public readonly ?array $anyOf; - /** @var self[]|null */ + + /** + * This keyword's value MUST be a non-empty array. + * @var ?array + */ public readonly ?array $oneOf; public function __construct( From 945530904346f5928409c0835c6789b512aa2a46 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 28 Mar 2024 18:53:47 +0000 Subject: [PATCH 45/56] Add Type Enum --- src/Exception/InvalidOpenAPI.php | 13 +++- src/MembraneReader.php | 2 +- src/ValueObject/Valid/Enum/Type.php | 63 +++++++++++++-- src/ValueObject/Valid/V30/Parameter.php | 5 +- src/ValueObject/Valid/V30/Schema.php | 76 ++++++++++++++----- tests/Factory/V30/FromCebeTest.php | 2 + tests/MembraneReaderTest.php | 7 +- tests/ReaderTest.php | 5 +- tests/ValueObject/Valid/V30/OperationTest.php | 2 + tests/ValueObject/Valid/V30/ParameterTest.php | 2 + tests/ValueObject/Valid/V30/SchemaTest.php | 55 +++++++------- 11 files changed, 175 insertions(+), 57 deletions(-) diff --git a/src/Exception/InvalidOpenAPI.php b/src/Exception/InvalidOpenAPI.php index b04dce4..9e6a936 100644 --- a/src/Exception/InvalidOpenAPI.php +++ b/src/Exception/InvalidOpenAPI.php @@ -280,6 +280,17 @@ public static function contentMissingMediaType(Identifier $identifier): self return new self($message); } + public static function invalidType(Identifier $identifier, string $type): self + { + $message = <<supportedVersions)) { throw CannotSupport::noSupportedVersions(); } - + /** todo create 3.1 validated objects */ if ($this->supportedVersions !== [OpenAPIVersion::Version_3_0]) { throw CannotSupport::membraneReaderOnlySupportsv30(); diff --git a/src/ValueObject/Valid/Enum/Type.php b/src/ValueObject/Valid/Enum/Type.php index 85fb089..65e111d 100644 --- a/src/ValueObject/Valid/Enum/Type.php +++ b/src/ValueObject/Valid/Enum/Type.php @@ -4,10 +4,63 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid\Enum; -enum In: string +use Membrane\OpenAPIReader\OpenAPIVersion; + +enum Type: string { - case Path = 'path'; - case Query = 'query'; - case Header = 'header'; - case Cookie = 'cookie'; + case Null = 'null'; + + case Boolean = 'boolean'; + case Integer = 'integer'; + case Number = 'number'; + case String = 'string'; + + case Array = 'array'; + case Object = 'object'; + + /** @return self[] */ + public static function casesSupportedByVersion( + OpenAPIVersion $version + ): array { + return match ($version) { + OpenAPIVersion::Version_3_0 => [ + Type::Boolean, + Type::Number, + Type::Integer, + Type::String, + Type::Array, + Type::Object, + ], + OpenAPIVersion::Version_3_1 => [ + Type::Null, + Type::Boolean, + Type::Number, + Type::Integer, + Type::String, + Type::Array, + Type::Object, + ] + }; + } + + /** @return string[] */ + public static function valuesSupportedByVersion( + OpenAPIVersion $version + ): array { + return array_map( + fn($t) => $t->value, + self::casesSupportedByVersion($version) + ); + } + + public static function tryFromCasesSupportedByVersion( + OpenAPIVersion $version, + string $type + ): ?self { + $type = self::tryFrom($type); + + return in_array($type, self::casesSupportedByVersion($version)) ? + $type : + null; + } } diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php index e19313e..58718f9 100644 --- a/src/ValueObject/Valid/V30/Parameter.php +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -8,6 +8,7 @@ use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\In; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Style; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; @@ -135,10 +136,10 @@ public function canConflict(Parameter $other): bool return match ($this->style) { Style::Form => $this->explode && $other->explode && $this ->getSchema() - ->canItBeThisType('object'), + ->canItBeThisType(Type::Object), Style::PipeDelimited, Style::SpaceDelimited => $this ->getSchema() - ->canItBeThisType('array', 'object'), + ->canItBeThisType(Type::Array, Type::Object), default => false, }; } diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index 2034122..b0df04d 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -5,6 +5,7 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid\V30; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; +use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; @@ -12,8 +13,12 @@ final class Schema extends Validated { - public readonly ?string $type; - /** @var self[]|null */ + /** + * This keyword's value MUST be one of the following: + * "boolean", "object", "array", "number", "string", or "integer" + */ + public readonly ?Type $type; + /** * This keyword's value MUST be a non-empty array. * @var ?array @@ -38,7 +43,7 @@ public function __construct( ) { parent::__construct($identifier); - $this->type = $schema->type ?? null; + $this->type = $this->validateType($this->getIdentifier(), $schema->type); if (isset($schema->allOf)) { if (empty($schema->allOf)) { @@ -68,6 +73,18 @@ public function __construct( } } + private function validateType(Identifier $identifier, ?string $type): ?Type + { + if (is_null($type)) { + return null; + } + + return Type::tryFromCasesSupportedByVersion( + OpenAPIVersion::Version_3_0, + $type + ) ?? throw InvalidOpenAPI::invalidType($identifier, $type); + } + /** * @param Partial\Schema[] $subSchemas * @return self[] @@ -84,26 +101,51 @@ private function getSubSchemas(string $keyword, array $subSchemas): array return $result; } - public function canItBeAnObject(): bool + public function canItBeThisType(Type $type, Type ...$types): bool { - return $this->canItBeThisType('object'); - } + $possibilities = array_map(fn($t) => Type::from($t), $this->whatTypesCanItBe()); - public function canItBeAnArray(): bool - { - return $this->canItBeThisType('array'); + foreach ([$type, ...$types] as $typeItCouldBe) { + if (in_array($typeItCouldBe, $possibilities)) { + return true; + } + } + + return false; } - public function canItBeThisType(string $type, string ...$types): bool + /** + * @return string[] + */ + public function whatTypesCanItBe(): array { - if (in_array($this->type, [$type, ...$types])) { - return true; + $possibilities = [Type::valuesSupportedByVersion(OpenAPIVersion::Version_3_0)]; + + if ($this->type !== null) { + $possibilities[] = [$this->type->value]; + } + + if (!empty($this->allOf)) { + $possibilities[] = array_intersect(...array_map( + fn($s) => $s->whatTypesCanItBe(), + $this->allOf, + )); + } + + if (!empty($this->anyOf)) { + $possibilities[] = array_unique(array_merge(...array_map( + fn($s) => $s->whatTypesCanItBe(), + $this->anyOf + ))); + } + + if (!empty($this->oneOf)) { + $possibilities[] = array_unique(array_merge(...array_map( + fn($s) => $s->whatTypesCanItBe(), + $this->oneOf + ))); } - return array_reduce( - [...($this->allOf ?? []), ...($this->anyOf ?? []), ...($this->oneOf ?? [])], - fn($v, Schema $schema) => $v || $schema->canItBeThisType($type, ...$types), - false - ); + return array_intersect(...$possibilities); } } diff --git a/tests/Factory/V30/FromCebeTest.php b/tests/Factory/V30/FromCebeTest.php index ecfe2d8..ba0f5c0 100644 --- a/tests/Factory/V30/FromCebeTest.php +++ b/tests/Factory/V30/FromCebeTest.php @@ -10,6 +10,7 @@ use Membrane\OpenAPIReader\Tests\Fixtures\Helper\OpenAPIProvider; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\V30\MediaType; use Membrane\OpenAPIReader\ValueObject\Valid\V30\OpenAPI; @@ -50,6 +51,7 @@ #[UsesClass(Warnings::class)] #[UsesClass(Identifier::class)] #[UsesClass(Method::class)] +#[UsesClass(Type::class)] class FromCebeTest extends TestCase { #[Test, DataProvider('provideCebeOpenAPIObjects')] diff --git a/tests/MembraneReaderTest.php b/tests/MembraneReaderTest.php index ea6e2da..3f74c17 100644 --- a/tests/MembraneReaderTest.php +++ b/tests/MembraneReaderTest.php @@ -6,7 +6,8 @@ use cebe\{openapi\exceptions as CebeException}; use Generator; -use Membrane\OpenAPIReader\{FileFormat, OpenAPIVersion, ValueObject\Valid\Enum\Method}; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; +use Membrane\OpenAPIReader\{FileFormat, OpenAPIVersion}; use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; use Membrane\OpenAPIReader\Factory\V30\FromCebe; use Membrane\OpenAPIReader\MembraneReader; @@ -22,7 +23,9 @@ #[CoversClass(MembraneReader::class)] #[CoversClass(CannotRead::class), CoversClass(CannotSupport::class), CoversClass(InvalidOpenAPI::class)] -#[UsesClass(FileFormat::class), UsesClass(Method::class), UsesClass(OpenAPIVersion::class)] +#[UsesClass(FileFormat::class), UsesClass(OpenAPIVersion::class)] +#[UsesClass(Method::class)] +#[UsesClass(Valid\Enum\Type::class)] #[UsesClass(FromCebe::class)] #[UsesClass(Identifier::class)] #[UsesClass(Partial\OpenAPI::class)] diff --git a/tests/ReaderTest.php b/tests/ReaderTest.php index 5276c86..f2fc61a 100644 --- a/tests/ReaderTest.php +++ b/tests/ReaderTest.php @@ -6,7 +6,8 @@ use cebe\{openapi\exceptions as CebeException, openapi\spec as CebeSpec}; use Generator; -use Membrane\OpenAPIReader\{CebeReader, FileFormat, OpenAPIVersion, ValueObject\Valid\Enum\Method}; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; +use Membrane\OpenAPIReader\{CebeReader, FileFormat, OpenAPIVersion}; use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; use Membrane\OpenAPIReader\Factory\V30\FromCebe; use Membrane\OpenAPIReader\Reader; @@ -22,7 +23,7 @@ #[CoversClass(Reader::class)] #[CoversClass(CebeReader::class)] #[CoversClass(CannotRead::class), CoversClass(CannotSupport::class), CoversClass(InvalidOpenAPI::class)] -#[UsesClass(FileFormat::class), UsesClass(Method::class), UsesClass(OpenAPIVersion::class)] +#[UsesClass(FileFormat::class), UsesClass(Method::class), UsesClass(OpenAPIVersion::class), UsesClass(Valid\Enum\Type::class)] #[UsesClass(FromCebe::class)] #[UsesClass(Identifier::class)] #[UsesClass(Partial\OpenAPI::class)] diff --git a/tests/ValueObject/Valid/V30/OperationTest.php b/tests/ValueObject/Valid/V30/OperationTest.php index 37fb663..56bc8a1 100644 --- a/tests/ValueObject/Valid/V30/OperationTest.php +++ b/tests/ValueObject/Valid/V30/OperationTest.php @@ -10,6 +10,7 @@ use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Operation; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Parameter; @@ -30,6 +31,7 @@ #[CoversClass(InvalidOpenAPI::class)] #[CoversClass(CannotSupport::class)] #[UsesClass(Server::class)] +#[UsesClass(Type::class)] #[UsesClass(Partial\Server::class)] #[UsesClass(Partial\Parameter::class)] #[UsesClass(Partial\Schema::class)] diff --git a/tests/ValueObject/Valid/V30/ParameterTest.php b/tests/ValueObject/Valid/V30/ParameterTest.php index 4a6ba4a..e3f9ecc 100644 --- a/tests/ValueObject/Valid/V30/ParameterTest.php +++ b/tests/ValueObject/Valid/V30/ParameterTest.php @@ -10,6 +10,7 @@ use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\In; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Style; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\V30\MediaType; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Parameter; @@ -26,6 +27,7 @@ #[CoversClass(Partial\Parameter::class)] // DTO #[CoversClass(InvalidOpenAPI::class)] #[UsesClass(MediaType::class)] +#[UsesClass(Type::class)] #[UsesClass(Partial\Schema::class)] #[UsesClass(Schema::class)] #[UsesClass(Identifier::class)] diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php index 12f9e0f..eb6d1db 100644 --- a/tests/ValueObject/Valid/V30/SchemaTest.php +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -6,8 +6,10 @@ use Generator; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; +use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; use Membrane\OpenAPIReader\ValueObject\Partial; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Schema; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; @@ -20,6 +22,7 @@ #[CoversClass(Schema::class)] #[CoversClass(Partial\Schema::class)] // DTO #[CoversClass(InvalidOpenAPI::class)] +#[UsesClass(Type::class)] #[UsesClass(Identifier::class)] #[UsesClass(Validated::class)] class SchemaTest extends TestCase @@ -36,7 +39,7 @@ public function itInvalidatesEmptyComplexSchemas( } /** - * @param string[] $types + * @param Type[] $types */ #[Test, DataProvider('provideSchemasToCheckTypes')] public function itKnowsWhatTypeItCanBe( @@ -85,81 +88,79 @@ public static function provideInvalidComplexSchemas(): Generator /** * @return \Generator */ public static function provideSchemasToCheckTypes(): Generator { - $types = ['boolean', 'number', 'integer', 'string', 'array', 'object']; - - foreach ($types as $desired) { - yield "it can always be $desired for empty schemas" => [ + foreach (Type::casesSupportedByVersion(OpenAPIVersion::Version_3_0) as $desired) { + yield "it can always be $desired->value for empty schemas" => [ true, [$desired], PartialHelper::createSchema(), ]; - foreach ($types as $type) { - yield "can it be $desired? top level type: $type" => [ + foreach (Type::casesSupportedByVersion(OpenAPIVersion::Version_3_0) as $type) { + yield "can it be $desired->value? top level type: $type->value" => [ $desired === $type, [$desired], - PartialHelper::createSchema(type: $type) + PartialHelper::createSchema(type: $type->value) ]; - yield "can it be $desired? allOf MUST be $type" => [ + yield "can it be $desired->value? allOf MUST be $type->value" => [ $desired === $type, [$desired], PartialHelper::createSchema(allOf: [ - PartialHelper::createSchema(type: $type), + PartialHelper::createSchema(type: $type->value), ]) ]; - yield "can it be $desired? anyOf MUST be $type" => [ + yield "can it be $desired->value? anyOf MUST be $type->value" => [ $desired === $type, [$desired], PartialHelper::createSchema(anyOf: [ - PartialHelper::createSchema(type: $type), + PartialHelper::createSchema(type: $type->value), ]) ]; - yield "can it be $desired? oneOf MUST be $type" => [ + yield "can it be $desired->value? oneOf MUST be $type->value" => [ $desired === $type, [$desired], PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: $type), + PartialHelper::createSchema(type: $type->value), ]) ]; - if ($type !== 'string') { - yield "can it be $desired? anyOf MAY be $type|string" => [ - in_array($desired, [$type, 'string'], true), + if ($type !== Type::String) { + yield "can it be $desired->value? anyOf MAY be $type->value|string" => [ + in_array($desired->value, [$type->value, 'string'], true), [$desired], PartialHelper::createSchema(anyOf: [ PartialHelper::createSchema(type: 'string'), - PartialHelper::createSchema(type: $type), + PartialHelper::createSchema(type: $type->value), ]) ]; } - if ($type !== 'boolean') { - yield "can it be $desired? oneOf MAY be $type|boolean" => [ - in_array($desired, [$type, 'boolean'], true), + if ($type->value !== Type::Boolean) { + yield "can it be $desired->value? oneOf MAY be $type->value|boolean" => [ + in_array($desired->value, [$type->value, 'boolean'], true), [$desired], PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: $type), PartialHelper::createSchema(type: 'boolean'), + PartialHelper::createSchema(type: $type->value), ]) ]; } - if ($type !== 'integer') { - yield "can it be $desired? allOf contains oneOf that may be $type|integer" => [ - in_array($desired, [$type, 'integer'], true), + if ($type !== Type::Integer) { + yield "can it be $desired->value? allOf contains oneOf that may be $type->value|integer" => [ + in_array($desired->value, [$type->value, 'integer'], true), [$desired], PartialHelper::createSchema(allOf: [ PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: $type), + PartialHelper::createSchema(type: $type->value), PartialHelper::createSchema(type: 'integer') ]), ]) From d41d8d9bdb21ea8e89463058531bab4c8f8d40e8 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 10 Apr 2024 17:43:44 +0100 Subject: [PATCH 46/56] Move logic to related Enums --- src/ValueObject/Valid/Enum/Style.php | 37 ++++++++++++ src/ValueObject/Valid/Enum/Type.php | 19 +++--- src/ValueObject/Valid/V30/Parameter.php | 49 ++++----------- src/ValueObject/Valid/V30/Schema.php | 60 ++++++++++++------- src/ValueObject/Valid/Warning.php | 5 ++ tests/Factory/V30/FromCebeTest.php | 2 + tests/MembraneReaderTest.php | 6 +- tests/ReaderTest.php | 9 ++- tests/ValueObject/Valid/V30/MediaTypeTest.php | 2 + tests/ValueObject/Valid/V30/OperationTest.php | 2 + tests/ValueObject/Valid/V30/ParameterTest.php | 3 + tests/ValueObject/Valid/V30/PathItemTest.php | 4 ++ tests/ValueObject/Valid/V30/SchemaTest.php | 30 +++++----- 13 files changed, 138 insertions(+), 90 deletions(-) diff --git a/src/ValueObject/Valid/Enum/Style.php b/src/ValueObject/Valid/Enum/Style.php index a1eee5c..38cb04c 100644 --- a/src/ValueObject/Valid/Enum/Style.php +++ b/src/ValueObject/Valid/Enum/Style.php @@ -13,4 +13,41 @@ enum Style: string case SpaceDelimited = 'spaceDelimited'; case PipeDelimited = 'pipeDelimited'; case DeepObject = 'deepObject'; + + public static function defaultCaseIn(In $in): self + { + return match ($in) { + In::Path, In::Header => self::Simple, + In::Query, In::Cookie => self::Form, + }; + } + + /** @return self[] */ + public static function casesIn(In $in): array + { + return match ($in) { + In::Path => [ + self::Matrix, + self::Label, + self::Simple + ], + In::Query => [ + self::Form, + self::SpaceDelimited, + self::PipeDelimited, + self::DeepObject + ], + In::Header => [ + self::Simple, + ], + In::Cookie => [ + self::Form, + ] + }; + } + + public function explodeDefault(): bool + { + return $this === self::Form; + } } diff --git a/src/ValueObject/Valid/Enum/Type.php b/src/ValueObject/Valid/Enum/Type.php index 65e111d..a14444f 100644 --- a/src/ValueObject/Valid/Enum/Type.php +++ b/src/ValueObject/Valid/Enum/Type.php @@ -19,9 +19,8 @@ enum Type: string case Object = 'object'; /** @return self[] */ - public static function casesSupportedByVersion( - OpenAPIVersion $version - ): array { + public static function casesForVersion(OpenAPIVersion $version): array + { return match ($version) { OpenAPIVersion::Version_3_0 => [ Type::Boolean, @@ -44,22 +43,18 @@ public static function casesSupportedByVersion( } /** @return string[] */ - public static function valuesSupportedByVersion( - OpenAPIVersion $version - ): array { - return array_map( - fn($t) => $t->value, - self::casesSupportedByVersion($version) - ); + public static function valuesForVersion(OpenAPIVersion $version): array + { + return array_map(fn($t) => $t->value, self::casesForVersion($version)); } - public static function tryFromCasesSupportedByVersion( + public static function tryFromVersion( OpenAPIVersion $version, string $type ): ?self { $type = self::tryFrom($type); - return in_array($type, self::casesSupportedByVersion($version)) ? + return in_array($type, self::casesForVersion($version)) ? $type : null; } diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php index 58718f9..b5dd0a3 100644 --- a/src/ValueObject/Valid/V30/Parameter.php +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -69,7 +69,7 @@ public function __construct(Identifier $parentIdentifier, Partial\Parameter $par $parameter->style, ); - $this->explode = $parameter->explode ?? $this->defaultExplode($this->style); + $this->explode = $parameter->explode ?? $this->style->explodeDefault(); if (isset($parameter->schema) !== empty($parameter->content)) { throw InvalidOpenAPI::mustHaveSchemaXorContent($parameter->name); @@ -90,6 +90,8 @@ public function __construct(Identifier $parentIdentifier, Partial\Parameter $par $parameter->content ); } + +// if ($this->getSchema()->canItBeThisType() } public function getSchema(): Schema @@ -134,12 +136,13 @@ public function canConflict(Parameter $other): bool } return match ($this->style) { - Style::Form => $this->explode && $other->explode && $this - ->getSchema() - ->canItBeThisType(Type::Object), - Style::PipeDelimited, Style::SpaceDelimited => $this - ->getSchema() - ->canItBeThisType(Type::Array, Type::Object), + Style::Form => + $this->explode && + $other->explode && + $this->getSchema()->canBe(Type::Object), + Style::PipeDelimited, Style::SpaceDelimited => + $this->getSchema()->canBe(Type::Array) || + $this->getSchema()->canBe(Type::Object), default => false, }; } @@ -172,46 +175,18 @@ private function validateStyle( ?string $style ): Style { if (is_null($style)) { - return $this->defaultStyle($in); + return Style::defaultCaseIn($in); } $style = Style::tryFrom($style) ?? throw InvalidOpenAPI::parameterInvalidStyle($identifier); - if (!$this->styleIsValidForLocation($in, $style)) { + in_array($style, Style::casesIn($in)) ?: throw InvalidOpenAPI::parameterIncompatibleStyle($identifier); - } return $style; } - private function defaultStyle(In $in): Style - { - return match ($in) { - In::Path, In::Header => Style::Simple, - In::Query, In::Cookie => Style::Form, - }; - } - - private function defaultExplode(Style $style): bool - { - return $style === Style::Form; - } - - private function styleIsValidForLocation(In $in, Style $style): bool - { - return in_array( - $style, - match ($in) { - In::Path => [Style::Matrix, Style::Label, Style::Simple], - In::Query => [Style::Form, Style::SpaceDelimited, Style::PipeDelimited, Style::DeepObject], - In::Header => [Style::Simple], - In::Cookie => [Style::Form], - }, - true - ); - } - /** * @param array $content * @return array diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index b0df04d..36faa6a 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -10,33 +10,39 @@ use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; +use Membrane\OpenAPIReader\ValueObject\Valid\Warning; final class Schema extends Validated { /** - * This keyword's value MUST be one of the following: + * If specified, this keyword's value MUST be one of the following: * "boolean", "object", "array", "number", "string", or "integer" */ public readonly ?Type $type; /** - * This keyword's value MUST be a non-empty array. + * If specified, this keyword's value MUST be a non-empty array. * @var ?array */ public readonly ?array $allOf; /** - * This keyword's value MUST be a non-empty array. + * If specified, this keyword's value MUST be a non-empty array. * @var ?array */ public readonly ?array $anyOf; /** - * This keyword's value MUST be a non-empty array. + * If specified, this keyword's value MUST be a non-empty array. * @var ?array */ public readonly ?array $oneOf; + /** + * @var Type[] + */ + private readonly array $typesItCanBe; + public function __construct( Identifier $identifier, Partial\Schema $schema @@ -71,6 +77,18 @@ public function __construct( } else { $this->oneOf = null; } + + $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 + ); + } } private function validateType(Identifier $identifier, ?string $type): ?Type @@ -79,7 +97,7 @@ private function validateType(Identifier $identifier, ?string $type): ?Type return null; } - return Type::tryFromCasesSupportedByVersion( + return Type::tryFromVersion( OpenAPIVersion::Version_3_0, $type ) ?? throw InvalidOpenAPI::invalidType($identifier, $type); @@ -101,25 +119,25 @@ private function getSubSchemas(string $keyword, array $subSchemas): array return $result; } - public function canItBeThisType(Type $type, Type ...$types): bool + public function canBe(Type $type): bool { - $possibilities = array_map(fn($t) => Type::from($t), $this->whatTypesCanItBe()); + return in_array($type, $this->typesItCanBe); + } - foreach ([$type, ...$types] as $typeItCouldBe) { - if (in_array($typeItCouldBe, $possibilities)) { - return true; - } - } + public function canOnlyBe(Type $type): bool + { + return $this->typesItCanBe === [$type]; + } - return false; + public function canOnlyBePrimitive(): bool + { + return !$this->canBe(Type::Array) && !$this->canBe(Type::Object); } - /** - * @return string[] - */ - public function whatTypesCanItBe(): array + /** @return string[] */ + private function typesItCanBe(): array { - $possibilities = [Type::valuesSupportedByVersion(OpenAPIVersion::Version_3_0)]; + $possibilities = [Type::valuesForVersion(OpenAPIVersion::Version_3_0)]; if ($this->type !== null) { $possibilities[] = [$this->type->value]; @@ -127,21 +145,21 @@ public function whatTypesCanItBe(): array if (!empty($this->allOf)) { $possibilities[] = array_intersect(...array_map( - fn($s) => $s->whatTypesCanItBe(), + fn($s) => $s->typesItCanBe(), $this->allOf, )); } if (!empty($this->anyOf)) { $possibilities[] = array_unique(array_merge(...array_map( - fn($s) => $s->whatTypesCanItBe(), + fn($s) => $s->typesItCanBe(), $this->anyOf ))); } if (!empty($this->oneOf)) { $possibilities[] = array_unique(array_merge(...array_map( - fn($s) => $s->whatTypesCanItBe(), + fn($s) => $s->typesItCanBe(), $this->oneOf ))); } diff --git a/src/ValueObject/Valid/Warning.php b/src/ValueObject/Valid/Warning.php index 5a3659d..804a542 100644 --- a/src/ValueObject/Valid/Warning.php +++ b/src/ValueObject/Valid/Warning.php @@ -38,6 +38,11 @@ public function __construct( */ public const IMPOSSIBLE_DEFAULT = 'impossible-default'; + /** + * Schema: No value, of any data type, can satisfy this schema + */ + public const IMPOSSIBLE_SCHEMA = 'impossible-schema'; + /** * Server: paths begin with a forward slash, so servers need not end in one * - membrane will ignore trailing forward slashes on server urls diff --git a/tests/Factory/V30/FromCebeTest.php b/tests/Factory/V30/FromCebeTest.php index ba0f5c0..d4b9475 100644 --- a/tests/Factory/V30/FromCebeTest.php +++ b/tests/Factory/V30/FromCebeTest.php @@ -10,6 +10,7 @@ use Membrane\OpenAPIReader\Tests\Fixtures\Helper\OpenAPIProvider; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Style; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\V30\MediaType; @@ -52,6 +53,7 @@ #[UsesClass(Identifier::class)] #[UsesClass(Method::class)] #[UsesClass(Type::class)] +#[UsesClass(Style::class)] class FromCebeTest extends TestCase { #[Test, DataProvider('provideCebeOpenAPIObjects')] diff --git a/tests/MembraneReaderTest.php b/tests/MembraneReaderTest.php index 3f74c17..6a7cf5b 100644 --- a/tests/MembraneReaderTest.php +++ b/tests/MembraneReaderTest.php @@ -6,7 +6,6 @@ use cebe\{openapi\exceptions as CebeException}; use Generator; -use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; use Membrane\OpenAPIReader\{FileFormat, OpenAPIVersion}; use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; use Membrane\OpenAPIReader\Factory\V30\FromCebe; @@ -14,6 +13,7 @@ use Membrane\OpenAPIReader\Tests\Fixtures\Helper\OpenAPIProvider; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\V30\OpenAPI; use org\bovigo\vfs\vfsStream; @@ -23,9 +23,11 @@ #[CoversClass(MembraneReader::class)] #[CoversClass(CannotRead::class), CoversClass(CannotSupport::class), CoversClass(InvalidOpenAPI::class)] -#[UsesClass(FileFormat::class), UsesClass(OpenAPIVersion::class)] +#[UsesClass(FileFormat::class)] +#[UsesClass(OpenAPIVersion::class)] #[UsesClass(Method::class)] #[UsesClass(Valid\Enum\Type::class)] +#[UsesClass(Valid\Enum\Style::class)] #[UsesClass(FromCebe::class)] #[UsesClass(Identifier::class)] #[UsesClass(Partial\OpenAPI::class)] diff --git a/tests/ReaderTest.php b/tests/ReaderTest.php index f2fc61a..b7adae6 100644 --- a/tests/ReaderTest.php +++ b/tests/ReaderTest.php @@ -6,7 +6,6 @@ use cebe\{openapi\exceptions as CebeException, openapi\spec as CebeSpec}; use Generator; -use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; use Membrane\OpenAPIReader\{CebeReader, FileFormat, OpenAPIVersion}; use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; use Membrane\OpenAPIReader\Factory\V30\FromCebe; @@ -14,6 +13,8 @@ use Membrane\OpenAPIReader\Tests\Fixtures\Helper\OpenAPIProvider; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Style; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test, TestDox, UsesClass}; @@ -23,7 +24,11 @@ #[CoversClass(Reader::class)] #[CoversClass(CebeReader::class)] #[CoversClass(CannotRead::class), CoversClass(CannotSupport::class), CoversClass(InvalidOpenAPI::class)] -#[UsesClass(FileFormat::class), UsesClass(Method::class), UsesClass(OpenAPIVersion::class), UsesClass(Valid\Enum\Type::class)] +#[UsesClass(FileFormat::class)] +#[UsesClass(OpenAPIVersion::class)] +#[UsesClass(Valid\Enum\Type::class)] +#[UsesClass(Method::class)] +#[UsesClass(Style::class)] #[UsesClass(FromCebe::class)] #[UsesClass(Identifier::class)] #[UsesClass(Partial\OpenAPI::class)] diff --git a/tests/ValueObject/Valid/V30/MediaTypeTest.php b/tests/ValueObject/Valid/V30/MediaTypeTest.php index 8d0ce1c..2016fa5 100644 --- a/tests/ValueObject/Valid/V30/MediaTypeTest.php +++ b/tests/ValueObject/Valid/V30/MediaTypeTest.php @@ -7,6 +7,7 @@ use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; use Membrane\OpenAPIReader\ValueObject\Partial; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\V30\MediaType; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Schema; @@ -23,6 +24,7 @@ #[UsesClass(Identifier::class)] #[UsesClass(Partial\Schema::class)] #[UsesClass(Schema::class)] +#[UsesClass(Type::class)] class MediaTypeTest extends TestCase { #[Test] diff --git a/tests/ValueObject/Valid/V30/OperationTest.php b/tests/ValueObject/Valid/V30/OperationTest.php index 56bc8a1..7461774 100644 --- a/tests/ValueObject/Valid/V30/OperationTest.php +++ b/tests/ValueObject/Valid/V30/OperationTest.php @@ -10,6 +10,7 @@ use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Style; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Operation; @@ -32,6 +33,7 @@ #[CoversClass(CannotSupport::class)] #[UsesClass(Server::class)] #[UsesClass(Type::class)] +#[UsesClass(Style::class)] #[UsesClass(Partial\Server::class)] #[UsesClass(Partial\Parameter::class)] #[UsesClass(Partial\Schema::class)] diff --git a/tests/ValueObject/Valid/V30/ParameterTest.php b/tests/ValueObject/Valid/V30/ParameterTest.php index e3f9ecc..d0b5dc7 100644 --- a/tests/ValueObject/Valid/V30/ParameterTest.php +++ b/tests/ValueObject/Valid/V30/ParameterTest.php @@ -28,6 +28,7 @@ #[CoversClass(InvalidOpenAPI::class)] #[UsesClass(MediaType::class)] #[UsesClass(Type::class)] +#[UsesClass(Style::class)] #[UsesClass(Partial\Schema::class)] #[UsesClass(Schema::class)] #[UsesClass(Identifier::class)] @@ -449,6 +450,8 @@ public static function provideNamesThatMayBeSimilar(): Generator 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', 'äöü']; } } diff --git a/tests/ValueObject/Valid/V30/PathItemTest.php b/tests/ValueObject/Valid/V30/PathItemTest.php index cb73cf8..06d9884 100644 --- a/tests/ValueObject/Valid/V30/PathItemTest.php +++ b/tests/ValueObject/Valid/V30/PathItemTest.php @@ -9,6 +9,8 @@ use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Style; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Operation; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Parameter; @@ -41,6 +43,8 @@ #[UsesClass(Warning::class)] #[UsesClass(Warnings::class)] #[UsesClass(Method::class)] +#[UsesClass(Type::class)] +#[UsesClass(Style::class)] class PathItemTest extends TestCase { /** diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php index eb6d1db..a4eb715 100644 --- a/tests/ValueObject/Valid/V30/SchemaTest.php +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -38,18 +38,16 @@ public function itInvalidatesEmptyComplexSchemas( new Schema($identifier, $partialSchema); } - /** - * @param Type[] $types - */ + #[Test, DataProvider('provideSchemasToCheckTypes')] public function itKnowsWhatTypeItCanBe( bool $expected, - array $types, + Type $type, Partial\Schema $partialSchema, ): void { $sut = new Schema(new Identifier(''), $partialSchema); - self::assertSame($expected, $sut->canItBeThisType(...$types)); + self::assertSame($expected, $sut->canBe($type)); } public static function provideInvalidComplexSchemas(): Generator @@ -88,29 +86,29 @@ public static function provideInvalidComplexSchemas(): Generator /** * @return \Generator */ public static function provideSchemasToCheckTypes(): Generator { - foreach (Type::casesSupportedByVersion(OpenAPIVersion::Version_3_0) as $desired) { + foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $desired) { yield "it can always be $desired->value for empty schemas" => [ true, - [$desired], + $desired, PartialHelper::createSchema(), ]; - foreach (Type::casesSupportedByVersion(OpenAPIVersion::Version_3_0) as $type) { + foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $type) { yield "can it be $desired->value? top level type: $type->value" => [ $desired === $type, - [$desired], + $desired, PartialHelper::createSchema(type: $type->value) ]; yield "can it be $desired->value? allOf MUST be $type->value" => [ $desired === $type, - [$desired], + $desired, PartialHelper::createSchema(allOf: [ PartialHelper::createSchema(type: $type->value), ]) @@ -118,7 +116,7 @@ public static function provideSchemasToCheckTypes(): Generator yield "can it be $desired->value? anyOf MUST be $type->value" => [ $desired === $type, - [$desired], + $desired, PartialHelper::createSchema(anyOf: [ PartialHelper::createSchema(type: $type->value), ]) @@ -126,7 +124,7 @@ public static function provideSchemasToCheckTypes(): Generator yield "can it be $desired->value? oneOf MUST be $type->value" => [ $desired === $type, - [$desired], + $desired, PartialHelper::createSchema(oneOf: [ PartialHelper::createSchema(type: $type->value), ]) @@ -135,7 +133,7 @@ public static function provideSchemasToCheckTypes(): Generator if ($type !== Type::String) { yield "can it be $desired->value? anyOf MAY be $type->value|string" => [ in_array($desired->value, [$type->value, 'string'], true), - [$desired], + $desired, PartialHelper::createSchema(anyOf: [ PartialHelper::createSchema(type: 'string'), PartialHelper::createSchema(type: $type->value), @@ -146,7 +144,7 @@ public static function provideSchemasToCheckTypes(): Generator if ($type->value !== Type::Boolean) { yield "can it be $desired->value? oneOf MAY be $type->value|boolean" => [ in_array($desired->value, [$type->value, 'boolean'], true), - [$desired], + $desired, PartialHelper::createSchema(oneOf: [ PartialHelper::createSchema(type: 'boolean'), PartialHelper::createSchema(type: $type->value), @@ -157,7 +155,7 @@ public static function provideSchemasToCheckTypes(): Generator if ($type !== Type::Integer) { yield "can it be $desired->value? allOf contains oneOf that may be $type->value|integer" => [ in_array($desired->value, [$type->value, 'integer'], true), - [$desired], + $desired, PartialHelper::createSchema(allOf: [ PartialHelper::createSchema(oneOf: [ PartialHelper::createSchema(type: $type->value), From a764020feb12220234414ded68cdccbc46875ab2 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 10 Apr 2024 17:45:29 +0100 Subject: [PATCH 47/56] Move MethodTest to correct directory --- tests/{ => ValueObject/Valid/Enum}/MethodTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/{ => ValueObject/Valid/Enum}/MethodTest.php (91%) diff --git a/tests/MethodTest.php b/tests/ValueObject/Valid/Enum/MethodTest.php similarity index 91% rename from tests/MethodTest.php rename to tests/ValueObject/Valid/Enum/MethodTest.php index 0bca250..338c227 100644 --- a/tests/MethodTest.php +++ b/tests/ValueObject/Valid/Enum/MethodTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Membrane\OpenAPIReader\Tests; +namespace Membrane\OpenAPIReader\Tests\ValueObject\Valid\Enum; use Generator; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; From 95e61a398b1f647affe44bc7dd83c5cec355d067 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 11 Apr 2024 09:54:24 +0100 Subject: [PATCH 48/56] Add Schema methods for checking Type --- src/ValueObject/Valid/Enum/Style.php | 2 +- src/ValueObject/Valid/V30/Parameter.php | 2 +- src/ValueObject/Valid/V30/Schema.php | 6 +- tests/ValueObject/Valid/Enum/StyleTest.php | 86 +++++++++++++ tests/ValueObject/Valid/Enum/TypeTest.php | 103 +++++++++++++++ tests/ValueObject/Valid/V30/SchemaTest.php | 140 ++++++++++++++++----- 6 files changed, 303 insertions(+), 36 deletions(-) create mode 100644 tests/ValueObject/Valid/Enum/StyleTest.php create mode 100644 tests/ValueObject/Valid/Enum/TypeTest.php diff --git a/src/ValueObject/Valid/Enum/Style.php b/src/ValueObject/Valid/Enum/Style.php index 38cb04c..360eaef 100644 --- a/src/ValueObject/Valid/Enum/Style.php +++ b/src/ValueObject/Valid/Enum/Style.php @@ -46,7 +46,7 @@ public static function casesIn(In $in): array }; } - public function explodeDefault(): bool + public function defaultExplode(): bool { return $this === self::Form; } diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php index b5dd0a3..a3c5ef8 100644 --- a/src/ValueObject/Valid/V30/Parameter.php +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -69,7 +69,7 @@ public function __construct(Identifier $parentIdentifier, Partial\Parameter $par $parameter->style, ); - $this->explode = $parameter->explode ?? $this->style->explodeDefault(); + $this->explode = $parameter->explode ?? $this->style->defaultExplode(); if (isset($parameter->schema) !== empty($parameter->content)) { throw InvalidOpenAPI::mustHaveSchemaXorContent($parameter->name); diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index 36faa6a..3147a33 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -41,7 +41,7 @@ final class Schema extends Validated /** * @var Type[] */ - private readonly array $typesItCanBe; + public readonly array $typesItCanBe; public function __construct( Identifier $identifier, @@ -126,7 +126,7 @@ public function canBe(Type $type): bool public function canOnlyBe(Type $type): bool { - return $this->typesItCanBe === [$type]; + return [$type] === $this->typesItCanBe; } public function canOnlyBePrimitive(): bool @@ -164,6 +164,6 @@ private function typesItCanBe(): array ))); } - return array_intersect(...$possibilities); + return array_values(array_intersect(...$possibilities)); } } diff --git a/tests/ValueObject/Valid/Enum/StyleTest.php b/tests/ValueObject/Valid/Enum/StyleTest.php new file mode 100644 index 0000000..f678c36 --- /dev/null +++ b/tests/ValueObject/Valid/Enum/StyleTest.php @@ -0,0 +1,86 @@ +defaultExplode()); + } + + /** + * @return Generator + */ + public static function provideDefaultStylesIn(): Generator + { + yield 'path' => [Style::Simple, In::Path]; + yield 'query' => [Style::Form, In::Query]; + yield 'header' => [Style::Simple, In::Header]; + yield 'cookie' => [Style::Form, In::Cookie]; + } + + /** + * @return Generator + */ + public static function provideStylesIn(): Generator + { + yield 'path' => [ + [Style::Matrix, Style::Label, Style::Simple], + In::Path + ]; + yield 'query' => [ + [Style::Form, Style::SpaceDelimited, Style::PipeDelimited, Style::DeepObject], + In::Query + ]; + yield 'header' => [[Style::Simple], In::Header]; + yield 'cookie' => [[Style::Form], In::Cookie]; + } + + /** + * @return Generator + */ + public static function provideDefaultExplodes(): Generator + { + foreach (Style::cases() as $case) { + yield "$case->value" => [$case === Style::Form, $case]; + } + } +} diff --git a/tests/ValueObject/Valid/Enum/TypeTest.php b/tests/ValueObject/Valid/Enum/TypeTest.php new file mode 100644 index 0000000..2b4f2e4 --- /dev/null +++ b/tests/ValueObject/Valid/Enum/TypeTest.php @@ -0,0 +1,103 @@ + */ + public static function provideCasesForVersion(): Generator + { + yield '3.0' => [ + [ + Type::Boolean, + Type::Number, + Type::Integer, + Type::String, + Type::Array, + Type::Object, + ], + OpenAPIVersion::Version_3_0 + ]; + + yield '3.1' => [ + [ + Type::Null, + Type::Boolean, + Type::Number, + Type::Integer, + Type::String, + Type::Array, + Type::Object, + ], + OpenAPIVersion::Version_3_1 + ]; + } + + /** @return Generator */ + public static function provideValuesForVersion(): Generator + { + foreach (self::provideCasesForVersion() as $dataSet => [$cases, $version]) { + yield $dataSet => [ + array_map(fn($t) => $t->value, $cases), + $version, + ]; + } + } + + /** @return Generator */ + public static function provideStringsToTryFromVersion(): Generator + { + foreach (Type::cases() as $case) { + yield "$case->value on 3.0" => [ + $case === Type::Null ? null : $case, + OpenAPIVersion::Version_3_0, + $case->value + ]; + + yield "$case->value on 3.1" => [ + $case, + OpenAPIVersion::Version_3_1, + $case->value + ]; + } + } +} diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php index a4eb715..934ced0 100644 --- a/tests/ValueObject/Valid/V30/SchemaTest.php +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -13,6 +13,8 @@ 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\Valid\Warnings; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -25,6 +27,8 @@ #[UsesClass(Type::class)] #[UsesClass(Identifier::class)] #[UsesClass(Validated::class)] +#[UsesClass(Warning::class)] +#[UsesClass(Warnings::class)] class SchemaTest extends TestCase { #[Test, DataProvider('provideInvalidComplexSchemas')] @@ -38,16 +42,50 @@ public function itInvalidatesEmptyComplexSchemas( 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 itKnowsWhatTypeItCanBe( - bool $expected, - Type $type, + public function itKnowsIfItCanOnlyBeACertainType( + array $typesItCanBe, + Type $typeToCheck, Partial\Schema $partialSchema, ): void { $sut = new Schema(new Identifier(''), $partialSchema); - self::assertSame($expected, $sut->canBe($type)); + 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( + !in_array(Type::Object, $typesItCanBe) && + !in_array(Type::Array, $typesItCanBe), + $sut->canOnlyBePrimitive() + ); } public static function provideInvalidComplexSchemas(): Generator @@ -85,55 +123,95 @@ public static function provideInvalidComplexSchemas(): Generator /** * @return \Generator */ public static function provideSchemasToCheckTypes(): Generator { - foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $desired) { - yield "it can always be $desired->value for empty schemas" => [ - true, - $desired, + foreach (Type::cases() as $typeToCheck) { + yield "$typeToCheck->value? empty schema" => [ + Type::casesForVersion(OpenAPIVersion::Version_3_0), + $typeToCheck, PartialHelper::createSchema(), ]; foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $type) { - yield "can it be $desired->value? top level type: $type->value" => [ - $desired === $type, - $desired, + yield "$typeToCheck->value? top level type: $type->value" => [ + [$type], + $typeToCheck, PartialHelper::createSchema(type: $type->value) ]; - yield "can it be $desired->value? allOf MUST be $type->value" => [ - $desired === $type, - $desired, + yield "$typeToCheck->value? allOf MUST be $type->value" => [ + [$type], + $typeToCheck, PartialHelper::createSchema(allOf: [ PartialHelper::createSchema(type: $type->value), + PartialHelper::createSchema(type: $type->value), ]) ]; - yield "can it be $desired->value? anyOf MUST be $type->value" => [ - $desired === $type, - $desired, + yield "$typeToCheck->value? anyOf MUST be $type->value" => [ + [$type], + $typeToCheck, PartialHelper::createSchema(anyOf: [ PartialHelper::createSchema(type: $type->value), + PartialHelper::createSchema(type: $type->value), ]) ]; - yield "can it be $desired->value? oneOf MUST be $type->value" => [ - $desired === $type, - $desired, + yield "$typeToCheck->value? oneOf MUST be $type->value" => [ + [$type], + $typeToCheck, PartialHelper::createSchema(oneOf: [ PartialHelper::createSchema(type: $type->value), + PartialHelper::createSchema(type: $type->value), ]) ]; + yield "$typeToCheck->value? top-level type: string, allOf MUST be $type->value" => [ + $type === Type::String ? [Type::String] : [], + $typeToCheck, + PartialHelper::createSchema( + type: Type::String->value, + allOf: [ + PartialHelper::createSchema(type: $type->value), + PartialHelper::createSchema(type: $type->value), + ] + ) + ]; + + yield "$typeToCheck->value? top-level type: number, anyOf MUST be $type->value" => [ + $type === Type::Number ? [Type::Number] : [], + $typeToCheck, + PartialHelper::createSchema( + type: Type::Number->value, + anyOf: [ + PartialHelper::createSchema(type: $type->value), + PartialHelper::createSchema(type: $type->value), + ] + ) + ]; + + yield "$typeToCheck->value? top-level type: array, oneOf MUST be $type->value" => [ + $type === Type::Array ? [Type::Array] : [], + $typeToCheck, + PartialHelper::createSchema( + type: Type::Array->value, + oneOf: [ + PartialHelper::createSchema(type: $type->value), + PartialHelper::createSchema(type: $type->value), + ] + ) + ]; + + if ($type !== Type::String) { - yield "can it be $desired->value? anyOf MAY be $type->value|string" => [ - in_array($desired->value, [$type->value, 'string'], true), - $desired, + 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), @@ -141,10 +219,10 @@ public static function provideSchemasToCheckTypes(): Generator ]; } - if ($type->value !== Type::Boolean) { - yield "can it be $desired->value? oneOf MAY be $type->value|boolean" => [ - in_array($desired->value, [$type->value, 'boolean'], true), - $desired, + if ($type !== Type::Boolean) { + 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), @@ -153,9 +231,9 @@ public static function provideSchemasToCheckTypes(): Generator } if ($type !== Type::Integer) { - yield "can it be $desired->value? allOf contains oneOf that may be $type->value|integer" => [ - in_array($desired->value, [$type->value, 'integer'], true), - $desired, + 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), From 26f1711197e17a3893b7efb76280cad46d7ad542 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 11 Apr 2024 11:24:20 +0100 Subject: [PATCH 49/56] Add Factory tests for invalid OpenAPI specs --- tests/Factory/V30/FromCebeTest.php | 37 +++++++++++++++++++-- tests/ValueObject/Valid/V30/OpenAPITest.php | 6 ++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/tests/Factory/V30/FromCebeTest.php b/tests/Factory/V30/FromCebeTest.php index d4b9475..e404a0a 100644 --- a/tests/Factory/V30/FromCebeTest.php +++ b/tests/Factory/V30/FromCebeTest.php @@ -6,6 +6,7 @@ use cebe\openapi\spec as Cebe; use Generator; +use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\Factory\V30\FromCebe; use Membrane\OpenAPIReader\Tests\Fixtures\Helper\OpenAPIProvider; use Membrane\OpenAPIReader\ValueObject\Partial; @@ -31,6 +32,7 @@ use PHPUnit\Framework\TestCase; #[CoversClass(FromCebe::class)] +#[UsesClass(InvalidOpenAPI::class)] #[UsesClass(OpenAPI::class)] #[UsesClass(Partial\OpenAPI::class)] #[UsesClass(Server::class)] @@ -56,15 +58,25 @@ #[UsesClass(Style::class)] class FromCebeTest extends TestCase { - #[Test, DataProvider('provideCebeOpenAPIObjects')] - public function itConstructsValidOpenAPIObjects( + #[Test, DataProvider('provideValidSpecs')] + public function itConstructsValidOpenAPI( OpenAPI $expected, Cebe\OpenApi $openApi, ): void { self::assertEquals($expected, FromCebe::createOpenAPI($openApi)); } - public static function provideCebeOpenAPIObjects(): Generator + #[Test, DataProvider('provideInvalidSpecs')] + public function itCannotConstructInvalidOpenAPI( + InvalidOpenAPI $expected, + Cebe\OpenApi $openApi, + ): void { + self::expectExceptionObject($expected); + + FromCebe::createOpenAPI($openApi); + } + + public static function provideValidSpecs(): Generator { yield 'minimal OpenAPI' => [ OpenAPIProvider::minimalV30MembraneObject(), @@ -76,4 +88,23 @@ public static function provideCebeOpenAPIObjects(): Generator OpenAPIProvider::detailedV30CebeObject(), ]; } + + public static function provideInvalidSpecs(): Generator + { + yield 'no title' => [ + InvalidOpenAPI::missingInfo(), + new Cebe\OpenApi([ + 'openapi' => '3.0.0', + 'info' => ['version' => '0.1'] + ]) + ]; + + yield 'no version' => [ + InvalidOpenAPI::missingInfo(), + new Cebe\OpenApi([ + 'openapi' => '3.0.0', + 'info' => ['title' => 'Slapdash API'] + ]) + ]; + } } diff --git a/tests/ValueObject/Valid/V30/OpenAPITest.php b/tests/ValueObject/Valid/V30/OpenAPITest.php index f6db307..a3d61eb 100644 --- a/tests/ValueObject/Valid/V30/OpenAPITest.php +++ b/tests/ValueObject/Valid/V30/OpenAPITest.php @@ -39,8 +39,8 @@ #[UsesClass(Method::class)] class OpenAPITest extends TestCase { - #[Test, DataProvider('providePartialOpenAPIs')] - public function itValidatesOpenAPIObjects( + #[Test, DataProvider('provideInvalidPartialObjects')] + public function itCannotBeInvalid( InvalidOpenAPI $expected, Partial\OpenAPI $partialOpenAPI, ): void { @@ -104,7 +104,7 @@ public function itHasADefaultServer(): void * 1: Partial\OpenAPI, * }> */ - public static function providePartialOpenAPIs(): Generator + public static function provideInvalidPartialObjects(): Generator { $title = 'Test OpenAPI'; $version = '1.0.0'; From fe5d01c4e9fef5ea289d20860b94f66e02fce8e7 Mon Sep 17 00:00:00 2001 From: John Charman Date: Fri, 12 Apr 2024 10:52:14 +0100 Subject: [PATCH 50/56] Test logic in Enums --- src/ValueObject/Valid/Enum/Style.php | 23 ++- src/ValueObject/Valid/Enum/Type.php | 9 + src/ValueObject/Valid/V30/Parameter.php | 35 +++- src/ValueObject/Valid/V30/Schema.php | 2 +- src/ValueObject/Valid/Warning.php | 7 + tests/ValueObject/Valid/Enum/StyleTest.php | 104 ++++++---- tests/ValueObject/Valid/Enum/TypeTest.php | 25 +++ tests/ValueObject/Valid/V30/ParameterTest.php | 188 ++++++++++++++++++ 8 files changed, 344 insertions(+), 49 deletions(-) diff --git a/src/ValueObject/Valid/Enum/Style.php b/src/ValueObject/Valid/Enum/Style.php index 360eaef..f372dcc 100644 --- a/src/ValueObject/Valid/Enum/Style.php +++ b/src/ValueObject/Valid/Enum/Style.php @@ -14,7 +14,7 @@ enum Style: string case PipeDelimited = 'pipeDelimited'; case DeepObject = 'deepObject'; - public static function defaultCaseIn(In $in): self + public static function default(In $in): self { return match ($in) { In::Path, In::Header => self::Simple, @@ -22,10 +22,14 @@ public static function defaultCaseIn(In $in): self }; } - /** @return self[] */ - public static function casesIn(In $in): array + public function defaultExplode(): bool { - return match ($in) { + return $this === self::Form; + } + + public function isAllowed(In $in): bool + { + return in_array($this, (match ($in) { In::Path => [ self::Matrix, self::Label, @@ -43,11 +47,16 @@ public static function casesIn(In $in): array In::Cookie => [ self::Form, ] - }; + })); } - public function defaultExplode(): bool + public function isSuitableFor(Type $type): bool { - return $this === self::Form; + return match ($this) { + self::SpaceDelimited, + self::PipeDelimited => !$type->isPrimitive(), + self::DeepObject => $type === Type::Object, + default => true, + }; } } diff --git a/src/ValueObject/Valid/Enum/Type.php b/src/ValueObject/Valid/Enum/Type.php index a14444f..854df1f 100644 --- a/src/ValueObject/Valid/Enum/Type.php +++ b/src/ValueObject/Valid/Enum/Type.php @@ -58,4 +58,13 @@ public static function tryFromVersion( $type : null; } + + public function isPrimitive(): bool + { + return match ($this) { + self::Array, + self::Object => false, + default => true, + }; + } } diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php index a3c5ef8..a883c33 100644 --- a/src/ValueObject/Valid/V30/Parameter.php +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -5,12 +5,14 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid\V30; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; +use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\In; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Style; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; +use Membrane\OpenAPIReader\ValueObject\Valid\Warning; final class Parameter extends Validated { @@ -44,8 +46,10 @@ final class Parameter extends Validated */ public readonly array $content; - public function __construct(Identifier $parentIdentifier, Partial\Parameter $parameter) - { + public function __construct( + Identifier $parentIdentifier, + Partial\Parameter $parameter + ) { $this->name = $parameter->name ?? throw InvalidOpenAPI::parameterMissingName($parentIdentifier); @@ -91,7 +95,7 @@ public function __construct(Identifier $parentIdentifier, Partial\Parameter $par ); } -// if ($this->getSchema()->canItBeThisType() + $this->checkStyleSuitability(); } public function getSchema(): Schema @@ -141,8 +145,7 @@ public function canConflict(Parameter $other): bool $other->explode && $this->getSchema()->canBe(Type::Object), Style::PipeDelimited, Style::SpaceDelimited => - $this->getSchema()->canBe(Type::Array) || - $this->getSchema()->canBe(Type::Object), + !$this->getSchema()->canOnlyBePrimitive(), default => false, }; } @@ -175,13 +178,13 @@ private function validateStyle( ?string $style ): Style { if (is_null($style)) { - return Style::defaultCaseIn($in); + return Style::default($in); } $style = Style::tryFrom($style) ?? throw InvalidOpenAPI::parameterInvalidStyle($identifier); - in_array($style, Style::casesIn($in)) ?: + $style->isAllowed($in) ?: throw InvalidOpenAPI::parameterIncompatibleStyle($identifier); return $style; @@ -215,4 +218,22 @@ private function validateContent( ), ]; } + + private function checkStyleSuitability(): void + { + foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $type) { + if ( + $this->getSchema()->canBe($type) && + !$this->style->isSuitableFor($type) + ) { + $this->addWarning( + 'unsuitable style for primitive data types', + Warning::UNSUITABLE_STYLE + ); + + break; + } + } + } + } diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index 3147a33..af09b48 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -41,7 +41,7 @@ final class Schema extends Validated /** * @var Type[] */ - public readonly array $typesItCanBe; + private readonly array $typesItCanBe; public function __construct( Identifier $identifier, diff --git a/src/ValueObject/Valid/Warning.php b/src/ValueObject/Valid/Warning.php index 804a542..21339fc 100644 --- a/src/ValueObject/Valid/Warning.php +++ b/src/ValueObject/Valid/Warning.php @@ -65,4 +65,11 @@ public function __construct( * Path Item, Operation: "parameters" can have identical/similar names, but this could be quite confusing. */ public const SIMILAR_NAMES = 'similar-names'; + + /** + * Parameter: + * - spaceDelimited and pipeDelimited styles should not be used for primitive values + * - deepObject style should only be used for object values + */ + public const UNSUITABLE_STYLE = 'unsuitable-style'; } diff --git a/tests/ValueObject/Valid/Enum/StyleTest.php b/tests/ValueObject/Valid/Enum/StyleTest.php index f678c36..e967fa4 100644 --- a/tests/ValueObject/Valid/Enum/StyleTest.php +++ b/tests/ValueObject/Valid/Enum/StyleTest.php @@ -7,28 +7,28 @@ use Generator; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\In; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Style; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[CoversClass(Style::class)] +#[UsesClass(Type::class)] class StyleTest extends TestCase { #[Test, DataProvider('provideDefaultStylesIn')] public function itGetsDefaultStyles(Style $expected, In $in): void { - self::assertSame($expected, Style::defaultCaseIn($in)); + self::assertSame($expected, Style::default($in)); } - /** - * @param Style[] $expected - */ #[Test, DataProvider('provideStylesIn')] - public function itGetsStylesIn(array $expected, In $in): void + public function itGetsStylesIn(bool $expected, Style $style, In $in): void { - self::assertSame($expected, Style::casesIn($in)); + self::assertSame($expected, $style->isAllowed($in)); } #[Test, DataProvider('provideDefaultExplodes')] @@ -37,12 +37,16 @@ public function itGetsExplodeDefault(bool $expected, Style $style): void self::assertSame($expected, $style->defaultExplode()); } - /** - * @return Generator - */ + #[Test, DataProvider('provideStylesThatMayBeSuitable')] + public function itKnowsIfItsSuitable( + bool $expected, + Style $style, + Type $type, + ): void { + self::assertSame($expected, $style->isSuitableFor($type)); + } + + /** @return Generator */ public static function provideDefaultStylesIn(): Generator { yield 'path' => [Style::Simple, In::Path]; @@ -51,36 +55,68 @@ public static function provideDefaultStylesIn(): Generator yield 'cookie' => [Style::Form, In::Cookie]; } - /** - * @return Generator - */ + /** @return Generator */ public static function provideStylesIn(): Generator { - yield 'path' => [ - [Style::Matrix, Style::Label, Style::Simple], - In::Path - ]; - yield 'query' => [ - [Style::Form, Style::SpaceDelimited, Style::PipeDelimited, Style::DeepObject], - In::Query - ]; - yield 'header' => [[Style::Simple], In::Header]; - yield 'cookie' => [[Style::Form], In::Cookie]; + foreach (Style::cases() as $style) { + yield "style:$style->value, in:path" => [ + in_array($style, [Style::Matrix, Style::Label, Style::Simple]), + $style, + In::Path, + ]; + + yield "style:$style->value, in:query" => [ + in_array($style, [ + Style::Form, + Style::SpaceDelimited, + Style::PipeDelimited, + Style::DeepObject + ]), + $style, + In::Query, + ]; + + yield "style:$style->value, in:header" => [ + $style === Style::Simple, + $style, + In::Header, + ]; + + yield "style:$style->value, in:cookie" => [ + $style === Style::Form, + $style, + In::Cookie, + ]; + } } - /** - * @return Generator - */ + /** @return Generator */ public static function provideDefaultExplodes(): Generator { foreach (Style::cases() as $case) { yield "$case->value" => [$case === Style::Form, $case]; } } + + /** @return Generator */ + public static function provideStylesThatMayBeSuitable(): Generator + { + foreach (Style::cases() as $style) { + foreach (Type::cases() as $type) { + yield "style:$style->value, type:$type->value" => [ + match ($style) { + Style::SpaceDelimited, + Style::PipeDelimited => in_array( + $type, + [Type::Array, Type::Object] + ), + Style::DeepObject => $type === Type::Object, + default => true, + }, + $style, + $type, + ]; + } + } + } } diff --git a/tests/ValueObject/Valid/Enum/TypeTest.php b/tests/ValueObject/Valid/Enum/TypeTest.php index 2b4f2e4..f32abb5 100644 --- a/tests/ValueObject/Valid/Enum/TypeTest.php +++ b/tests/ValueObject/Valid/Enum/TypeTest.php @@ -43,6 +43,12 @@ public function itTriesFromVersion( self::assertSame($expected, Type::tryFromVersion($version, $type)); } + #[Test, DataProvider('provideTypesThatMayBePrimitive')] + public function itKnowsIfItIsPrimitive(bool $expected, Type $type): void + { + self::assertSame($expected, $type->isPrimitive()); + } + /** @return Generator */ public static function provideCasesForVersion(): Generator { @@ -100,4 +106,23 @@ public static function provideStringsToTryFromVersion(): Generator ]; } } + + /** @return Generator */ + public static function provideTypesThatMayBePrimitive(): Generator + { + $primitiveTypes = [ + Type::Boolean, + Type::Integer, + Type::Null, + Type::Number, + Type::String, + ]; + + foreach (Type::cases() as $type) { + yield "$type->value" => [ + in_array($type, $primitiveTypes), + $type + ]; + } + } } diff --git a/tests/ValueObject/Valid/V30/ParameterTest.php b/tests/ValueObject/Valid/V30/ParameterTest.php index d0b5dc7..761710d 100644 --- a/tests/ValueObject/Valid/V30/ParameterTest.php +++ b/tests/ValueObject/Valid/V30/ParameterTest.php @@ -6,6 +6,7 @@ use Generator; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; +use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\In; @@ -16,6 +17,8 @@ use Membrane\OpenAPIReader\ValueObject\Valid\V30\Parameter; use Membrane\OpenAPIReader\ValueObject\Valid\V30\Schema; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; +use Membrane\OpenAPIReader\ValueObject\Valid\Warning; +use Membrane\OpenAPIReader\ValueObject\Valid\Warnings; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -33,6 +36,8 @@ #[UsesClass(Schema::class)] #[UsesClass(Identifier::class)] #[UsesClass(Validated::class)] +#[UsesClass(Warning::class)] +#[UsesClass(Warnings::class)] class ParameterTest extends TestCase { #[Test, DataProvider('provideInvalidPartialParameters')] @@ -170,6 +175,35 @@ public function itChecksIfNameIsSimilar( self::assertSame($expected, $sut->isSimilar($otherSUT)); } + #[Test, DataProvider('provideStylesThatMayBeSuitable')] + public function itWarnsAgainstUnsuitableStyles( + bool $expected, + Partial\Parameter $parameter, + ): void { + $sut = new Parameter(new Identifier(''), $parameter); + + self::assertSame( + $expected, + $sut->getWarnings()->hasWarningCode(Warning::UNSUITABLE_STYLE) + ); + } + + #[Test] + #[DataProvider('provideParametersThatAreNotInQuery')] + #[DataProvider('provideParametersWithDifferentStyles')] + #[DataProvider('provideParametersThatMustBePrimitiveType')] + #[DataProvider('provideParametersThatConflict')] + 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->canConflict($otherSUT)); + } + public static function provideInvalidPartialParameters(): Generator { $parentIdentifier = new Identifier('test'); @@ -454,4 +488,158 @@ public static function provideNamesThatMayBeSimilar(): Generator 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) { + yield "style:$style->value, type:$type->value" => [ + !$style->isSuitableFor($type), + 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" => [ + false, + PartialHelper::createParameter( + in: In::Query->value, + style: $style->value, + explode: true, + ), + PartialHelper::createParameter( + in: In::Query->value, + style: $other->value, + explode: true, + ), + ]; + } + } + } + + /** + * @return Generator + */ + public static function provideParametersThatConflict(): Generator + { + $dataSet = fn(Style $style, bool $explode, Type $type) => [ + true, + PartialHelper::createParameter( + in: In::Query->value, + style: $style->value, + explode: $explode, + schema: PartialHelper::createSchema(type: $type->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 for array data types' => + $dataSet(Style::PipeDelimited, false, Type::Array); + + yield 'style:pipeDelimited for object data types' => + $dataSet(Style::PipeDelimited, false, Type::Object); + + yield 'style:spaceDelimited for array data types' => + $dataSet(Style::SpaceDelimited, false, Type::Array); + + yield 'style:spaceDelimited for object data types' => + $dataSet(Style::SpaceDelimited, false, Type::Object); + } + + /** + * @return Generator + */ + public static function provideParametersThatMustBePrimitiveType(): Generator + { + foreach (self::provideParametersThatConflict() as $case => $dataSet) { + $dataSet[1]->schema = PartialHelper::createSchema(type: Type::Integer->value); + $dataSet[2]->schema = PartialHelper::createSchema(type: Type::Integer->value); + + yield $case => [ + false, + $dataSet[1], + $dataSet[2], + ]; + } + } } From 4695778a64c1b9f9838a22c516835e6bf9e59c01 Mon Sep 17 00:00:00 2001 From: John Charman Date: Fri, 12 Apr 2024 11:41:02 +0100 Subject: [PATCH 51/56] Test Schema Warnings - test it warns against having no acceptable data types --- tests/ValueObject/Valid/V30/SchemaTest.php | 32 +++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php index 934ced0..cfd4dad 100644 --- a/tests/ValueObject/Valid/V30/SchemaTest.php +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -88,6 +88,20 @@ public function itKnowsIfItCanOnlyBePrimitive( ); } + #[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) + ); + } + public static function provideInvalidComplexSchemas(): Generator { $xOfs = [ @@ -125,7 +139,7 @@ public static function provideInvalidComplexSchemas(): Generator * @return \Generator */ public static function provideSchemasToCheckTypes(): Generator @@ -245,4 +259,20 @@ public static function provideSchemasToCheckTypes(): Generator } } } + + /** + * @return Generator + */ + public static function provideSchemasAcceptNoTypes(): Generator + { + foreach (self::provideSchemasToCheckTypes() as $case => $dataSet) { + yield $case => [ + empty($dataSet[0]), + $dataSet[2] + ]; + } + } } From 1227f07776c400aebef01676184f4f33a3b0ccba Mon Sep 17 00:00:00 2001 From: John Charman Date: Fri, 12 Apr 2024 11:42:06 +0100 Subject: [PATCH 52/56] Refactor Parameter Warning - Avoid throwing warning unless no data type appropriately matches style --- src/ValueObject/Valid/V30/Parameter.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php index a883c33..be21fab 100644 --- a/src/ValueObject/Valid/V30/Parameter.php +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -224,16 +224,15 @@ private function checkStyleSuitability(): void foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $type) { if ( $this->getSchema()->canBe($type) && - !$this->style->isSuitableFor($type) + $this->style->isSuitableFor($type) ) { - $this->addWarning( - 'unsuitable style for primitive data types', - Warning::UNSUITABLE_STYLE - ); - - break; + return; } } + $this->addWarning( + 'unsuitable style for primitive data types', + Warning::UNSUITABLE_STYLE + ); } } From 07e430887c3bc8b233a83d352f11ffff672dae2d Mon Sep 17 00:00:00 2001 From: John Charman Date: Fri, 12 Apr 2024 11:42:55 +0100 Subject: [PATCH 53/56] Test Parameter handles different explodes and deepObject --- tests/ValueObject/Valid/V30/ParameterTest.php | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/tests/ValueObject/Valid/V30/ParameterTest.php b/tests/ValueObject/Valid/V30/ParameterTest.php index 761710d..988810d 100644 --- a/tests/ValueObject/Valid/V30/ParameterTest.php +++ b/tests/ValueObject/Valid/V30/ParameterTest.php @@ -192,6 +192,8 @@ public function itWarnsAgainstUnsuitableStyles( #[DataProvider('provideParametersThatAreNotInQuery')] #[DataProvider('provideParametersWithDifferentStyles')] #[DataProvider('provideParametersThatMustBePrimitiveType')] + #[DataProvider('provideParametersWithDifferentExplodes')] + #[DataProvider('provideParametersInQueryThatDoNotConflict')] #[DataProvider('provideParametersThatConflict')] public function itChecksIfItCanConflict( bool $expected, @@ -580,6 +582,62 @@ public static function provideParametersWithDifferentStyles(): Generator } } + /** + * @return Generator + */ + public static function provideParametersWithDifferentExplodes(): Generator + { + $stylesWhereDifferentExplodesMatter = [Style::Form]; + + foreach ($stylesWhereDifferentExplodesMatter as $style) { + yield "style:$style->value" => [ + false, + PartialHelper::createParameter( + in: In::Query->value, + style: $style->value, + explode: true, + ), + PartialHelper::createParameter( + in: In::Query->value, + style: $style->value, + explode: false, + ), + ]; + } + } + + /** + * @return Generator + */ + public static function provideParametersInQueryThatDoNotConflict(): Generator + { + $queryStylesThatCannotConflict = [Style::DeepObject]; + + foreach ($queryStylesThatCannotConflict as $style) { + yield "style:$style->value" => [ + false, + PartialHelper::createParameter( + in: In::Query->value, + style: $style->value, + explode: true, + ), + PartialHelper::createParameter( + in: In::Query->value, + style: $style->value, + explode: true, + ), + ]; + } + } + /** * @return Generator $dataSet) { $dataSet[1]->schema = PartialHelper::createSchema(type: Type::Integer->value); $dataSet[2]->schema = PartialHelper::createSchema(type: Type::Integer->value); - + yield $case => [ false, $dataSet[1], From 1c5648de6c0dc9125548ef33ca2c3a79062e1aaf Mon Sep 17 00:00:00 2001 From: John Charman Date: Fri, 12 Apr 2024 12:22:47 +0100 Subject: [PATCH 54/56] Remove unnecessary Exceptions --- src/Exception/CannotSupport.php | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/Exception/CannotSupport.php b/src/Exception/CannotSupport.php index 4eca0e8..121639c 100644 --- a/src/Exception/CannotSupport.php +++ b/src/Exception/CannotSupport.php @@ -4,7 +4,6 @@ namespace Membrane\OpenAPIReader\Exception; -use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use RuntimeException; /* @@ -48,17 +47,6 @@ public static function missingOperationId(string $pathUrl, string $method): self return new self($message, self::MISSING_OPERATION_ID); } - public static function undeclaredType(Identifier $identifier): self - { - $message = << Date: Fri, 12 Apr 2024 12:26:09 +0100 Subject: [PATCH 55/56] Test Schema checks for Invalid Types --- src/ValueObject/Valid/V30/OpenAPI.php | 1 + src/ValueObject/Valid/V30/Parameter.php | 1 - tests/ValueObject/Valid/V30/SchemaTest.php | 13 +++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/ValueObject/Valid/V30/OpenAPI.php b/src/ValueObject/Valid/V30/OpenAPI.php index 14eb78b..5194e0b 100644 --- a/src/ValueObject/Valid/V30/OpenAPI.php +++ b/src/ValueObject/Valid/V30/OpenAPI.php @@ -108,6 +108,7 @@ private function validatePaths( $pathItem->path ); } + if (isset($result[$pathItem->path])) { throw InvalidOpenAPI::identicalEndpoints( $result[$pathItem->path]->getIdentifier() diff --git a/src/ValueObject/Valid/V30/Parameter.php b/src/ValueObject/Valid/V30/Parameter.php index be21fab..6e1525b 100644 --- a/src/ValueObject/Valid/V30/Parameter.php +++ b/src/ValueObject/Valid/V30/Parameter.php @@ -234,5 +234,4 @@ private function checkStyleSuitability(): void Warning::UNSUITABLE_STYLE ); } - } diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php index cfd4dad..af5eda9 100644 --- a/tests/ValueObject/Valid/V30/SchemaTest.php +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -42,6 +42,19 @@ public function itInvalidatesEmptyComplexSchemas( new Schema($identifier, $partialSchema); } + #[Test] + public function itInvalidatesInvalidTypes(): void + { + $identifier = new Identifier(''); + $schema = PartialHelper::createSchema(type: 'invalid'); + + + + self::expectExceptionObject(InvalidOpenAPI::invalidType($identifier, 'invalid')); + + new Schema($identifier, $schema); + } + /** @param Type[] $typesItCanBe */ #[Test, DataProvider('provideSchemasToCheckTypes')] public function itKnowsIfItCanBeACertainType( From 8fb9df90ccd072ca01aaa89f72c7b45710e625a9 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 17 Apr 2024 14:54:42 +0100 Subject: [PATCH 56/56] Instantiate Warnings in Constructor --- src/ValueObject/Valid/Validated.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ValueObject/Valid/Validated.php b/src/ValueObject/Valid/Validated.php index 36811c5..ec1e0a6 100644 --- a/src/ValueObject/Valid/Validated.php +++ b/src/ValueObject/Valid/Validated.php @@ -11,6 +11,7 @@ abstract class Validated implements HasIdentifier, HasWarnings public function __construct( private readonly Identifier $identifier, ) { + $this->warnings = new Warnings($this->identifier); } public function getIdentifier(): Identifier @@ -28,15 +29,11 @@ protected function appendedIdentifier( public function hasWarnings(): bool { - return isset($this->warnings); + return $this->warnings->hasWarnings(); } public function getWarnings(): Warnings { - if (!isset($this->warnings)) { - $this->warnings = new Warnings($this->identifier); - } - return $this->warnings; }