diff --git a/Makefile b/Makefile index 4da06b3..e9b15d0 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ coverage: .php-openapi-covA .php-openapi-covB .php-openapi-covA: grep -rhPo '@covers .+' tests |cut -c 28- |sort > $@ .php-openapi-covB: - grep -rhPo 'class \w+' src/spec/ | awk '{print $$2}' |grep -v '^Type$$' | sort > $@ + grep -rhPo '^class \w+' src/spec/ | awk '{print $$2}' |grep -v '^Type$$' | sort > $@ .PHONY: all check-style fix-style install test coverage diff --git a/README.md b/README.md index 2bb21f6..aae2789 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ READ [OpenAPI](https://www.openapis.org/) 3.0.x YAML and JSON files and make the ## Usage +### Reading Specification information + Read OpenAPI spec from JSON: ```php @@ -46,6 +48,43 @@ foreach($openapi->paths as $path => $definition) { Object properties are exactly like in the [OpenAPI specification](https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.2.md#openapi-specification). You may also access additional properties added by specification extensions. +### Reading Specification Files and Resolving References + +In the above we have passed the raw JSON or YAML data to the Reader. In order to be able to resolve +references to external files that may exist in the specification files, we must provide the full context. + +```php +use cebe\openapi\Reader; +// an absolute URL or file path is needed to allow resolving internal references +$openapi = Reader::readFromJsonFile('https://www.example.com/api/openapi.json'); +$openapi = Reader::readFromYamlFile('https://www.example.com/api/openapi.yaml'); +``` + +If data has been loaded in a different way you can manually resolve references like this by giving a context: + +```php +$openapi->resolveReferences( + new \cebe\openapi\ReferenceContext($openapi, 'https://www.example.com/api/openapi.yaml') +); +``` + +> **Note:** Resolving references currently does not deal with references in referenced files, you have to call it multiple times to resolve these. + +### Validation + +The library provides simple validation operations, that check basic OpenAPI spec requirements. + +``` +// return `true` in case no errors have been found, `false` in case of errors. +$specValid = $openapi->validate(); +// after validation getErrors() can be used to retrieve the list of errors found. +$errors = $openapi->getErrors(); +``` + +> **Note:** Validation is done on a very basic level and is not complete. So a failing validation will show some errors, +> but the list of errors given may not be complete. Also a passing validation does not necessarily indicate a completely +> valid spec. + ## Completeness @@ -77,7 +116,7 @@ This library is currently work in progress, the following list tracks completene - [ ] [Runtime Expressions](https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.2.md#runtime-expressions) - [x] Header Object - [x] Tag Object - - [ ] Reference Object + - [x] Reference Object - [x] Schema Object - [x] load/read - [ ] validation diff --git a/src/Reader.php b/src/Reader.php index 20896b2..1c92d3c 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -7,22 +7,90 @@ namespace cebe\openapi; +use cebe\openapi\exceptions\TypeErrorException; +use cebe\openapi\exceptions\UnresolvableReferenceException; use cebe\openapi\spec\OpenApi; use Symfony\Component\Yaml\Yaml; /** - * + * Utility class to simplify reading JSON or YAML OpenAPI specs. * */ class Reader { + /** + * Populate OpenAPI spec object from JSON data. + * @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. + * @throws TypeErrorException in case invalid spec data is supplied. + */ public static function readFromJson(string $json, string $baseType = OpenApi::class): SpecObjectInterface { return new $baseType(json_decode($json, true)); } + /** + * Populate OpenAPI spec object from YAML data. + * @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. + * @throws TypeErrorException in case invalid spec data is supplied. + */ public static function readFromYaml(string $yaml, string $baseType = OpenApi::class): SpecObjectInterface { return new $baseType(Yaml::parse($yaml)); } + + /** + * Populate OpenAPI spec object from a JSON file. + * @param string $fileName the file name of the file to be read. + * If `$resolveReferences` is true (the default), this should be an absolute URL, a `file://` URI or + * an absolute path to allow resolving relative path references. + * @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. + * If `true`, all [[Reference]] objects will be replaced with their referenced spec objects by calling + * [[SpecObjectInterface::resolveReferences()]]. + * @return SpecObjectInterface|OpenApi the OpenApi object instance. + * @throws TypeErrorException in case invalid spec data is supplied. + * @throws UnresolvableReferenceException in case references could not be resolved. + */ + public static function readFromJsonFile(string $fileName, string $baseType = OpenApi::class, $resolveReferences = true): SpecObjectInterface + { + $spec = static::readFromJson(file_get_contents($fileName), $baseType); + if ($resolveReferences) { + $spec->resolveReferences(new ReferenceContext($spec, $fileName)); + } + return $spec; + } + + /** + * Populate OpenAPI spec object from YAML file. + * @param string $fileName the file name of the file to be read. + * If `$resolveReferences` is true (the default), this should be an absolute URL, a `file://` URI or + * an absolute path to allow resolving relative path references. + * @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. + * If `true`, all [[Reference]] objects will be replaced with their referenced spec objects by calling + * [[SpecObjectInterface::resolveReferences()]]. + * @return SpecObjectInterface|OpenApi the OpenApi object instance. + * @throws TypeErrorException in case invalid spec data is supplied. + * @throws UnresolvableReferenceException in case references could not be resolved. + */ + public static function readFromYamlFile(string $fileName, string $baseType = OpenApi::class, $resolveReferences = true): SpecObjectInterface + { + $spec = static::readFromYaml(file_get_contents($fileName), $baseType); + if ($resolveReferences) { + $spec->resolveReferences(new ReferenceContext($spec, $fileName)); + } + return $spec; + } } diff --git a/src/ReferenceContext.php b/src/ReferenceContext.php new file mode 100644 index 0000000..3257466 --- /dev/null +++ b/src/ReferenceContext.php @@ -0,0 +1,115 @@ + and contributors + * @license https://github.com/cebe/php-openapi/blob/master/LICENSE + */ + +namespace cebe\openapi; + +use cebe\openapi\exceptions\UnresolvableReferenceException; + +/** + * ReferenceContext represents a context in which references are resolved. + */ +class ReferenceContext +{ + /** + * @var SpecObjectInterface + */ + private $_baseSpec; + /** + * @var string + */ + private $_uri; + + /** + * ReferenceContext constructor. + * @param SpecObjectInterface $base the base object of the spec. + * @param string $uri the URI to the base object. + * @throws UnresolvableReferenceException in case an invalid or non-absolute URI is provided. + */ + public function __construct(SpecObjectInterface $base, string $uri) + { + $this->_baseSpec = $base; + $this->_uri = $this->normalizeUri($uri); + } + + /** + * @throws UnresolvableReferenceException in case an invalid or non-absolute URI is provided. + */ + private function normalizeUri($uri) + { + if (strpos($uri, '://') !== false) { + return $uri; + } + if (strncmp($uri, '/', 1) === 0) { + return "file://$uri"; + } + throw new UnresolvableReferenceException('Can not resolve references for a specification given as a relative path.'); + } + + /** + * @return mixed + */ + public function getBaseSpec(): SpecObjectInterface + { + return $this->_baseSpec; + } + + /** + * @return mixed + */ + public function getUri(): string + { + return $this->_uri; + } + + /** + * Resolve a relative URI to an absolute URI in the current context. + * @param string $uri + * @throws UnresolvableReferenceException + * @return string + */ + public function resolveRelativeUri(string $uri): string + { + $parts = parse_url($uri); + 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']; + } + if (isset($parts['path'])) { + // relative path + return dirname($baseUri) . '/' . $parts['path']; + } + + throw new UnresolvableReferenceException("Invalid URI: '$uri'"); + } + + $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']; + } elseif (isset($parts['path'])) { + $absoluteUri .= rtrim(dirname($baseParts['path'] ?? ''), '/') . '/' . $parts['path']; + } + return $absoluteUri + . (isset($parts['query']) ? '?' . $parts['query'] : '') + . (isset($parts['fragment']) ? '#' . $parts['fragment'] : ''); + } +} diff --git a/src/SpecBaseObject.php b/src/SpecBaseObject.php index 2bcbb8d..b2c7248 100644 --- a/src/SpecBaseObject.php +++ b/src/SpecBaseObject.php @@ -10,6 +10,7 @@ use cebe\openapi\exceptions\ReadonlyPropertyException; use cebe\openapi\exceptions\TypeErrorException; use cebe\openapi\exceptions\UnknownPropertyException; +use cebe\openapi\spec\Reference; use cebe\openapi\spec\Type; /** @@ -73,7 +74,6 @@ public function __construct(array $data) } elseif ($type[0] === Type::ANY || $type[0] === Type::BOOLEAN || $type[0] === Type::INTEGER) { // TODO simplify handling of scalar types $this->_properties[$property][] = $item; } else { - // TODO implement reference objects $this->_properties[$property][] = $this->instantiate($type[0], $item); } } @@ -93,7 +93,6 @@ public function __construct(array $data) } elseif ($type[1] === Type::ANY || $type[1] === Type::BOOLEAN || $type[1] === Type::INTEGER) { // TODO simplify handling of scalar types $this->_properties[$property][$key] = $item; } else { - // TODO implement reference objects $this->_properties[$property][$key] = $this->instantiate($type[1], $item); } } @@ -114,6 +113,9 @@ public function __construct(array $data) */ private function instantiate($type, $data) { + if (isset($data['$ref'])) { + return new Reference($data, $type); + } try { return new $type($data); } catch (\TypeError $e) { @@ -239,4 +241,27 @@ public function __unset($name) { throw new ReadonlyPropertyException('Unsetting read-only property: ' . \get_class($this) . '::' . $name); } + + /** + * Resolves all Reference Objects in this object and replaces them with their resolution. + * @throws exceptions\UnresolvableReferenceException in case resolving a reference fails. + */ + public function resolveReferences(ReferenceContext $context) + { + foreach ($this->_properties as $property => $value) { + if ($value instanceof Reference) { + $this->_properties[$property] = $value->resolve($context); + } elseif ($value instanceof SpecObjectInterface) { + $value->resolveReferences($context); + } elseif (is_array($value)) { + foreach ($value as $k => $item) { + if ($item instanceof Reference) { + $this->_properties[$property][$k] = $item->resolve($context); + } elseif ($item instanceof SpecObjectInterface) { + $item->resolveReferences($context); + } + } + } + } + } } diff --git a/src/SpecObjectInterface.php b/src/SpecObjectInterface.php index 85ebbf6..aa0779f 100644 --- a/src/SpecObjectInterface.php +++ b/src/SpecObjectInterface.php @@ -30,4 +30,9 @@ public function validate(): bool; * @see validate() */ public function getErrors(): array; + + /** + * Resolves all Reference Objects in this object and replaces them with their resolution. + */ + public function resolveReferences(ReferenceContext $context); } diff --git a/src/exceptions/UnresolvableReferenceException.php b/src/exceptions/UnresolvableReferenceException.php new file mode 100644 index 0000000..8559985 --- /dev/null +++ b/src/exceptions/UnresolvableReferenceException.php @@ -0,0 +1,16 @@ + and contributors + * @license https://github.com/cebe/php-openapi/blob/master/LICENSE + */ + +namespace cebe\openapi\exceptions; + +/** + * + * + */ +class UnresolvableReferenceException extends \Exception +{ +} diff --git a/src/spec/Callback.php b/src/spec/Callback.php index e93085e..3b355e2 100644 --- a/src/spec/Callback.php +++ b/src/spec/Callback.php @@ -8,6 +8,8 @@ namespace cebe\openapi\spec; use cebe\openapi\exceptions\TypeErrorException; +use cebe\openapi\exceptions\UnresolvableReferenceException; +use cebe\openapi\ReferenceContext; use cebe\openapi\SpecObjectInterface; /** @@ -75,4 +77,15 @@ public function getErrors(): array $pathItemErrors = $this->_pathItem === null ? [] : $this->_pathItem->getErrors(); return array_merge($this->_errors, $pathItemErrors); } + + /** + * Resolves all Reference Objects in this object and replaces them with their resolution. + * @throws UnresolvableReferenceException + */ + public function resolveReferences(ReferenceContext $context) + { + if ($this->_pathItem !== null) { + $this->_pathItem->resolveReferences($context); + } + } } diff --git a/src/spec/Components.php b/src/spec/Components.php index 4304887..6fd1b4d 100644 --- a/src/spec/Components.php +++ b/src/spec/Components.php @@ -37,7 +37,7 @@ class Components extends SpecBaseObject protected function attributes(): array { return [ - 'schemas' => [Type::STRING, Schema::class],// TODO implement support for reference + 'schemas' => [Type::STRING, Schema::class], 'responses' => [Type::STRING, Response::class], 'parameters' => [Type::STRING, Parameter::class], 'examples' => [Type::STRING, Example::class], diff --git a/src/spec/MediaType.php b/src/spec/MediaType.php index 338a0a1..2818d29 100644 --- a/src/spec/MediaType.php +++ b/src/spec/MediaType.php @@ -27,9 +27,9 @@ class MediaType extends SpecBaseObject protected function attributes(): array { return [ - 'schema' => Schema::class, // TODO support Reference + 'schema' => Schema::class, 'example' => Type::ANY, - 'examples' => [Type::STRING, Example::class], // TODO support Reference + 'examples' => [Type::STRING, Example::class], 'encoding' => [Type::STRING, Encoding::class], ]; } diff --git a/src/spec/Operation.php b/src/spec/Operation.php index 156e7cd..db1b524 100644 --- a/src/spec/Operation.php +++ b/src/spec/Operation.php @@ -41,10 +41,10 @@ protected function attributes(): array 'description' => Type::STRING, 'externalDocs' => ExternalDocumentation::class, 'operationId' => Type::STRING, - 'parameters' => [Parameter::class],// TODO reference + 'parameters' => [Parameter::class], 'requestBody' => RequestBody::class, 'responses' => Responses::class, - 'callbacks' => [Type::STRING, Callback::class],// TODO reference + 'callbacks' => [Type::STRING, Callback::class], 'deprecated' => Type::BOOLEAN, 'security' => [SecurityRequirement::class], 'servers' => [Server::class], diff --git a/src/spec/PathItem.php b/src/spec/PathItem.php index 63170a3..244b27d 100644 --- a/src/spec/PathItem.php +++ b/src/spec/PathItem.php @@ -50,7 +50,7 @@ protected function attributes(): array 'patch' => Operation::class, 'trace' => Operation::class, 'servers' => [Server::class], - 'parameters' => [Parameter::class], // @TODO Reference::class + 'parameters' => [Parameter::class], ]; } diff --git a/src/spec/Paths.php b/src/spec/Paths.php index 3defcf9..cf434bf 100644 --- a/src/spec/Paths.php +++ b/src/spec/Paths.php @@ -10,6 +10,9 @@ use ArrayAccess; use ArrayIterator; use cebe\openapi\exceptions\ReadonlyPropertyException; +use cebe\openapi\exceptions\TypeErrorException; +use cebe\openapi\exceptions\UnresolvableReferenceException; +use cebe\openapi\ReferenceContext; use cebe\openapi\SpecObjectInterface; use Countable; use IteratorAggregate; @@ -37,11 +40,11 @@ class Paths implements SpecObjectInterface, ArrayAccess, Countable, IteratorAggr /** * Create an object from spec data. * @param array $data spec data read from YAML or JSON + * @throws TypeErrorException in case invalid data is supplied. */ public function __construct(array $data) { foreach ($data as $path => $object) { - // TODO support reference if ($object === null) { $this->_paths[$path] = null; } else { @@ -181,4 +184,15 @@ public function getIterator() { return new ArrayIterator($this->_paths); } + + /** + * Resolves all Reference Objects in this object and replaces them with their resolution. + * @throws UnresolvableReferenceException + */ + public function resolveReferences(ReferenceContext $context) + { + foreach ($this->_paths as $key => $path) { + $path->resolveReferences($context); + } + } } diff --git a/src/spec/Reference.php b/src/spec/Reference.php new file mode 100644 index 0000000..d385308 --- /dev/null +++ b/src/spec/Reference.php @@ -0,0 +1,190 @@ + and contributors + * @license https://github.com/cebe/php-openapi/blob/master/LICENSE + */ + +namespace cebe\openapi\spec; + +use cebe\openapi\exceptions\TypeErrorException; +use cebe\openapi\exceptions\UnresolvableReferenceException; +use cebe\openapi\ReferenceContext; +use cebe\openapi\SpecObjectInterface; +use Symfony\Component\Yaml\Yaml; + +/** + * Reference Object + * + * @link https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.2.md#referenceObject + * @link https://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03 + * @link https://tools.ietf.org/html/rfc6901 + * + */ +class Reference implements SpecObjectInterface +{ + private $_to; + private $_ref; + + private $_errors = []; + + /** + * Create an object from spec data. + * @param array $data spec data read from YAML or JSON + * @param string $to class name of the type referenced by this Reference + * @throws TypeErrorException in case invalid data is supplied. + */ + public function __construct(array $data, string $to = null) + { + if (!isset($data['$ref'])) { + throw new TypeErrorException( + "Unable to instantiate Reference Object with data '" . print_r($data, true) . "'" + ); + } + if ($to !== null && !is_subclass_of($to, SpecObjectInterface::class, true)) { + throw new TypeErrorException( + "Unable to instantiate Reference Object, Referenced Class type must implement SpecObjectInterface" + ); + } + $this->_to = $to; + if (count($data) !== 1) { + $this->_errors[] = 'Reference: additional properties are given. Only $ref should be set in a Reference Object.'; + } + if (!is_string($data['$ref'])) { + $this->_errors[] = 'Reference: value of $ref must be a string.'; + } + if (!empty($this->_errors)) { + return; + } + $this->_ref = $data['$ref']; + } + + /** + * Validate object data according to OpenAPI spec. + * @return bool whether the loaded data is valid according to OpenAPI spec + * @see getErrors() + */ + public function validate(): bool + { + return empty($this->_errors); + } + + /** + * @return string[] list of validation errors according to OpenAPI spec. + * @see validate() + */ + public function getErrors(): array + { + return $this->_errors; + } + + /** + * @return string the reference string. + */ + public function getReference() + { + return $this->_ref; + } + + /** + * @param ReferenceContext $context + * @return SpecObjectInterface the resolved spec type. + * @throws UnresolvableReferenceException in case of errors. + */ + public function resolve(ReferenceContext $context) + { + if (($pos = strpos($this->_ref, '#')) === 0) { + // resolve in current document + $jsonPointer = substr($this->_ref, 1); + // TODO type error if resolved object does not match $this->_to ? + return $this->resolveJsonPointer($jsonPointer, $context->getBaseSpec()); + } + + $file = ($pos === false) ? $this->_ref : substr($this->_ref, 0, $pos); + $file = $context->resolveRelativeUri($file); + $jsonPointer = substr($this->_ref, $pos + 1); + + // TODO could be a good idea to cache loaded files in current context to avoid loading the same files over and over again + $fileContent = $this->fetchReferencedFile($file); + $referencedData = $this->resolveJsonPointer($jsonPointer, $fileContent); + + /** @var $referencedObject SpecObjectInterface */ + $referencedObject = new $this->_to($referencedData); + if ($jsonPointer === '') { + $referencedObject->resolveReferences(new ReferenceContext($referencedObject, $file)); + } else { + // TODO resolving references recursively does not work as we do not know the base type of the file at this point +// $referencedObject->resolveReferences(new ReferenceContext($referencedObject, $file)); + } + + return $referencedObject; + } + + private function resolveJsonPointer($jsonPointer, $currentReference) + { + if ($jsonPointer === '') { + // empty pointer references the whole document + return $currentReference; + } + $pointerParts = explode('/', ltrim($jsonPointer, '/')); + foreach ($pointerParts as $part) { + $part = strtr($part, [ + '~1' => '/', + '~0' => '~', + ]); + + if (is_array($currentReference) || $currentReference instanceof \ArrayAccess) { + if (!isset($currentReference[$part])) { + throw new UnresolvableReferenceException( + "Failed to resolve Reference '$this->_ref' to $this->_to Object: path $jsonPointer does not exist in referenced object." + ); + } + $currentReference = $currentReference[$part]; + } elseif (is_object($currentReference)) { + if (!isset($currentReference->$part)) { + throw new UnresolvableReferenceException( + "Failed to resolve Reference '$this->_ref' to $this->_to Object: path $jsonPointer does not exist in referenced object." + ); + } + $currentReference = $currentReference->$part; + } else { + throw new UnresolvableReferenceException( + "Failed to resolve Reference '$this->_ref' to $this->_to Object: path $jsonPointer does not exist in referenced object." + ); + } + } + + return $currentReference; + } + + /** + * @throws UnresolvableReferenceException + */ + private function fetchReferencedFile($uri) + { + try { + $content = file_get_contents($uri); + // TODO lazy content detection, should probably be improved + if (strpos(ltrim($content), '{') === 0) { + return json_decode($content, true); + } else { + return Yaml::parse($content); + } + } catch (\Throwable $e) { + throw new UnresolvableReferenceException( + "Failed to resolve Reference '$this->_ref' to $this->_to Object: " . $e->getMessage(), + $e->getCode(), + $e + ); + } + } + + /** + * Resolves all Reference Objects in this object and replaces them with their resolution. + * @throws UnresolvableReferenceException + */ + public function resolveReferences(ReferenceContext $context) + { + throw new UnresolvableReferenceException('Cyclic reference detected, resolveReferences() called on a Reference Object.'); + } +} diff --git a/src/spec/Response.php b/src/spec/Response.php index 35c6987..9520859 100644 --- a/src/spec/Response.php +++ b/src/spec/Response.php @@ -27,7 +27,7 @@ class Response extends SpecBaseObject protected function attributes(): array { return [ - 'description' => Type::STRING,// TODO implement support for reference + 'description' => Type::STRING, 'headers' => [Type::STRING, Header::class], 'content' => [Type::STRING, MediaType::class], 'links' => [Type::STRING, Link::class], diff --git a/src/spec/Responses.php b/src/spec/Responses.php index 891bc97..6aafc6e 100644 --- a/src/spec/Responses.php +++ b/src/spec/Responses.php @@ -10,6 +10,8 @@ use ArrayAccess; use ArrayIterator; use cebe\openapi\exceptions\ReadonlyPropertyException; +use cebe\openapi\exceptions\UnresolvableReferenceException; +use cebe\openapi\ReferenceContext; use cebe\openapi\SpecObjectInterface; use Countable; use IteratorAggregate; @@ -22,6 +24,9 @@ */ class Responses implements SpecObjectInterface, ArrayAccess, Countable, IteratorAggregate { + /** + * @var Response[]|Reference[] + */ private $_responses = []; private $_errors = []; @@ -171,4 +176,19 @@ public function getIterator() { return new ArrayIterator($this->_responses); } + + /** + * Resolves all Reference Objects in this object and replaces them with their resolution. + * @throws UnresolvableReferenceException + */ + public function resolveReferences(ReferenceContext $context) + { + foreach ($this->_responses as $k => $response) { + if ($response instanceof Reference) { + $this->_responses[$k] = $response->resolve($context); + } else { + $response->resolveReferences($context); + } + } + } } diff --git a/src/spec/Schema.php b/src/spec/Schema.php index 688d89c..7138526 100644 --- a/src/spec/Schema.php +++ b/src/spec/Schema.php @@ -71,7 +71,7 @@ protected function attributes(): array { return [ 'type' => Type::STRING, - 'allOf' => [Schema::class], // TODO allow reference + 'allOf' => [Schema::class], 'oneOf' => [Schema::class], 'anyOf' => [Schema::class], 'not' => Schema::class, diff --git a/tests/ReferenceContextTest.php b/tests/ReferenceContextTest.php new file mode 100644 index 0000000..4934da9 --- /dev/null +++ b/tests/ReferenceContextTest.php @@ -0,0 +1,76 @@ +assertEquals($expected, $context->resolveRelativeUri($referencedUri)); + } + +} diff --git a/tests/spec/MediaTypeTest.php b/tests/spec/MediaTypeTest.php index 5f41583..e0c5d8d 100644 --- a/tests/spec/MediaTypeTest.php +++ b/tests/spec/MediaTypeTest.php @@ -3,6 +3,7 @@ use cebe\openapi\Reader; use cebe\openapi\spec\MediaType; use cebe\openapi\spec\Example; +use cebe\openapi\spec\Reference; /** * @covers \cebe\openapi\spec\MediaType @@ -42,7 +43,7 @@ public function testRead() $this->assertEquals([], $mediaType->getErrors()); $this->assertTrue($result); - //$this->assertEquals('schema', $mediaType->name);// TODO support for reference + $this->assertInstanceOf(Reference::class, $mediaType->schema); $this->assertInternalType('array', $mediaType->examples); $this->assertCount(3, $mediaType->examples); $this->assertArrayHasKey('cat', $mediaType->examples); @@ -50,7 +51,7 @@ public function testRead() $this->assertArrayHasKey('frog', $mediaType->examples); $this->assertInstanceOf(Example::class, $mediaType->examples['cat']); $this->assertInstanceOf(Example::class, $mediaType->examples['dog']); - $this->assertInstanceOf(Example::class, $mediaType->examples['frog']); + $this->assertInstanceOf(Reference::class, $mediaType->examples['frog']); $this->assertEquals('An example of a cat', $mediaType->examples['cat']->summary); $expectedCat = [ // TODO we might actually expect this to be an object of stdClass diff --git a/tests/spec/ReferenceTest.php b/tests/spec/ReferenceTest.php new file mode 100644 index 0000000..2cd0df4 --- /dev/null +++ b/tests/spec/ReferenceTest.php @@ -0,0 +1,167 @@ +validate(); + $this->assertEquals([], $openapi->getErrors()); + $this->assertTrue($result); + + /** @var $response \cebe\openapi\spec\Response */ + $response = $openapi->paths->getPath('/pet')->get->responses['200']; + $this->assertInstanceOf(Reference::class, $response->content['application/json']->schema); + $this->assertInstanceOf(Reference::class, $response->content['application/json']->examples['frog']); + + $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, 'file:///tmp/openapi.yaml')); + + $this->assertInstanceOf(Schema::class, $refSchema = $response->content['application/json']->schema); + $this->assertInstanceOf(Example::class, $refExample = $response->content['application/json']->examples['frog']); + + $this->assertSame($openapi->components->schemas['Pet'], $refSchema); + $this->assertSame($openapi->components->examples['frog-example'], $refExample); + } + + public function testResolveCyclicReferenceInDocument() + { + /** @var $openapi OpenApi */ + $openapi = Reader::readFromYaml(<<<'YAML' +openapi: 3.0.0 +info: + title: test api + version: 1.0.0 +components: + schemas: + Pet: + type: object + properties: + id: + type: array + items: + $ref: "#/components/schemas/Pet" + example: + $ref: "#/components/examples/frog-example" + examples: + frog-example: + description: a frog +paths: + '/pet': + get: + responses: + 200: + description: return a pet + content: + 'application/json': + schema: + $ref: "#/components/schemas/Pet" + examples: + frog: + $ref: "#/components/examples/frog-example" +YAML + , OpenApi::class); + + $result = $openapi->validate(); + $this->assertEquals([], $openapi->getErrors()); + $this->assertTrue($result); + + /** @var $response \cebe\openapi\spec\Response */ + $response = $openapi->paths->getPath('/pet')->get->responses['200']; + $this->assertInstanceOf(Reference::class, $response->content['application/json']->schema); + $this->assertInstanceOf(Reference::class, $response->content['application/json']->examples['frog']); + +// $this->expectException(\cebe\openapi\exceptions\UnresolvableReferenceException::class); + $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, 'file:///tmp/openapi.yaml')); + + $this->assertInstanceOf(Schema::class, $petItems = $openapi->components->schemas['Pet']->properties['id']->items); + $this->assertInstanceOf(Schema::class, $refSchema = $response->content['application/json']->schema); + $this->assertInstanceOf(Example::class, $refExample = $response->content['application/json']->examples['frog']); + + $this->assertSame($openapi->components->schemas['Pet'], $petItems); + $this->assertSame($openapi->components->schemas['Pet'], $refSchema); + $this->assertSame($openapi->components->examples['frog-example'], $refExample); + } + + public function testResolveFile() + { + $file = __DIR__ . '/data/reference/base.yaml'; + /** @var $openapi OpenApi */ + $openapi = Reader::readFromYaml(str_replace('##ABSOLUTEPATH##', 'file://' . dirname($file), file_get_contents($file))); + + $result = $openapi->validate(); + $this->assertEquals([], $openapi->getErrors()); + $this->assertTrue($result); + + $this->assertInstanceOf(Reference::class, $petItems = $openapi->components->schemas['Pet']); + $this->assertInstanceOf(Reference::class, $petItems = $openapi->components->schemas['Dog']); + + $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, $file)); + + $this->assertInstanceOf(Schema::class, $petItems = $openapi->components->schemas['Pet']); + $this->assertInstanceOf(Schema::class, $petItems = $openapi->components->schemas['Dog']); + $this->assertArrayHasKey('id', $openapi->components->schemas['Pet']->properties); + $this->assertArrayHasKey('name', $openapi->components->schemas['Dog']->properties); + } + + public function testResolveFileHttp() + { + $file = 'https://raw.githubusercontent.com/cebe/php-openapi/290389bbd337cf4d70ecedfd3a3d886715e19552/tests/spec/data/reference/base.yaml'; + /** @var $openapi OpenApi */ + $openapi = Reader::readFromYaml(str_replace('##ABSOLUTEPATH##', dirname($file), file_get_contents($file))); + + $result = $openapi->validate(); + $this->assertEquals([], $openapi->getErrors()); + $this->assertTrue($result); + + $this->assertInstanceOf(Reference::class, $petItems = $openapi->components->schemas['Pet']); + $this->assertInstanceOf(Reference::class, $petItems = $openapi->components->schemas['Dog']); + + $openapi->resolveReferences(new \cebe\openapi\ReferenceContext($openapi, $file)); + + $this->assertInstanceOf(Schema::class, $petItems = $openapi->components->schemas['Pet']); + $this->assertInstanceOf(Schema::class, $petItems = $openapi->components->schemas['Dog']); + $this->assertArrayHasKey('id', $openapi->components->schemas['Pet']->properties); + $this->assertArrayHasKey('name', $openapi->components->schemas['Dog']->properties); + } + +} diff --git a/tests/spec/data/reference/base.yaml b/tests/spec/data/reference/base.yaml new file mode 100644 index 0000000..0a3fafa --- /dev/null +++ b/tests/spec/data/reference/base.yaml @@ -0,0 +1,16 @@ +openapi: 3.0.0 +info: + title: Link Example + version: 1.0.0 +components: + schemas: + Pet: + $ref: definitions.yaml#/Pet + Dog: + $ref: ##ABSOLUTEPATH##/definitions.yaml#/Dog +paths: + '/pet': + get: + responses: + 200: + description: return a pet diff --git a/tests/spec/data/reference/definitions.yaml b/tests/spec/data/reference/definitions.yaml new file mode 100644 index 0000000..3f6c7f3 --- /dev/null +++ b/tests/spec/data/reference/definitions.yaml @@ -0,0 +1,11 @@ +Pet: + type: object + properties: + id: + type: integer + format: int64 +Dog: + type: object + properties: + name: + type: string