diff --git a/.gitignore b/.gitignore index c86af6f..fa1d02a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /node_modules /.php_cs.cache +/.php-cs-fixer.cache /.phpunit.result.cache php-cs-fixer.phar diff --git a/.php_cs.dist b/.php-cs-fixer.dist.php similarity index 92% rename from .php_cs.dist rename to .php-cs-fixer.dist.php index 704f437..3594ae9 100644 --- a/.php_cs.dist +++ b/.php-cs-fixer.dist.php @@ -1,6 +1,6 @@ setRules([ '@PSR2' => true, 'array_syntax' => ['syntax' => 'short'], diff --git a/Makefile b/Makefile index ee99b2a..b4fae49 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ check-style: php-cs-fixer.phar fix-style: php-cs-fixer.phar $(DOCKER_PHP) vendor/bin/indent --tabs composer.json - $(DOCKER_PHP) vendor/bin/indent --spaces .php_cs.dist + $(DOCKER_PHP) vendor/bin/indent --spaces .php-cs-fixer.dist.php $(DOCKER_PHP) ./php-cs-fixer.phar fix src/ --diff install: composer.lock yarn.lock diff --git a/src/Reader.php b/src/Reader.php index 99ee5c3..2776313 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -23,6 +23,7 @@ class Reader { /** * Populate OpenAPI spec object from JSON data. + * Saves reference context for resolving internal references * @phpstan-template T of SpecObjectInterface * @phpstan-param class-string $baseType * @phpstan-return T @@ -34,7 +35,33 @@ class Reader * The type of the returned object depends on the `$baseType` argument. * @throws TypeErrorException in case invalid spec data is supplied. */ - public static function readFromJson(string $json, string $baseType = OpenApi::class): SpecObjectInterface + public static function readFromJson(string $json, string $baseType = OpenApi::class, bool $resolveReferences = true): SpecObjectInterface + { + $spec = static::fromJson($json, $baseType); + $context = ReferenceContext::readFromString($spec, $json); + $context->setDefaultCacheKey($baseType); + $context->mode = ReferenceContext::RESOLVE_MODE_INLINE; + $spec->setReferenceContext($context); + if($resolveReferences && $context->hasComponentsRef()) { + $spec->resolveReferences(); + } + return $spec; + } + + /** + * Populate OpenAPI spec object from JSON data. + * @phpstan-template T of SpecObjectInterface + * @phpstan-param class-string $baseType + * @phpstan-return T + * @param string $json the JSON string to decode. + * @param string $baseType the base Type to instantiate. This must be an instance of [[SpecObjectInterface]]. + * The default is [[OpenApi]] which is the base type of a OpenAPI specification file. + * You may choose a different type if you instantiate objects from sub sections of a specification. + * @return SpecObjectInterface|OpenApi the OpenApi object instance. + * The type of the returned object depends on the `$baseType` argument. + * @throws TypeErrorException in case invalid spec data is supplied. + */ + protected static function fromJson(string $json, string $baseType = OpenApi::class): SpecObjectInterface { return new $baseType(json_decode($json, true)); } @@ -52,7 +79,33 @@ public static function readFromJson(string $json, string $baseType = OpenApi::cl * The type of the returned object depends on the `$baseType` argument. * @throws TypeErrorException in case invalid spec data is supplied. */ - public static function readFromYaml(string $yaml, string $baseType = OpenApi::class): SpecObjectInterface + public static function readFromYaml(string $yaml, string $baseType = OpenApi::class, bool $resolveReferences = true): SpecObjectInterface + { + $spec = static::fromYaml($yaml, $baseType); + $context = ReferenceContext::readFromString($spec, $yaml); + $context->setDefaultCacheKey($baseType); + $context->mode = ReferenceContext::RESOLVE_MODE_INLINE; + $spec->setReferenceContext($context); + if($resolveReferences && $context->hasComponentsRef()) { + $spec->resolveReferences(); + } + return $spec; + } + + /** + * Populate OpenAPI spec object from YAML data. + * @phpstan-template T of SpecObjectInterface + * @phpstan-param class-string $baseType + * @phpstan-return T + * @param string $yaml the YAML string to decode. + * @param string $baseType the base Type to instantiate. This must be an instance of [[SpecObjectInterface]]. + * The default is [[OpenApi]] which is the base type of a OpenAPI specification file. + * You may choose a different type if you instantiate objects from sub sections of a specification. + * @return SpecObjectInterface|OpenApi the OpenApi object instance. + * The type of the returned object depends on the `$baseType` argument. + * @throws TypeErrorException in case invalid spec data is supplied. + */ + public static function fromYaml(string $yaml, string $baseType = OpenApi::class): SpecObjectInterface { return new $baseType(Yaml::parse($yaml)); } @@ -89,7 +142,7 @@ public static function readFromJsonFile(string $fileName, string $baseType = Ope $e->fileName = $fileName; throw $e; } - $spec = static::readFromJson($fileContent, $baseType); + $spec = static::fromJson($fileContent, $baseType); $context = new ReferenceContext($spec, $fileName); $spec->setReferenceContext($context); if ($resolveReferences !== false) { @@ -135,7 +188,7 @@ public static function readFromYamlFile(string $fileName, string $baseType = Ope $e->fileName = $fileName; throw $e; } - $spec = static::readFromYaml($fileContent, $baseType); + $spec = static::fromYaml($fileContent, $baseType); $context = new ReferenceContext($spec, $fileName); $spec->setReferenceContext($context); if ($resolveReferences !== false) { diff --git a/src/ReferenceContext.php b/src/ReferenceContext.php index bde0a96..4fba238 100644 --- a/src/ReferenceContext.php +++ b/src/ReferenceContext.php @@ -10,6 +10,7 @@ use cebe\openapi\exceptions\IOException; use cebe\openapi\exceptions\UnresolvableReferenceException; use cebe\openapi\json\JsonPointer; +use cebe\openapi\spec\OpenApi; use cebe\openapi\spec\Reference; use Symfony\Component\Yaml\Yaml; @@ -51,6 +52,21 @@ class ReferenceContext */ private $_cache; + /** + * @var bool checks if content is read from string or file + */ + private $_read_from_string = false; + + /** + * @var string Default cache key for data read from string + */ + private $_string_cache_key = OpenApi::class; + + /** + * @var bool checks if read string contains components + */ + private $_content_has_components = false; + /** * ReferenceContext constructor. @@ -62,7 +78,7 @@ class ReferenceContext public function __construct(?SpecObjectInterface $base, string $uri, $cache = null) { $this->_baseSpec = $base; - $this->_uri = $this->normalizeUri($uri); + $this->_uri = empty($uri) ? static::class : $this->normalizeUri($uri); $this->_cache = $cache ?? new ReferenceContextCache(); if ($cache === null && $base !== null) { $this->_cache->set($this->_uri, null, $base); @@ -210,6 +226,7 @@ public function resolveRelativeUri(string $uri): string */ public function fetchReferencedFile($uri) { + $uri = $this->resolveCacheUri($uri); if ($this->_cache->has('FILE_CONTENT://' . $uri, 'FILE_CONTENT')) { return $this->_cache->get('FILE_CONTENT://' . $uri, 'FILE_CONTENT'); } @@ -221,6 +238,15 @@ public function fetchReferencedFile($uri) throw $e; } // TODO lazy content detection, should be improved + $parsedContent = $this->parseAndCacheContent($content, $uri); + return $parsedContent; + } + + /** + * Parse content from string to either Yaml or Json + */ + protected function parseAndCacheContent($content, $uri): array + { if (strpos(ltrim($content), '{') === 0) { $parsedContent = json_decode($content, true); } else { @@ -229,6 +255,43 @@ public function fetchReferencedFile($uri) $this->_cache->set('FILE_CONTENT://' . $uri, 'FILE_CONTENT', $parsedContent); return $parsedContent; } + + /** + * Prepare content read and cache from JSON or YAML string + */ + public function prepareDataFromString($content) + { + $this->_read_from_string = true; + $parsedContent = $this->parseAndCacheContent($content, $this->resolveCacheUri('')); + + if(array_key_exists('components', $parsedContent) && str_contains($content, '#/components')) { + $this->_content_has_components = true; + } + } + + /** + * Is content from string or file + */ + public function isFromFile(): bool + { + return !$this->_read_from_string; + } + + /** + * Uses default base classname to cache what ever is read + */ + public function setDefaultCacheKey($key = OpenApi::class) + { + $this->_string_cache_key = $key; + } + + /** + * Return result indicating if components and ref is present in read string + */ + public function hasComponentsRef(): bool + { + return $this->_content_has_components; + } /** * Retrieve the referenced data via JSON pointer. @@ -267,4 +330,19 @@ public function resolveReferenceData($uri, JsonPointer $pointer, $data, $toType) return $referencedObject; } + + protected function resolveCacheUri($uri) + { + return empty($uri) ? $this->_string_cache_key : $uri; + } + + /** + * Static function that reads from string and initialises base class + */ + public static function readFromString($base, $content): static + { + $context = new static($base, ''); + $context->prepareDataFromString($content); + return $context; + } } diff --git a/src/json/JsonPointer.php b/src/json/JsonPointer.php index 0e1f75e..3121e08 100644 --- a/src/json/JsonPointer.php +++ b/src/json/JsonPointer.php @@ -107,11 +107,11 @@ public function evaluate($jsonDocument) foreach ($this->getPath() as $part) { if (is_array($currentReference)) { -// if (!preg_match('~^([1-9]*[0-9]|-)$~', $part)) { -// throw new NonexistentJsonPointerReferenceException( -// "Failed to evaluate pointer '$this->_pointer'. Invalid pointer path '$part' for Array at path '$currentPath'." -// ); -// } + // if (!preg_match('~^([1-9]*[0-9]|-)$~', $part)) { + // throw new NonexistentJsonPointerReferenceException( + // "Failed to evaluate pointer '$this->_pointer'. Invalid pointer path '$part' for Array at path '$currentPath'." + // ); + // } if ($part === '-' || !array_key_exists($part, $currentReference)) { throw new NonexistentJsonPointerReferenceException( "Failed to evaluate pointer '$this->_pointer'. Array has no member $part at path '$currentPath'." diff --git a/src/spec/Reference.php b/src/spec/Reference.php index cda612a..f3191bc 100644 --- a/src/spec/Reference.php +++ b/src/spec/Reference.php @@ -154,7 +154,7 @@ public function setContext(ReferenceContext $context) /** * @return ReferenceContext */ - public function getContext() : ?ReferenceContext + public function getContext(): ?ReferenceContext { return $this->_context; } @@ -186,53 +186,58 @@ public function resolve(ReferenceContext $context = null) return $this; } try { - if ($jsonReference->getDocumentUri() === '') { - if ($context->mode === ReferenceContext::RESOLVE_MODE_INLINE) { - return $this; - } - - // resolve in current document - $baseSpec = $context->getBaseSpec(); - if ($baseSpec !== null) { - // TODO type error if resolved object does not match $this->_to ? - /** @var SpecObjectInterface $referencedObject */ - $referencedObject = $jsonReference->getJsonPointer()->evaluate($baseSpec); - // transitive reference - if ($referencedObject instanceof Reference) { - $referencedObject = $this->resolveTransitiveReference($referencedObject, $context); + if ($context->isFromFile()) { + if ($jsonReference->getDocumentUri() === '') { + if ($context->mode === ReferenceContext::RESOLVE_MODE_INLINE) { + return $this; } - if ($referencedObject instanceof SpecObjectInterface) { - $referencedObject->setReferenceContext($context); + + // resolve in current document + $baseSpec = $context->getBaseSpec(); + if ($baseSpec !== null) { + // TODO type error if resolved object does not match $this->_to ? + /** @var SpecObjectInterface $referencedObject */ + $referencedObject = $jsonReference->getJsonPointer()->evaluate($baseSpec); + // transitive reference + if ($referencedObject instanceof Reference) { + $referencedObject = $this->resolveTransitiveReference($referencedObject, $context); + } + if ($referencedObject instanceof SpecObjectInterface) { + $referencedObject->setReferenceContext($context); + } + return $referencedObject; + } else { + // if current document was loaded via reference, it may be null, + // so we load current document by URI instead. + $jsonReference = JsonReference::createFromUri($context->getUri(), $jsonReference->getJsonPointer()); } - return $referencedObject; - } else { - // if current document was loaded via reference, it may be null, - // so we load current document by URI instead. - $jsonReference = JsonReference::createFromUri($context->getUri(), $jsonReference->getJsonPointer()); } - } - // resolve in external document - $file = $context->resolveRelativeUri($jsonReference->getDocumentUri()); - try { - $referencedDocument = $context->fetchReferencedFile($file); - } catch (\Throwable $e) { - $exception = new UnresolvableReferenceException( - "Failed to resolve Reference '$this->_ref' to $this->_to Object: " . $e->getMessage(), - $e->getCode(), - $e - ); - $exception->context = $this->getDocumentPosition(); - throw $exception; - } + // resolve in external document + $file = $context->resolveRelativeUri($jsonReference->getDocumentUri()); + try { + $referencedDocument = $context->fetchReferencedFile($file); + } catch (\Throwable $e) { + $exception = new UnresolvableReferenceException( + "Failed to resolve Reference '$this->_ref' to $this->_to Object: " . $e->getMessage(), + $e->getCode(), + $e + ); + $exception->context = $this->getDocumentPosition(); + throw $exception; + } - $referencedDocument = $this->adjustRelativeReferences($referencedDocument, $file, null, $context); - $referencedObject = $context->resolveReferenceData($file, $jsonReference->getJsonPointer(), $referencedDocument, $this->_to); + $referencedDocument = $this->adjustRelativeReferences($referencedDocument, $file, null, $context); + $referencedObject = $context->resolveReferenceData($file, $jsonReference->getJsonPointer(), $referencedDocument, $this->_to); - if ($referencedObject instanceof DocumentContextInterface) { - if ($referencedObject->getDocumentPosition() === null && $this->getDocumentPosition() !== null) { - $referencedObject->setDocumentContext($context->getBaseSpec(), $this->getDocumentPosition()); + if ($referencedObject instanceof DocumentContextInterface) { + if ($referencedObject->getDocumentPosition() === null && $this->getDocumentPosition() !== null) { + $referencedObject->setDocumentContext($context->getBaseSpec(), $this->getDocumentPosition()); + } } + } else { + $referencedDocument = $context->fetchReferencedFile(''); + $referencedObject = $context->resolveReferenceData('', $jsonReference->getJsonPointer(), $referencedDocument, $this->_to); } // transitive reference diff --git a/tests/spec/OpenApiTest.php b/tests/spec/OpenApiTest.php index 20b568e..178d759 100644 --- a/tests/spec/OpenApiTest.php +++ b/tests/spec/OpenApiTest.php @@ -1,5 +1,6 @@ assertFalse($openapi->validate()); + + //PASS - Contains Description + $openapi = Reader::readFromJson(<<assertTrue($openapi->validate()); + + + //FAILS - Does not contain required description + $openapi = Reader::readFromJson(<<assertFalse($openapi->validate()); + + //PASSES - Contains Description + $openapi = Reader::readFromJson(<<assertTrue($openapi->validate()); + } + + public function testDeepValidationOfApiOperationsYAML() + { + $openapi = Reader::readFromYaml(<<<'YAML' +openapi: 3.0.2 +info: + title: My API + version: "1.0.0" +paths: + '/path': + get: + responses: + 200: + $ref: '#/components/schemas/notAResponse' +components: + schemas: + notAResponse: + type: "integer" +YAML); + $this->assertFalse($openapi->validate()); + + //PASS - Contains Description + $openapi = Reader::readFromYaml(<<<'YAML' +openapi: 3.0.2 +info: + title: My API + version: "1.0.0" +paths: + '/path': + get: + responses: + 200: + $ref: '#/components/schemas/notAResponse' +components: + schemas: + notAResponse: + type: "integer" + description: "Test Description" +YAML); + + $this->assertTrue($openapi->validate()); + + //FAILS - Contains Description + $openapi = Reader::readFromYaml(<<<'YAML' +openapi: 3.0.2 +info: + title: My API + version: "1.0.0" +paths: + '/path': + get: + responses: + 200: + content: "A simple response" +components: + schemas: + notAResponse: + type: "integer" + description: "Test Description" +YAML); + + $this->assertFalse($openapi->validate()); + + //FAILS - Contains Description + $openapi = Reader::readFromYaml(<<<'YAML' +openapi: 3.0.2 +info: + title: My API + version: "1.0.0" +paths: + '/path': + get: + responses: + 200: + description: "Test Description" +components: + schemas: + notAResponse: + type: "integer" + description: "Test Description" +YAML); + + $this->assertTrue($openapi->validate()); + } } diff --git a/tests/spec/ReferenceTest.php b/tests/spec/ReferenceTest.php index b94c946..30a552f 100644 --- a/tests/spec/ReferenceTest.php +++ b/tests/spec/ReferenceTest.php @@ -54,7 +54,7 @@ public function testResolveInDocument() 200: $ref: "#/components/responses/Pet" YAML - , OpenApi::class); + , OpenApi::class, false); $result = $openapi->validate(); $this->assertEquals([], $openapi->getErrors()); @@ -113,7 +113,7 @@ public function testResolveCyclicReferenceInDocument() frog: $ref: "#/components/examples/frog-example" YAML - , OpenApi::class); + , OpenApi::class, false); $result = $openapi->validate(); $this->assertEquals([], $openapi->getErrors()); @@ -311,7 +311,7 @@ enum: - "Six" YAML; - $openapi = Reader::readFromYaml($schema); + $openapi = Reader::readFromYaml($schema, OpenApi::class, false); $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, $this->createFileUri(__DIR__ . '/data/reference/definitions.yaml'))); $this->assertTrue(isset($openapi->components->schemas['Pet'])); @@ -392,7 +392,7 @@ public function testTransitiveReferenceToFile() YAML; - $openapi = Reader::readFromYaml($schema); + $openapi = Reader::readFromYaml($schema, OpenApi::class, false); $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, $this->createFileUri(__DIR__ . '/data/reference/definitions.yaml'))); $this->assertTrue(isset($openapi->components->schemas['Dog'])); diff --git a/tests/spec/SchemaTest.php b/tests/spec/SchemaTest.php index 1600b3b..fc0ffdc 100644 --- a/tests/spec/SchemaTest.php +++ b/tests/spec/SchemaTest.php @@ -3,6 +3,7 @@ use cebe\openapi\Reader; use cebe\openapi\ReferenceContext; use cebe\openapi\spec\Discriminator; +use cebe\openapi\spec\OpenApi; use cebe\openapi\spec\Reference; use cebe\openapi\spec\Schema; use cebe\openapi\spec\Type; @@ -278,7 +279,7 @@ public function testAllOf() } } JSON; - $openApi = Reader::readFromJson($json); + $openApi = Reader::readFromJson($json, OpenApi::class, false); $this->assertInstanceOf(Schema::class, $identifier = $openApi->components->schemas['identifier']); $this->assertInstanceOf(Schema::class, $person = $openApi->components->schemas['person']); @@ -371,7 +372,7 @@ public function testRefAdditionalProperties() } } JSON; - $openApi = Reader::readFromJson($json); + $openApi = Reader::readFromJson($json, OpenApi::class, false); $this->assertInstanceOf(Schema::class, $booleanProperties = $openApi->components->schemas['booleanProperties']); $this->assertInstanceOf(Schema::class, $person = $openApi->components->schemas['person']);