diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 00000000..de060f21 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,101 @@ +name: PHP Composer + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + strategy: + fail-fast: false + matrix: +# os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest] + php: ['7.1', '7.2', '7.3', '7.4', '8.0'] + # max 4.4.16, see https://github.com/symfony/symfony/issues/39521 + # max 5.1.8, see https://github.com/symfony/symfony/issues/39521 + yaml: ['5.1.8', '4.4.16', '^3.4'] + exclude: + # Symfony YAML does not run on PHP 7.1 + - php: '7.1' + yaml: '5.1.8' + include: + - php: '7.4' + os: windows-latest + yaml: '5.1.8' + - php: '7.4' + os: macos-latest + yaml: '5.1.8' + + + runs-on: ${{ matrix.os }} + env: + YAML: ${{ matrix.yaml }} + + steps: + - uses: actions/checkout@v2 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + ini-values: date.timezone='UTC' + coverage: pcov + tools: composer:v2 + + - name: Determine composer cache directory (Linux/MacOS) + if: matrix.os != 'windows-latest' + run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV + + - name: Determine composer cache directory (Windows) + if: matrix.os == 'windows-latest' + run: echo "COMPOSER_CACHE_DIR=~\AppData\Local\Composer" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Cache dependencies installed with composer + uses: actions/cache@v2 + with: + path: ${{ env.COMPOSER_CACHE_DIR }} + key: php${{ matrix.php }}-os${{ matrix.os }}-yaml${{ matrix.yaml }}-composer-${{ hashFiles('**/composer.json') }} + + + - name: Validate composer.json and composer.lock + run: composer validate --ansi + + - name: Install dependencies (Linux/MacOS) + if: matrix.os != 'windows-latest' + run: | + make install + composer require symfony/yaml:"${YAML}" --prefer-dist --no-interaction --ansi + + - name: Install dependencies (Windows) + if: matrix.os == 'windows-latest' + run: | + composer install --prefer-dist --no-interaction --no-progress --ansi + composer require symfony/yaml:5.1.8 --prefer-dist --no-interaction --ansi + + - name: Validate test data + if: matrix.os == 'ubuntu-latest' + run: make lint + + - name: PHP Stan analysis + if: matrix.os == 'ubuntu-latest' + run: make stan + + - name: PHPUnit tests (Linux/MacOS) + if: matrix.os != 'windows-latest' + run: make test + + - name: PHPUnit tests (Windows) + if: matrix.os == 'windows-latest' + run: vendor/bin/phpunit --colors=always + + - name: Check code style + if: matrix.os == 'ubuntu-latest' + run: make check-style + + - name: Code coverage + if: matrix.os == 'ubuntu-latest' + run: make coverage diff --git a/.gitignore b/.gitignore index 9356be31..c86af6fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ /vendor /composer.lock +/composer.phar /node_modules /.php_cs.cache +/.phpunit.result.cache + +php-cs-fixer.phar diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e3afb510..00000000 --- a/.travis.yml +++ /dev/null @@ -1,62 +0,0 @@ -language: php - -matrix: - include: - # linux tests - - php: '7.1' - env: YAML=^3.0 - - php: '7.1' - env: YAML=~4.3.0 - - php: '7.2' - env: YAML=^3.0 - - php: '7.2' - env: YAML=~4.3.0 - - php: '7.3' - env: YAML=^3.0 - - php: '7.3' - env: YAML=~4.3.0 - - php: '7.3' - env: YAML=^5.0 - - php: '7.4' - env: YAML=^3.0 - - php: '7.4' - env: YAML=~4.3.0 - - php: '7.4' - env: YAML=^5.0 - - php: nightly - env: YAML=~4.3.0 - # windows tests - # https://travis-ci.community/t/where-to-contribute-php-support-for-windows/304 - - os: windows - language: sh - before_install: - - export PATH="/c/tools/composer:/c/tools/php73:$PATH" - - echo "$PATH" - - choco install php --version 7.3.5 - - php -i - - cat /c/tools/php73/php.ini - #- choco install make - - ls /c/tools/php73 - - ls /c/tools/php73/ext - #- ls /c/tools/composer - - wget https://raw.githubusercontent.com/composer/getcomposer.org/76a7060ccb93902cd7576b67264ad91c8a2700e2/web/installer -O - -q | php -dextension=/c/tools/php73/ext/php_openssl.dll -- - - ls - install: - - php -dextension=/c/tools/php73/ext/php_openssl.dll -dextension=/c/tools/php73/ext/php_mbstring.dll composer.phar install --prefer-dist --no-interaction - - php -dextension=/c/tools/php73/ext/php_openssl.dll -dextension=/c/tools/php73/ext/php_mbstring.dll composer.phar require symfony/yaml:"~4.3.0" --prefer-dist --no-interaction - script: php -dextension=/c/tools/php73/ext/php_openssl.dll -dextension=/c/tools/php73/ext/php_mbstring.dll vendor/phpunit/phpunit/phpunit - - # allow php nightly to fail until there is a phpunit version that supports PHP 8 - allow_failures: - - php: nightly - -install: - - make install - - composer require symfony/yaml:"${YAML}" --prefer-dist --no-interaction -script: - - make lint - - make stan - - make test - - if [[ $TRAVIS_PHP_VERSION = "7.3" || $TRAVIS_PHP_VERSION = "nightly" ]]; then true; else make check-style; fi - - make coverage - diff --git a/Makefile b/Makefile index 06447788..627df52f 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,34 @@ 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: -check-style: - vendor/bin/php-cs-fixer fix src/ --diff --dry-run +check-style: php-cs-fixer.phar + PHP_CS_FIXER_IGNORE_ENV=1 ./php-cs-fixer.phar fix src/ --diff --dry-run -fix-style: +fix-style: php-cs-fixer.phar vendor/bin/indent --tabs composer.json vendor/bin/indent --spaces .php_cs.dist - vendor/bin/php-cs-fixer fix src/ --diff + ./php-cs-fixer.phar fix src/ --diff install: - composer install --prefer-dist --no-interaction + composer install --prefer-dist --no-interaction --no-progress --ansi 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 --colors=always $(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 @@ -38,6 +42,8 @@ schemas/openapi-v3.0.json: vendor/oai/openapi-specification/schemas/v3.0/schema. schemas/openapi-v3.0.yaml: vendor/oai/openapi-specification/schemas/v3.0/schema.yaml cp $< $@ +php-cs-fixer.phar: + wget -q https://github.com/FriendsOfPHP/PHP-CS-Fixer/releases/download/v2.16.7/php-cs-fixer.phar && chmod +x php-cs-fixer.phar # find spec classes that are not mentioned in tests with @covers yet coverage: .php-openapi-covA .php-openapi-covB diff --git a/README.md b/README.md index ae7a86e5..0bdb7981 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It also provides a CLI tool for validating and converting OpenAPI 3.0.x Descript [![Latest Stable Version](https://poser.pugx.org/cebe/php-openapi/v/stable)](https://packagist.org/packages/cebe/php-openapi) [![Total Downloads](https://poser.pugx.org/cebe/php-openapi/downloads)](https://packagist.org/packages/cebe/php-openapi) -[![Build Status](https://travis-ci.org/cebe/php-openapi.svg?branch=master)](https://travis-ci.org/cebe/php-openapi) +[![Build Status](https://github.com/cebe/php-openapi/workflows/PHP%20Composer/badge.svg)](https://github.com/cebe/php-openapi/actions) [![License](https://poser.pugx.org/cebe/php-openapi/license)](https://packagist.org/packages/cebe/php-openapi) @@ -16,7 +16,7 @@ It also provides a CLI tool for validating and converting OpenAPI 3.0.x Descript ## Requirements -- PHP 7.1 or higher +- PHP 7.1 or higher (works fine with PHP 8) ## Used by @@ -26,6 +26,7 @@ do awesome work: - [cebe/yii2-openapi](https://github.com/cebe/yii2-openapi) Code Generator for REST API from OpenAPI 3 Descriptions, includes fake data generator. - [cebe/yii2-app-api](https://github.com/cebe/yii2-app-api) Yii framework application template for developing API-first applications. - [league/openapi-psr7-validator](https://github.com/thephpleague/openapi-psr7-validator) validates PSR-7 messages (HTTP request/response) against OpenAPI descriptions. +- [dsuurlant/response2schema](https://github.com/dsuurlant/response2schema) a quick and easy tool for generating OpenAPI schemas based on example data. - ... ([add yours](https://github.com/cebe/php-openapi/edit/master/README.md#L24)) ## Usage @@ -54,7 +55,6 @@ do awesome work: Exits with code 2 on validation errors, 1 on other errors and 0 on success. convert Convert a JSON or YAML input file to JSON or YAML output file. - References are being resolved so the output will be a single API Description file. If no input file is specified input will be read from STDIN. If no output file is specified output will be written to STDOUT. @@ -62,6 +62,19 @@ do awesome work: to do so, you may specify --read-yaml or --read-json to force the input file type. and --write-yaml or --write-json to force the output file type. + By default all references are resolved (replaced with the object refered to). You can control + handling of references with the following arguments: + + --resolve-none Do not resolve references. + --resolve-external Only resolve references that point to external files. + This process is often referred to as "inlining". + --resolve-all Resolve all references (default). + Recursive pointers will stay references. + + inline Convert a JSON or YAML input file to JSON or YAML output file and + resolve all external references. The output will be a single API Description file. + This is a shortcut for calling convert --resolve-external. + help Shows this usage information. Options: @@ -70,6 +83,7 @@ do awesome work: --read-yaml force reading input as YAML. Auto-detect if not specified. --write-json force writing output as JSON. Auto-detect if not specified. --write-yaml force writing output as YAML. Auto-detect if not specified. + -s, --silent silent mode. Will hide all success/information messages and only print errors. ### Reading API Description Files diff --git a/bin/php-openapi b/bin/php-openapi index 81728f12..95052987 100755 --- a/bin/php-openapi +++ b/bin/php-openapi @@ -33,6 +33,7 @@ $inputFormat = null; $outputFile = null; $outputFormat = null; $silentMode = false; +$referenceMode = ReferenceContext::RESOLVE_MODE_ALL; foreach($argv as $k => $arg) { if ($k == 0) { continue; @@ -54,6 +55,15 @@ foreach($argv as $k => $arg) { error("Conflicting arguments: only one of --read-json or --read-yaml is allowed!", "usage"); } break; + case '--resolve-none': + $referenceMode = false; + break; + case '--resolve-external': + $referenceMode = ReferenceContext::RESOLVE_MODE_INLINE; + break; + case '--resolve-all': + $referenceMode = ReferenceContext::RESOLVE_MODE_ALL; + break; case '--write-yaml': if ($outputFormat === null) { $outputFormat = 'yaml'; @@ -89,6 +99,11 @@ foreach($argv as $k => $arg) { } else { if ($command === null) { $command = $arg; + // inline is an alias for "convert --resolve-external" + if ($command === 'inline') { + $command = 'convert'; + $referenceMode = ReferenceContext::RESOLVE_MODE_INLINE; + } } elseif ($inputFile === null) { $inputFile = $arg; } elseif ($outputFile === null) { @@ -109,6 +124,7 @@ switch ($command) { $openApi = read_input($inputFile, $inputFormat); $referenceContext = new ReferenceContext($openApi, $inputFile ? realpath($inputFile) : ''); $referenceContext->throwException = false; + $referenceContext->mode = ReferenceContext::RESOLVE_MODE_INLINE; $openApi->resolveReferences($referenceContext); $openApi->setDocumentContext($openApi, new \cebe\openapi\json\JsonPointer('')); @@ -186,7 +202,13 @@ switch ($command) { $openApi = read_input($inputFile, $inputFormat); try { - $openApi->resolveReferences(); + // set document context for correctly converting recursive references + $openApi->setDocumentContext($openApi, new \cebe\openapi\json\JsonPointer('')); + if ($referenceMode) { + $referenceContext = new ReferenceContext($openApi, $inputFile ? realpath($inputFile) : ''); + $referenceContext->mode = $referenceMode; + $openApi->resolveReferences($referenceContext); + } } catch (\cebe\openapi\exceptions\UnresolvableReferenceException $e) { error("[\e[33m{$e->context}\e[0m] " . $e->getMessage()); } @@ -302,7 +324,6 @@ Usage: Exits with code 2 on validation errors, 1 on other errors and 0 on success. \Bconvert\C Convert a JSON or YAML input file to JSON or YAML output file. - References are being resolved so the output will be a single API Description file. If no input file is specified input will be read from STDIN. If no output file is specified output will be written to STDOUT. @@ -310,6 +331,19 @@ Usage: to do so, you may specify \Y--read-yaml\C or \Y--read-json\C to force the input file type. and \Y--write-yaml\C or \Y--write-json\C to force the output file type. + By default all references are resolved (replaced with the object refered to). You can control + handling of references with the following arguments: + + \Y--resolve-none\C Do not resolve references. + \Y--resolve-external\C Only resolve references that point to external files. + This process is often referred to as "inlining". + \Y--resolve-all\C Resolve all references (default). + Recursive pointers will stay references. + + \Binline\C Convert a JSON or YAML input file to JSON or YAML output file and + resolve all external references. The output will be a single API Description file. + This is a shortcut for calling \Bconvert\C \Y--resolve-external\C. + \Bhelp\C Shows this usage information. Options: diff --git a/composer.json b/composer.json index 1e05bd54..eef6513b 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "cebe/php-openapi", - "description": "READ OpenAPI yaml files and make the content accessable in PHP objects.", + "description": "Read and write OpenAPI yaml/json files and make the content accessable in PHP objects.", "keywords": ["openapi"], "homepage": "https://github.com/cebe/php-openapi#readme", "type": "library", @@ -20,15 +20,14 @@ "require": { "php": ">=7.1.0", "ext-json": "*", - "symfony/yaml": "^3.0 | ^4.0 | ^5.0", + "symfony/yaml": "^3.4 | ^4.0 | ^5.0", "justinrainbow/json-schema": "^5.0" }, "require-dev": { "cebe/indent": "*", - "friendsofphp/php-cs-fixer": "~2.16.1", - "phpunit/phpunit": "^6.5", + "phpunit/phpunit": "^6.5 || ^7.5 || ^8.5 || ^9.4", - "oai/openapi-specification": "3.0.2", + "oai/openapi-specification": "3.0.3", "mermade/openapi3-examples": "1.0.0", "apis-guru/openapi-directory": "1.0.0", "nexmo/api-specification": "1.0.0", @@ -41,7 +40,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "1.5.x-dev" } }, "bin": [ @@ -52,11 +51,11 @@ "type": "package", "package": { "name": "oai/openapi-specification", - "version": "3.0.2", + "version": "3.0.3", "source": { "url": "https://github.com/OAI/OpenAPI-Specification", "type": "git", - "reference": "3.0.2" + "reference": "3.0.3" } } }, @@ -92,7 +91,7 @@ "source": { "url": "https://github.com/Nexmo/api-specification", "type": "git", - "reference": "master" + "reference": "voice-2.0.0" } } } 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..d073fa01 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 except 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,15 @@ 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; + } + if ($spec instanceof DocumentContextInterface) { + $spec->setDocumentContext($spec, new JsonPointer('')); + } $spec->resolveReferences(); } return $spec; @@ -90,9 +101,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 except 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 +122,15 @@ 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; + } + if ($spec instanceof DocumentContextInterface) { + $spec->setDocumentContext($spec, new JsonPointer('')); + } $spec->resolveReferences(); } return $spec; diff --git a/src/ReferenceContext.php b/src/ReferenceContext.php index 7eaab54f..3d824a63 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) { - return "file:///" . strtr($uri, [' ' => '%20', '\\' => '/']); + $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,103 @@ 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|null + */ + 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'])) { + 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/Callback.php b/src/spec/Callback.php index b73ae8fe..06bbd58a 100644 --- a/src/spec/Callback.php +++ b/src/spec/Callback.php @@ -22,12 +22,25 @@ */ class Callback implements SpecObjectInterface, DocumentContextInterface { + /** + * @var string|null + */ private $_url; + /** + * @var PathItem + */ private $_pathItem; - + /** + * @var array + */ private $_errors = []; - + /** + * @var SpecObjectInterface|null + */ private $_baseDocument; + /** + * @var JsonPointer|null + */ private $_jsonPointer; 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/Paths.php b/src/spec/Paths.php index e87606a0..a2da8a10 100644 --- a/src/spec/Paths.php +++ b/src/spec/Paths.php @@ -34,10 +34,17 @@ class Paths implements SpecObjectInterface, DocumentContextInterface, ArrayAcces * @var (PathItem|null)[] */ private $_paths = []; - + /** + * @var array + */ private $_errors = []; - + /** + * @var SpecObjectInterface|null + */ private $_baseDocument; + /** + * @var JsonPointer|null + */ private $_jsonPointer; diff --git a/src/spec/Reference.php b/src/spec/Reference.php index 1eef2eca..9fff04bd 100644 --- a/src/spec/Reference.php +++ b/src/spec/Reference.php @@ -29,14 +29,33 @@ */ class Reference implements SpecObjectInterface, DocumentContextInterface { + /** + * @var string + */ private $_to; + /** + * @var string + */ private $_ref; + /** + * @var JsonReference|null + */ private $_jsonReference; + /** + * @var ReferenceContext + */ private $_context; - + /** + * @var SpecObjectInterface|null + */ private $_baseDocument; + /** + * @var JsonPointer|null + */ private $_jsonPointer; - + /** + * @var array + */ private $_errors = []; /** @@ -168,6 +187,10 @@ 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 +199,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 +214,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 +270,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/src/spec/Schema.php b/src/spec/Schema.php index 8f69a8d5..59eb425c 100644 --- a/src/spec/Schema.php +++ b/src/spec/Schema.php @@ -26,9 +26,9 @@ * @property string $title * @property int|float $multipleOf * @property int|float $maximum - * @property int|float $exclusiveMaximum + * @property bool $exclusiveMaximum * @property int|float $minimum - * @property int|float $exclusiveMinimum + * @property bool $exclusiveMinimum * @property int $maxLength * @property int $minLength * @property string $pattern (This string SHOULD be a valid regular expression, according to the [ECMA 262 regular expression dialect](https://www.ecma-international.org/ecma-262/5.1/#sec-7.8.5)) diff --git a/tests/ReaderTest.php b/tests/ReaderTest.php index 6aa1faed..6e961ab6 100644 --- a/tests/ReaderTest.php +++ b/tests/ReaderTest.php @@ -96,8 +96,12 @@ public function testSymfonyYamlBugHunt() { // skip test on symfony/yaml 5.0 due to bug https://github.com/symfony/symfony/issues/34805 $installed = json_decode(file_get_contents(__DIR__ . '/../vendor/composer/installed.json'), true); + // Check for composer 2.0 structure + if (array_key_exists('packages', $installed)) { + $installed = $installed['packages']; + } foreach($installed as $pkg) { - if ($pkg['name'] === 'symfony/yaml' && strncmp($pkg['version_normalized'], '5.0', 3) === 0) { + if ($pkg['name'] === 'symfony/yaml' && version_compare($pkg['version'], 'v4.4', '>=')) { $this->markTestSkipped( 'This test is incompatible with symfony/yaml 4.4 and 5.0, see symfony bug https://github.com/symfony/symfony/issues/34805' ); @@ -108,7 +112,13 @@ public function testSymfonyYamlBugHunt() $openapi = \cebe\openapi\Reader::readFromYamlFile($openApiFile); $inlineYamlExample = $openapi->paths['/']->get->responses['200']->content['application/json']->example; - $this->assertInternalType('array', $inlineYamlExample); + + if (method_exists($this, 'assertIsArray')) { + $this->assertIsArray($inlineYamlExample); + } else { + $this->assertInternalType('array', $inlineYamlExample); + } + $expectedArray = json_decode(<<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/MediaTypeTest.php b/tests/spec/MediaTypeTest.php index f18bef29..125e721d 100644 --- a/tests/spec/MediaTypeTest.php +++ b/tests/spec/MediaTypeTest.php @@ -46,7 +46,13 @@ public function testRead() $this->assertTrue($result); $this->assertInstanceOf(Reference::class, $mediaType->schema); - $this->assertInternalType('array', $mediaType->examples); + + if (method_exists($this, 'assertIsArray')) { + $this->assertIsArray($mediaType->examples); + } else { + $this->assertInternalType('array', $mediaType->examples); + } + $this->assertCount(3, $mediaType->examples); $this->assertArrayHasKey('cat', $mediaType->examples); $this->assertArrayHasKey('dog', $mediaType->examples); diff --git a/tests/spec/OpenApiTest.php b/tests/spec/OpenApiTest.php index dc9dd449..bf321f1b 100644 --- a/tests/spec/OpenApiTest.php +++ b/tests/spec/OpenApiTest.php @@ -52,7 +52,12 @@ public function testReadPetStore() // servers - $this->assertInternalType('array', $openapi->servers); + if (method_exists($this, 'assertIsArray')) { + $this->assertIsArray($openapi->servers); + } else { + $this->assertInternalType('array', $openapi->servers); + } + $this->assertCount(1, $openapi->servers); foreach ($openapi->servers as $server) { $this->assertInstanceOf(\cebe\openapi\spec\Server::class, $server); @@ -186,8 +191,12 @@ public function testSpecs($openApiFile) // skip test on symfony/yaml 5.0 due to bug https://github.com/symfony/symfony/issues/34805 if ($openApiFile === 'oai/openapi-specification/examples/v3.0/uspto.yaml') { $installed = json_decode(file_get_contents(__DIR__ . '/../../vendor/composer/installed.json'), true); + // Check for composer 2.0 structure + if (array_key_exists('packages', $installed)) { + $installed = $installed['packages']; + } foreach ($installed as $pkg) { - if ($pkg['name'] === 'symfony/yaml' && strncmp($pkg['version_normalized'], '5.0', 3) === 0) { + if ($pkg['name'] === 'symfony/yaml' && version_compare($pkg['version'], 'v4.4', '>=')) { $this->markTestSkipped( 'This test is incompatible with symfony/yaml 4.4 and 5.0, see symfony bug https://github.com/symfony/symfony/issues/34805' ); 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..40307fd2 100644 --- a/tests/spec/ReferenceTest.php +++ b/tests/spec/ReferenceTest.php @@ -136,14 +136,20 @@ public function testResolveCyclicReferenceInDocument() $this->assertSame($openapi->components->examples['frog-example'], $refExample); } - public function testResolveFile() + private function createFileUri($file) { - $file = __DIR__ . '/data/reference/base.yaml'; if (stripos(PHP_OS, 'WIN') === 0) { - $yaml = str_replace('##ABSOLUTEPATH##', 'file:///' . strtr(dirname($file), [' ' => '%20', '\\' => '/']), file_get_contents($file)); + return 'file:///' . strtr($file, [' ' => '%20', '\\' => '/']); } else { - $yaml = str_replace('##ABSOLUTEPATH##', 'file://' . dirname($file), file_get_contents($file)); + return 'file://' . $file; } + } + + public function testResolveFile() + { + $file = __DIR__ . '/data/reference/base.yaml'; + $yaml = str_replace('##ABSOLUTEPATH##', $this->createFileUri(dirname($file)), file_get_contents($file)); + /** @var $openapi OpenApi */ $openapi = Reader::readFromYaml($yaml); @@ -295,7 +301,7 @@ enum: YAML; $openapi = Reader::readFromYaml($schema); - $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, 'file://' . __DIR__ . '/data/reference/definitions.yaml')); + $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, $this->createFileUri(__DIR__ . '/data/reference/definitions.yaml'))); $this->assertTrue(isset($openapi->components->schemas['Pet'])); $this->assertEquals(['One', 'Two'], $openapi->components->schemas['Pet']->properties['typeA']->enum); @@ -376,7 +382,7 @@ public function testTransitiveReferenceToFile() YAML; $openapi = Reader::readFromYaml($schema); - $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, 'file://' . __DIR__ . '/data/reference/definitions.yaml')); + $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, $this->createFileUri(__DIR__ . '/data/reference/definitions.yaml'))); $this->assertTrue(isset($openapi->components->schemas['Dog'])); $this->assertEquals('object', $openapi->components->schemas['Dog']->type); @@ -416,4 +422,163 @@ 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); + + $expected = <<assertEquals(str_replace("'200':", "200:", $expected), $yaml, $yaml); + } else { + $this->assertEquals($expected, $yaml, $yaml); + } + } + + public function testResolveRelativePathInline() + { + $openapi = Reader::readFromYamlFile(__DIR__ . '/data/reference/openapi_models.yaml', OpenApi::class, \cebe\openapi\ReferenceContext::RESOLVE_MODE_INLINE); + + $yaml = \cebe\openapi\Writer::writeToYaml($openapi); + + $expected = <<assertEquals(str_replace("'200':", "200:", $expected), $yaml, $yaml); + } else { + $this->assertEquals($expected, $yaml, $yaml); + } + } + + public function testResolveRelativePathAll() + { + $openapi = Reader::readFromYamlFile(__DIR__ . '/data/reference/openapi_models.yaml', OpenApi::class, \cebe\openapi\ReferenceContext::RESOLVE_MODE_ALL); + + $yaml = \cebe\openapi\Writer::writeToYaml($openapi); + + $expected = <<assertEquals(str_replace("'200':", "200:", $expected), $yaml, $yaml); + } else { + $this->assertEquals($expected, $yaml, $yaml); + } + } + } diff --git a/tests/spec/data/reference/models/Cat.yaml b/tests/spec/data/reference/models/Cat.yaml new file mode 100644 index 00000000..cb5d48c3 --- /dev/null +++ b/tests/spec/data/reference/models/Cat.yaml @@ -0,0 +1,11 @@ +type: object +description: "A Cat" +properties: + id: + type: integer + format: int64 + name: + type: string + description: the cats name + pet: + $ref: '../openapi_models.yaml#/components/schemas/Pet' diff --git a/tests/spec/data/reference/models/Pet.yaml b/tests/spec/data/reference/models/Pet.yaml new file mode 100644 index 00000000..169768fc --- /dev/null +++ b/tests/spec/data/reference/models/Pet.yaml @@ -0,0 +1,8 @@ +type: object +description: "A Pet" +properties: + id: + type: integer + format: int64 + cat: + $ref: '../openapi_models.yaml#/components/schemas/Cat' diff --git a/tests/spec/data/reference/openapi_models.yaml b/tests/spec/data/reference/openapi_models.yaml new file mode 100644 index 00000000..b7323dc0 --- /dev/null +++ b/tests/spec/data/reference/openapi_models.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.3 +info: + title: Link Example + version: 1.0.0 +components: + schemas: + Pet: + $ref: models/Pet.yaml + Cat: + $ref: models/Cat.yaml +paths: + '/pet': + get: + responses: + 200: + description: return a pet diff --git a/tests/spec/data/reference/structure.yaml b/tests/spec/data/reference/structure.yaml new file mode 100644 index 00000000..c7567bd8 --- /dev/null +++ b/tests/spec/data/reference/structure.yaml @@ -0,0 +1,9 @@ +openapi: 3.0.0 +info: + title: Ref Example + version: 1.0.0 +paths: + '/pet': + $ref: 'structure/paths.yml#/~1pet' + '/cat': + $ref: 'structure/paths.yml#/~1cat' diff --git a/tests/spec/data/reference/structure/paths.yml b/tests/spec/data/reference/structure/paths.yml new file mode 100644 index 00000000..46f4d6b3 --- /dev/null +++ b/tests/spec/data/reference/structure/paths.yml @@ -0,0 +1,5 @@ +/pet: + get: + $ref: 'paths/pet.yml#/get' +/cat: + $ref: 'paths/cat.yml' diff --git a/tests/spec/data/reference/structure/paths/cat.yml b/tests/spec/data/reference/structure/paths/cat.yml new file mode 100644 index 00000000..58e4dc53 --- /dev/null +++ b/tests/spec/data/reference/structure/paths/cat.yml @@ -0,0 +1,4 @@ +get: + responses: + 200: + description: return a cat diff --git a/tests/spec/data/reference/structure/paths/pet.yml b/tests/spec/data/reference/structure/paths/pet.yml new file mode 100644 index 00000000..af848e88 --- /dev/null +++ b/tests/spec/data/reference/structure/paths/pet.yml @@ -0,0 +1,4 @@ +get: + responses: + 200: + description: return a pet diff --git a/yarn.lock b/yarn.lock index d5d682a4..b829c619 100644 --- a/yarn.lock +++ b/yarn.lock @@ -164,9 +164,9 @@ classnames@^2.2.0, classnames@^2.2.3, classnames@^2.2.6: integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== clipboard@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.4.tgz#836dafd66cf0fea5d71ce5d5b0bf6e958009112d" - integrity sha512-Vw26VSLRpJfBofiVaFb/I8PVfdI1OxKcYShe6fm0sP/DtmiWQNCjhM/okTvdCo0G+lMMm1rMYbk4IK4x1X+kgQ== + version "2.0.6" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.6.tgz#52921296eec0fdf77ead1749421b21c968647376" + integrity sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg== dependencies: good-listener "^1.2.2" select "^1.1.2" @@ -557,9 +557,9 @@ inherits@2.0.4: integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== ini@^1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.7" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== invert-kv@^1.0.0: version "1.0.0" @@ -840,9 +840,9 @@ node-fetch-h2@^2.3.0: http2-client "^1.2.5" node-fetch@^2.3.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" - integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== node-readfiles@^0.2.0: version "0.2.0" @@ -1026,9 +1026,9 @@ polished@^3.0.3: "@babel/runtime" "^7.4.5" prismjs@^1.15.0: - version "1.17.1" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.17.1.tgz#e669fcbd4cdd873c35102881c33b14d0d68519be" - integrity sha512-PrEDJAFdUGbOP6xK/UsfkC5ghJsPJviKgnQOoxaDbBjwc8op68Quupwt1DeAFoG8GImPhiKXAvvsH7wDSLsu1Q== + version "1.21.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.21.0.tgz#36c086ec36b45319ec4218ee164c110f9fc015a3" + integrity sha512-uGdSIu1nk3kej2iZsLyDoJ7e9bnPzIgY0naW/HdknGj61zScaprVEVGHrPoXqI+M9sP0NDnTK2jpkvmldpuqDw== optionalDependencies: clipboard "^2.0.0"