diff --git a/Makefile b/Makefile index 06447788..5305d1b5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ TESTCASE= +XDEBUG=0 PHPARGS=-dmemory_limit=512M -#PHPARGS=-dmemory_limit=512M -dzend_extension=xdebug.so -dxdebug.remote_enable=1 +XPHPARGS= +ifeq ($(XDEBUG),1) +XPHPARGS=-dzend_extension=xdebug.so -dxdebug.remote_enable=1 -dxdebug.remote_autostart=1 +endif all: @@ -17,14 +21,14 @@ install: yarn install test: - php $(PHPARGS) vendor/bin/phpunit --verbose $(TESTCASE) - php $(PHPARGS) bin/php-openapi validate tests/spec/data/recursion.json - php $(PHPARGS) bin/php-openapi validate tests/spec/data/recursion2.yaml + php $(PHPARGS) $(XPHPARGS) vendor/bin/phpunit --verbose $(TESTCASE) + php $(PHPARGS) $(XPHPARGS) bin/php-openapi validate tests/spec/data/recursion.json + php $(PHPARGS) $(XPHPARGS) bin/php-openapi validate tests/spec/data/recursion2.yaml lint: - php $(PHPARGS) bin/php-openapi validate tests/spec/data/reference/playlist.json - php $(PHPARGS) bin/php-openapi validate tests/spec/data/recursion.json - php $(PHPARGS) bin/php-openapi validate tests/spec/data/recursion2.yaml + php $(PHPARGS) $(XPHPARGS) bin/php-openapi validate tests/spec/data/reference/playlist.json + php $(PHPARGS) $(XPHPARGS) bin/php-openapi validate tests/spec/data/recursion.json + php $(PHPARGS) $(XPHPARGS) bin/php-openapi validate tests/spec/data/recursion2.yaml node_modules/.bin/speccy lint tests/spec/data/reference/playlist.json node_modules/.bin/speccy lint tests/spec/data/recursion.json diff --git a/bin/php-openapi b/bin/php-openapi index 81728f12..00730ef6 100755 --- a/bin/php-openapi +++ b/bin/php-openapi @@ -109,6 +109,9 @@ switch ($command) { $openApi = read_input($inputFile, $inputFormat); $referenceContext = new ReferenceContext($openApi, $inputFile ? realpath($inputFile) : ''); $referenceContext->throwException = false; + // TODO apply reference context mode +// $referenceContext->mode = ReferenceContext::RESOLVE_MODE_ALL; +// $referenceContext->mode = ReferenceContext::RESOLVE_MODE_INLINE; $openApi->resolveReferences($referenceContext); $openApi->setDocumentContext($openApi, new \cebe\openapi\json\JsonPointer('')); @@ -186,6 +189,11 @@ switch ($command) { $openApi = read_input($inputFile, $inputFormat); try { + // TODO apply reference context mode +// $referenceContext->mode = ReferenceContext::RESOLVE_MODE_ALL; +// $referenceContext->mode = ReferenceContext::RESOLVE_MODE_INLINE; + // set document context for correctly converting recursive references + $openApi->setDocumentContext($openApi, new \cebe\openapi\json\JsonPointer('')); $openApi->resolveReferences(); } catch (\cebe\openapi\exceptions\UnresolvableReferenceException $e) { error("[\e[33m{$e->context}\e[0m] " . $e->getMessage()); diff --git a/composer.json b/composer.json index 457bc832..d6201b0b 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "1.5.x-dev" } }, "bin": [ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 667ac5f3..de50cde0 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,9 @@ + + ./src + ./vendor ./tests diff --git a/src/Reader.php b/src/Reader.php index 08c2996c..7dc00a92 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -10,6 +10,7 @@ use cebe\openapi\exceptions\IOException; use cebe\openapi\exceptions\TypeErrorException; use cebe\openapi\exceptions\UnresolvableReferenceException; +use cebe\openapi\json\JsonPointer; use cebe\openapi\spec\OpenApi; use Symfony\Component\Yaml\Yaml; @@ -57,9 +58,12 @@ public static function readFromYaml(string $yaml, string $baseType = OpenApi::cl * @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. - * @param bool $resolveReferences whether to automatically resolve references in the specification. + * @param bool|string $resolveReferences whether to automatically resolve references in the specification. * If `true`, all [[Reference]] objects will be replaced with their referenced spec objects by calling * [[SpecObjectInterface::resolveReferences()]]. + * Since version 1.5.0 this can be a string indicating the reference resolving mode: + * - `inline` only resolve references to external files. + * - `all` resolve all references exceot recursive references. * @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. @@ -75,8 +79,13 @@ public static function readFromJsonFile(string $fileName, string $baseType = Ope throw $e; } $spec = static::readFromJson($fileContent, $baseType); - $spec->setReferenceContext(new ReferenceContext($spec, $fileName)); - if ($resolveReferences) { + $context = new ReferenceContext($spec, $fileName); + $spec->setReferenceContext($context); + if ($resolveReferences !== false) { + if (is_string($resolveReferences)) { + $context->mode = $resolveReferences; + } + $spec->setDocumentContext($spec, new JsonPointer('')); $spec->resolveReferences(); } return $spec; @@ -90,9 +99,12 @@ public static function readFromJsonFile(string $fileName, string $baseType = Ope * @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. - * @param bool $resolveReferences whether to automatically resolve references in the specification. + * @param bool|string $resolveReferences whether to automatically resolve references in the specification. * If `true`, all [[Reference]] objects will be replaced with their referenced spec objects by calling * [[SpecObjectInterface::resolveReferences()]]. + * Since version 1.5.0 this can be a string indicating the reference resolving mode: + * - `inline` only resolve references to external files. + * - `all` resolve all references exceot recursive references. * @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. @@ -108,8 +120,13 @@ public static function readFromYamlFile(string $fileName, string $baseType = Ope throw $e; } $spec = static::readFromYaml($fileContent, $baseType); - $spec->setReferenceContext(new ReferenceContext($spec, $fileName)); - if ($resolveReferences) { + $context = new ReferenceContext($spec, $fileName); + $spec->setReferenceContext($context); + if ($resolveReferences !== false) { + if (is_string($resolveReferences)) { + $context->mode = $resolveReferences; + } + $spec->setDocumentContext($spec, new JsonPointer('')); $spec->resolveReferences(); } return $spec; diff --git a/src/ReferenceContext.php b/src/ReferenceContext.php index 7eaab54f..a5f02d25 100644 --- a/src/ReferenceContext.php +++ b/src/ReferenceContext.php @@ -7,18 +7,37 @@ namespace cebe\openapi; +use cebe\openapi\exceptions\IOException; use cebe\openapi\exceptions\UnresolvableReferenceException; +use cebe\openapi\json\JsonPointer; +use cebe\openapi\spec\Reference; +use Symfony\Component\Yaml\Yaml; /** * ReferenceContext represents a context in which references are resolved. */ class ReferenceContext { + /** + * only resolve external references. + * The result will be a single API description file with references + * inside of the file structure. + */ + const RESOLVE_MODE_INLINE = 'inline'; + /** + * resolve all references, except recursive ones. + */ + const RESOLVE_MODE_ALL = 'all'; + /** * @var bool whether to throw UnresolvableReferenceException in case a reference can not * be resolved. If `false` errors are added to the Reference Objects error list instead. */ public $throwException = true; + /** + * @var string + */ + public $mode = self::RESOLVE_MODE_ALL; /** * @var SpecObjectInterface */ @@ -27,17 +46,32 @@ class ReferenceContext * @var string */ private $_uri; + /** + * @var ReferenceContextCache + */ + private $_cache; + /** * ReferenceContext constructor. * @param SpecObjectInterface $base the base object of the spec. * @param string $uri the URI to the base object. + * @param ReferenceContextCache $cache cache instance for storing referenced file data. * @throws UnresolvableReferenceException in case an invalid or non-absolute URI is provided. */ - public function __construct(?SpecObjectInterface $base, string $uri) + public function __construct(?SpecObjectInterface $base, string $uri, $cache = null) { $this->_baseSpec = $base; $this->_uri = $this->normalizeUri($uri); + $this->_cache = $cache ?? new ReferenceContextCache(); + if ($cache === null && $base !== null) { + $this->_cache->set($this->_uri, null, $base); + } + } + + public function getCache(): ReferenceContextCache + { + return $this->_cache; } /** @@ -46,17 +80,73 @@ public function __construct(?SpecObjectInterface $base, string $uri) private function normalizeUri($uri) { if (strpos($uri, '://') !== false) { - return $uri; + $parts = parse_url($uri); + if (isset($parts['path'])) { + $parts['path'] = $this->reduceDots($parts['path']); + } + return $this->buildUri($parts); } if (strncmp($uri, '/', 1) === 0) { + $uri = $this->reduceDots($uri); return "file://$uri"; } if (stripos(PHP_OS, 'WIN') === 0 && strncmp(substr($uri, 1), ':\\', 2) === 0) { + $uri = $this->reduceDots($uri); return "file:///" . strtr($uri, [' ' => '%20', '\\' => '/']); } throw new UnresolvableReferenceException('Can not resolve references for a specification given as a relative path.'); } + private function buildUri($parts) + { + $scheme = !empty($parts['scheme']) ? $parts['scheme'] . '://' : ''; + $host = $parts['host'] ?? ''; + $port = !empty($parts['port']) ? ':' . $parts['port'] : ''; + $user = $parts['user'] ?? ''; + $pass = !empty($parts['pass']) ? ':' . $parts['pass'] : ''; + $pass = ($user || $pass) ? "$pass@" : ''; + $path = $parts['path'] ?? ''; + $query = !empty($parts['query']) ? '?' . $parts['query'] : ''; + $fragment = !empty($parts['fragment']) ? '#' . $parts['fragment'] : ''; + return "$scheme$user$pass$host$port$path$query$fragment"; + } + + private function reduceDots($path) + { + $parts = explode('/', ltrim($path, '/')); + $c = count($parts); + for($i = 0; $i < $c; $i++) { + if ($parts[$i] === '.') { + unset($parts[$i]); + continue; + } + if ($i > 0 && $parts[$i] === '..' && $parts[$i-1] !== '..') { + unset($parts[$i-1]); + unset($parts[$i]); + } + } + return '/'.implode('/', $parts); + } + + /** + * Returns parent directory's path. + * This method is similar to `dirname()` except that it will treat + * both \ and / as directory separators, independent of the operating system. + * + * @param string $path A path string. + * @return string the parent directory's path. + * @see http://www.php.net/manual/en/function.dirname.php + * @see https://github.com/yiisoft/yii2/blob/e1f6761dfd9eba1ff1260cd37b04936aaa4959b5/framework/helpers/BaseStringHelper.php#L75-L92 + */ + private function dirname($path) + { + $pos = mb_strrpos(str_replace('\\', '/', $path), '/'); + if ($pos !== false) { + return mb_substr($path, 0, $pos); + } + return ''; + } + public function getBaseSpec(): ?SpecObjectInterface { return $this->_baseSpec; @@ -76,66 +166,105 @@ public function getUri(): string public function resolveRelativeUri(string $uri): string { $parts = parse_url($uri); + // absolute URI, no need to combine with baseURI if (isset($parts['scheme'])) { - // absolute URL - return $uri; - } - - $baseUri = $this->getUri(); - if (strncmp($baseUri, 'file://', 7) === 0) { - if (isset($parts['path'][0]) && $parts['path'][0] === '/') { - // absolute path - return 'file://' . $parts['path']; - } - // convert absolute path on windows to a file:// URI. This is probably incomplete but should work with the majority of paths. - if (stripos(PHP_OS, 'WIN') === 0 && strncmp(substr($uri, 1), ':\\', 2) === 0) { - return "file:///" . strtr($uri, [' ' => '%20', '\\' => '/']); - } - if (isset($parts['path'])) { - // relative path - return $this->dirname($baseUri) . '/' . $parts['path']; + $parts['path'] = $this->reduceDots($parts['path']); } + return $this->buildUri($parts); + } - throw new UnresolvableReferenceException("Invalid URI: '$uri'"); + // convert absolute path on windows to a file:// URI. This is probably incomplete but should work with the majority of paths. + if (stripos(PHP_OS, 'WIN') === 0 && strncmp(substr($uri, 1), ':\\', 2) === 0) { + // convert absolute path on windows to a file:// URI. This is probably incomplete but should work with the majority of paths. + $absoluteUri = "file:///" . strtr($uri, [' ' => '%20', '\\' => '/']); + return $absoluteUri + . (isset($parts['fragment']) ? '#' . $parts['fragment'] : ''); } + $baseUri = $this->getUri(); $baseParts = parse_url($baseUri); - $absoluteUri = implode('', [ - $baseParts['scheme'], - '://', - isset($baseParts['username']) ? $baseParts['username'] . ( - isset($baseParts['password']) ? ':' . $baseParts['password'] : '' - ) . '@' : '', - $baseParts['host'] ?? '', - isset($baseParts['port']) ? ':' . $baseParts['port'] : '', - ]); if (isset($parts['path'][0]) && $parts['path'][0] === '/') { - $absoluteUri .= $parts['path']; + // absolute path + $baseParts['path'] = $this->reduceDots($parts['path']); } elseif (isset($parts['path'])) { - $absoluteUri .= rtrim($this->dirname($baseParts['path'] ?? ''), '/') . '/' . $parts['path']; + // relative path + $baseParts['path'] = $this->reduceDots(rtrim($this->dirname($baseParts['path'] ?? ''), '/') . '/' . $parts['path']); + } else { + throw new UnresolvableReferenceException("Invalid URI: '$uri'"); } - return $absoluteUri - . (isset($parts['query']) ? '?' . $parts['query'] : '') - . (isset($parts['fragment']) ? '#' . $parts['fragment'] : ''); + $baseParts['query'] = $parts['query'] ?? null; + $baseParts['fragment'] = $parts['fragment'] ?? null; + return $this->buildUri($baseParts); } /** - * Returns parent directory's path. - * This method is similar to `dirname()` except that it will treat - * both \ and / as directory separators, independent of the operating system. + * Fetch referenced file by URI. * - * @param string $path A path string. - * @return string the parent directory's path. - * @see http://www.php.net/manual/en/function.dirname.php - * @see https://github.com/yiisoft/yii2/blob/e1f6761dfd9eba1ff1260cd37b04936aaa4959b5/framework/helpers/BaseStringHelper.php#L75-L92 + * The current context will cache files by URI, so they are only loaded once. + * + * @throws IOException in case the file is not readable or fetching the file + * from a remote URL failed. */ - private function dirname($path) + public function fetchReferencedFile($uri) { - $pos = mb_strrpos(str_replace('\\', '/', $path), '/'); - if ($pos !== false) { - return mb_substr($path, 0, $pos); + if ($this->_cache->has('FILE_CONTENT://' . $uri, 'FILE_CONTENT')) { + return $this->_cache->get('FILE_CONTENT://' . $uri, 'FILE_CONTENT'); } - return ''; + + $content = file_get_contents($uri); + if ($content === false) { + $e = new IOException("Failed to read file: '$uri'"); + $e->fileName = $uri; + throw $e; + } + // TODO lazy content detection, should be improved + if (strpos(ltrim($content), '{') === 0) { + $parsedContent = json_decode($content, true); + } else { + $parsedContent = Yaml::parse($content); + } + $this->_cache->set('FILE_CONTENT://' . $uri, 'FILE_CONTENT', $parsedContent); + return $parsedContent; + } + + /** + * Retrieve the referenced data via JSON pointer. + * + * This function caches referenced data to make sure references to the same + * data structures end up being the same object instance in PHP. + * + * @param string $uri + * @param JsonPointer $pointer + * @param array $data + * @param string|null $toType + * @return SpecObjectInterface|array + */ + public function resolveReferenceData($uri, JsonPointer $pointer, $data, $toType) + { + $ref = $uri . '#' . $pointer->getPointer(); + if ($this->_cache->has($ref, $toType)) { + return $this->_cache->get($ref, $toType); + } + + $referencedData = $pointer->evaluate($data); + + if ($referencedData === null) { + return null; + } + + // transitive reference + if (isset($referencedData['$ref'])) { + /** @var Reference $referencedObject */ + return new Reference($referencedData, $toType); + } else { + /** @var SpecObjectInterface|array $referencedObject */ + $referencedObject = $toType !== null ? new $toType($referencedData) : $referencedData; + } + + $this->_cache->set($ref, $toType, $referencedObject); + + return $referencedObject; } + } diff --git a/src/ReferenceContextCache.php b/src/ReferenceContextCache.php new file mode 100644 index 00000000..74ed3028 --- /dev/null +++ b/src/ReferenceContextCache.php @@ -0,0 +1,38 @@ + and contributors + * @license https://github.com/cebe/php-openapi/blob/master/LICENSE + */ + +namespace cebe\openapi; + +/** + * ReferenceContextCache represents a cache storage for caching content of referenced files. + */ +class ReferenceContextCache +{ + private $_cache = []; + + + public function set($ref, $type, $data) + { + $this->_cache[$ref][$type ?? ''] = $data; + + // store fallback value for resolving with unknown type + if ($type !== null && !isset($this->_cache[$ref][''])) { + $this->_cache[$ref][''] = $data; + } + } + + public function get($ref, $type) + { + return $this->_cache[$ref][$type ?? ''] ?? null; + } + + public function has($ref, $type) + { + return isset($this->_cache[$ref]) && + array_key_exists($type ?? '', $this->_cache[$ref]); + } +} diff --git a/src/spec/PathItem.php b/src/spec/PathItem.php index 236bda81..f93e43fc 100644 --- a/src/spec/PathItem.php +++ b/src/spec/PathItem.php @@ -91,6 +91,12 @@ public function getSerializableData() if ($this->_ref instanceof Reference) { $data->{'$ref'} = $this->_ref->getReference(); } + if (isset($data->servers) && empty($data->servers)) { + unset($data->servers); + } + if (isset($data->parameters) && empty($data->parameters)) { + unset($data->parameters); + } return $data; } diff --git a/src/spec/Reference.php b/src/spec/Reference.php index 1eef2eca..a1e2b859 100644 --- a/src/spec/Reference.php +++ b/src/spec/Reference.php @@ -168,6 +168,11 @@ public function resolve(ReferenceContext $context = null) } try { if ($jsonReference->getDocumentUri() === '') { + + if ($context->mode === ReferenceContext::RESOLVE_MODE_INLINE) { + return $this; + } + // resolve in current document $baseSpec = $context->getBaseSpec(); if ($baseSpec !== null) { @@ -176,21 +181,7 @@ public function resolve(ReferenceContext $context = null) $referencedObject = $jsonReference->getJsonPointer()->evaluate($baseSpec); // transitive reference if ($referencedObject instanceof Reference) { - if ($referencedObject->_to === null) { - $referencedObject->_to = $this->_to; - } - $referencedObject->setContext($context); - - if ($referencedObject === $this) { // catch recursion - throw new UnresolvableReferenceException('Cyclic reference detected on a Reference Object.'); - } - - $transitiveRefResult = $referencedObject->resolve(); - - if ($transitiveRefResult === $this) { // catch recursion - throw new UnresolvableReferenceException('Cyclic reference detected on a Reference Object.'); - } - return $transitiveRefResult; + $referencedObject = $this->resolveTransitiveReference($referencedObject, $context); } if ($referencedObject instanceof SpecObjectInterface) { $referencedObject->setReferenceContext($context); @@ -205,35 +196,38 @@ public function resolve(ReferenceContext $context = null) // resolve in external document $file = $context->resolveRelativeUri($jsonReference->getDocumentUri()); - // TODO could be a good idea to cache loaded files in current context to avoid loading the same files over and over again - $referencedDocument = $this->fetchReferencedFile($file); - $referencedData = $jsonReference->getJsonPointer()->evaluate($referencedDocument); - - if ($referencedData === null) { - return null; + 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; } - // transitive reference - if (isset($referencedData['$ref'])) { - return (new Reference($referencedData, $this->_to))->resolve(new ReferenceContext(null, $file)); + $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()); + } } - /** @var SpecObjectInterface|array $referencedObject */ - $referencedObject = $this->_to !== null ? new $this->_to($referencedData) : $referencedData; - if ($jsonReference->getJsonPointer()->getPointer() === '') { - $newContext = new ReferenceContext($referencedObject instanceof SpecObjectInterface ? $referencedObject : null, $file); - if ($referencedObject instanceof DocumentContextInterface) { - $referencedObject->setDocumentContext($referencedObject, $jsonReference->getJsonPointer()); + // transitive reference + if ($referencedObject instanceof Reference) { + if ($context->mode === ReferenceContext::RESOLVE_MODE_INLINE && strncmp($referencedObject->getReference(), '#', 1) === 0) { + $referencedObject->setContext($context); + } else { + return $this->resolveTransitiveReference($referencedObject, $context); } } else { - // resolving references recursively does not work the same if we have not referenced - // the whole document. We do not know the base type of the file at this point, - // so base document must be null. - $newContext = new ReferenceContext(null, $file); - } - $newContext->throwException = $context->throwException; - if ($referencedObject instanceof SpecObjectInterface) { - $referencedObject->setReferenceContext($newContext); + if ($referencedObject instanceof SpecObjectInterface) { + $referencedObject->setReferenceContext($context); + } } return $referencedObject; @@ -258,33 +252,52 @@ public function resolve(ReferenceContext $context = null) } } - /** - * @throws UnresolvableReferenceException - */ - private function fetchReferencedFile($uri) + private function resolveTransitiveReference(Reference $referencedObject, ReferenceContext $context) { - try { - $content = file_get_contents($uri); - if ($content === false) { - $e = new IOException("Failed to read file: '$uri'"); - $e->fileName = $uri; - throw $e; + if ($referencedObject->_to === null) { + $referencedObject->_to = $this->_to; + } + $referencedObject->setContext($context); + + if ($referencedObject === $this) { // catch recursion + throw new UnresolvableReferenceException('Cyclic reference detected on a Reference Object.'); + } + + $transitiveRefResult = $referencedObject->resolve(); + + if ($transitiveRefResult === $this) { // catch recursion + throw new UnresolvableReferenceException('Cyclic reference detected on a Reference Object.'); + } + return $transitiveRefResult; + } + + // adjust relative refernces inside of the file to match the context of the base file + private function adjustRelativeReferences($referencedDocument, $basePath, $baseDocument = null, $oContext = null) + { + $context = new ReferenceContext(null, $basePath); + if ($baseDocument === null) { + $baseDocument = $referencedDocument; + } + + foreach($referencedDocument as $key => $value) { + if ($key === '$ref' && is_string($value)) { + if (isset($value[0]) && $value[0] === '#') { + // direcly inline references in the same document, + // these are not going to be valid in the new context anymore + return (new JsonPointer(substr($value, 1)))->evaluate($baseDocument); + } + $referencedDocument[$key] = $context->resolveRelativeUri($value); + $parts = explode('#', $referencedDocument[$key], 2); + if ($parts[0] === $oContext->getUri()) { + $referencedDocument[$key] = '#' . ($parts[1] ?? ''); + } + continue; } - // TODO lazy content detection, should probably be improved - if (strpos(ltrim($content), '{') === 0) { - return json_decode($content, true); - } else { - return Yaml::parse($content); + if (is_array($value)) { + $referencedDocument[$key] = $this->adjustRelativeReferences($value, $basePath, $baseDocument, $oContext); } - } 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; } + return $referencedDocument; } /** diff --git a/tests/ReferenceContextTest.php b/tests/ReferenceContextTest.php index 4934da95..92d9d93e 100644 --- a/tests/ReferenceContextTest.php +++ b/tests/ReferenceContextTest.php @@ -6,7 +6,7 @@ class ReferenceContextTest extends \PHPUnit\Framework\TestCase { - public function uriProvider() + public function resolveUriProvider() { $data = [ [ @@ -14,37 +14,77 @@ public function uriProvider() 'definitions.yaml', // referenced URI 'https://example.com/definitions.yaml', // expected result ], + [ + 'https://example.com/openapi.yaml', // base URI + 'definitions.yaml#/components/Pet', // referenced URI + 'https://example.com/definitions.yaml#/components/Pet', // expected result + ], + [ 'https://example.com/openapi.yaml', // base URI '/definitions.yaml', // referenced URI 'https://example.com/definitions.yaml', // expected result ], + [ + 'https://example.com/openapi.yaml', // base URI + '/definitions.yaml#/components/Pet', // referenced URI + 'https://example.com/definitions.yaml#/components/Pet', // expected result + ], + [ 'https://example.com/api/openapi.yaml', // base URI 'definitions.yaml', // referenced URI 'https://example.com/api/definitions.yaml', // expected result ], + [ + 'https://example.com/api/openapi.yaml', // base URI + 'definitions.yaml#/components/Pet', // referenced URI + 'https://example.com/api/definitions.yaml#/components/Pet', // expected result + ], + [ 'https://example.com/api/openapi.yaml', // base URI '/definitions.yaml', // referenced URI 'https://example.com/definitions.yaml', // expected result ], + [ + 'https://example.com/api/openapi.yaml', // base URI + '/definitions.yaml#/components/Pet', // referenced URI + 'https://example.com/definitions.yaml#/components/Pet', // expected result + ], + [ 'https://example.com/api/openapi.yaml', // base URI '../definitions.yaml', // referenced URI - 'https://example.com/api/../definitions.yaml', // expected result + 'https://example.com/definitions.yaml', // expected result ], + [ + 'https://example.com/api/openapi.yaml', // base URI + '../definitions.yaml#/components/Pet', // referenced URI + 'https://example.com/definitions.yaml#/components/Pet', // expected result + ], + [ '/var/www/openapi.yaml', // base URI 'definitions.yaml', // referenced URI 'file:///var/www/definitions.yaml', // expected result ], + [ + '/var/www/openapi.yaml', // base URI + 'definitions.yaml#/components/Pet', // referenced URI + 'file:///var/www/definitions.yaml#/components/Pet', // expected result + ], + [ '/var/www/openapi.yaml', // base URI '/var/definitions.yaml', // referenced URI 'file:///var/definitions.yaml', // expected result ], - + [ + '/var/www/openapi.yaml', // base URI + '/var/definitions.yaml#/components/Pet', // referenced URI + 'file:///var/definitions.yaml#/components/Pet', // expected result + ], ]; // absolute URLs should not be changed @@ -54,18 +94,29 @@ public function uriProvider() 'file:///var/www/definitions.yaml', 'file:///var/www/definitions.yaml', ]; + $data[] = [ + $url, + 'file:///var/www/definitions.yaml#/components/Pet', + 'file:///var/www/definitions.yaml#/components/Pet', + ]; + $data[] = [ $url, 'https://example.com/definitions.yaml', 'https://example.com/definitions.yaml', ]; + $data[] = [ + $url, + 'https://example.com/definitions.yaml#/components/Pet', + 'https://example.com/definitions.yaml#/components/Pet', + ]; } return $data; } /** - * @dataProvider uriProvider + * @dataProvider resolveUriProvider */ public function testResolveUri($baseUri, $referencedUri, $expected) { @@ -73,4 +124,77 @@ public function testResolveUri($baseUri, $referencedUri, $expected) $this->assertEquals($expected, $context->resolveRelativeUri($referencedUri)); } + public function normalizeUriProvider() + { + $data = [ + [ + 'https://example.com/openapi.yaml', + 'https://example.com/openapi.yaml', + ], + [ + 'https://example.com/openapi.yaml#/components/Pet', + 'https://example.com/openapi.yaml#/components/Pet', + ], + [ + 'https://example.com/./openapi.yaml', + 'https://example.com/openapi.yaml', + ], + [ + 'https://example.com/./openapi.yaml#/components/Pet', + 'https://example.com/openapi.yaml#/components/Pet', + ], + [ + 'https://example.com/api/../openapi.yaml', + 'https://example.com/openapi.yaml', + ], + [ + 'https://example.com/api/../openapi.yaml#/components/Pet', + 'https://example.com/openapi.yaml#/components/Pet', + ], + [ + 'https://example.com/../openapi.yaml', + 'https://example.com/../openapi.yaml', + ], + [ + 'https://example.com/../openapi.yaml#/components/Pet', + 'https://example.com/../openapi.yaml#/components/Pet', + ], + [ + '/definitions.yaml', + 'file:///definitions.yaml', + ], + [ + '/definitions.yaml#/components/Pet', + 'file:///definitions.yaml#/components/Pet', + ], + [ + '/var/www/definitions.yaml', + 'file:///var/www/definitions.yaml', + ], + [ + '/var/www/definitions.yaml#/components/Pet', + 'file:///var/www/definitions.yaml#/components/Pet', + ], + [ + '/var/www/api/../definitions.yaml', + 'file:///var/www/definitions.yaml', + ], + [ + '/var/www/api/../definitions.yaml#/components/Pet', + 'file:///var/www/definitions.yaml#/components/Pet', + ], + ]; + + return $data; + } + + /** + * @dataProvider normalizeUriProvider + */ + public function testNormalizeUri($uri, $expected) + { + $context = new ReferenceContext(null, $uri); + $this->assertEquals($expected, $context->getUri()); + } + } diff --git a/tests/spec/PathTest.php b/tests/spec/PathTest.php index aa065a64..cf50b19d 100644 --- a/tests/spec/PathTest.php +++ b/tests/spec/PathTest.php @@ -161,8 +161,13 @@ public function testPathItemReference() $this->assertInstanceOf(Operation::class, $ReferencedBarPath->get); $this->assertEquals('getBar', $ReferencedBarPath->get->operationId); - $this->assertInstanceOf(Reference::class, $ReferencedBarPath->get->responses['200']); - $this->assertInstanceOf(Reference::class, $ReferencedBarPath->get->responses['404']); + $this->assertInstanceOf(Reference::class, $reference200 = $ReferencedBarPath->get->responses['200']); + $this->assertInstanceOf(Response::class, $ReferencedBarPath->get->responses['404']); + $this->assertEquals('non-existing resource', $ReferencedBarPath->get->responses['404']->description); + + $path200 = $reference200->resolve(); + $this->assertInstanceOf(Response::class, $path200); + $this->assertEquals('A bar', $path200->description); /** @var $openapi OpenApi */ $openapi = Reader::readFromYamlFile($file, \cebe\openapi\spec\OpenApi::class, true); diff --git a/tests/spec/ReferenceTest.php b/tests/spec/ReferenceTest.php index 223c4eb9..29928b4c 100644 --- a/tests/spec/ReferenceTest.php +++ b/tests/spec/ReferenceTest.php @@ -416,4 +416,142 @@ public function testTransitiveReferenceCyclic() $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, 'file:///tmp/openapi.yaml')); } + + public function testTransitiveReferenceOverTwoFiles() + { + $openapi = Reader::readFromYamlFile(__DIR__ . '/data/reference/structure.yaml', OpenApi::class, \cebe\openapi\ReferenceContext::RESOLVE_MODE_INLINE); + + $yaml = \cebe\openapi\Writer::writeToYaml($openapi); + + $this->assertEquals( +<<assertEquals( +<<assertEquals( +<<