From 59a58e9df141f9dc4e22ea2063e3eac2ac1056fc Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Fri, 16 Nov 2018 17:47:15 +0100 Subject: [PATCH 1/8] WIP references --- Makefile | 2 +- README.md | 2 +- src/ReferenceContext.php | 64 +++++++ src/SpecBaseObject.php | 29 ++- src/SpecObjectInterface.php | 5 + .../UnresolvableReferenceException.php | 16 ++ src/spec/Callback.php | 13 ++ src/spec/Paths.php | 16 +- src/spec/Reference.php | 176 ++++++++++++++++++ src/spec/Responses.php | 20 ++ tests/spec/MediaTypeTest.php | 3 +- tests/spec/ReferenceTest.php | 125 +++++++++++++ 12 files changed, 465 insertions(+), 6 deletions(-) create mode 100644 src/ReferenceContext.php create mode 100644 src/exceptions/UnresolvableReferenceException.php create mode 100644 src/spec/Reference.php create mode 100644 tests/spec/ReferenceTest.php 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..eebdbae 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,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/ReferenceContext.php b/src/ReferenceContext.php new file mode 100644 index 0000000..9a6b903 --- /dev/null +++ b/src/ReferenceContext.php @@ -0,0 +1,64 @@ + 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; + + + public function __construct(SpecObjectInterface $base, string $uri) + { + $this->_baseSpec = $base; + $this->_uri = $uri; + } + + /** + * @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'], $parts['host'])) { + // TODO resolve relative URL + throw new UnresolvableReferenceException('Relative URLs are currently not supported in Reference.'); + } + + return $uri; + } +} 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/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..892e33a --- /dev/null +++ b/src/spec/Reference.php @@ -0,0 +1,176 @@ + 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\Reader; +use cebe\openapi\ReferenceContext; +use cebe\openapi\SpecBaseObject; +use cebe\openapi\SpecObjectInterface; + +/** + * 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) + { + $currentReference = $context->getBaseSpec(); + if (($pos = strpos($this->_ref, '#')) === 0) { + // resolve in current document + $jsonPointer = substr($this->_ref, 1); + } else { + $file = ($pos === false) ? $this->_ref : substr($this->_ref, 0, $pos); + $jsonPointer = substr($this->_ref, $pos + 1); + $currentReference = $this->fetchReferencedFile($file, $context); + // TODO could be a good idea to cache loaded files in current context to avoid loading the same files over and over again + $currentReference->resolveReferences(new ReferenceContext($currentReference, $file)); + } + + // resolve JSON Pointer + 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, ReferenceContext $context): SpecObjectInterface + { + $uri = $context->resolveRelativeUri($uri); + try { + $content = file_get_contents($uri); + // TODO lazy content detection, should probably be improved + if (strpos(ltrim($content), '{') === 0) { + return Reader::readFromJson($content, $this->_to); + } else { + return Reader::readFromYaml($content, $this->_to); + } + } 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/Responses.php b/src/spec/Responses.php index 891bc97..97de619 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/tests/spec/MediaTypeTest.php b/tests/spec/MediaTypeTest.php index 5f41583..8cdeee8 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 @@ -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..b2b5947 --- /dev/null +++ b/tests/spec/ReferenceTest.php @@ -0,0 +1,125 @@ +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); + } + +} From 80cdd3c47c7cbad1002a2d11500e3501f29ab82f Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Thu, 22 Nov 2018 11:19:38 +0100 Subject: [PATCH 2/8] fix code style --- src/ReferenceContext.php | 1 + src/spec/Responses.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ReferenceContext.php b/src/ReferenceContext.php index 9a6b903..615e484 100644 --- a/src/ReferenceContext.php +++ b/src/ReferenceContext.php @@ -6,6 +6,7 @@ */ namespace cebe\openapi; + use cebe\openapi\exceptions\UnresolvableReferenceException; /** diff --git a/src/spec/Responses.php b/src/spec/Responses.php index 97de619..6aafc6e 100644 --- a/src/spec/Responses.php +++ b/src/spec/Responses.php @@ -183,7 +183,7 @@ public function getIterator() */ public function resolveReferences(ReferenceContext $context) { - foreach($this->_responses as $k => $response) { + foreach ($this->_responses as $k => $response) { if ($response instanceof Reference) { $this->_responses[$k] = $response->resolve($context); } else { From 290389bbd337cf4d70ecedfd3a3d886715e19552 Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Thu, 22 Nov 2018 12:31:58 +0100 Subject: [PATCH 3/8] references to other files --- README.md | 37 ++++++++++ src/Reader.php | 81 +++++++++++++++++++++- src/spec/Reference.php | 1 - tests/spec/ReferenceTest.php | 42 +++++++++++ tests/spec/data/reference/base.yaml | 16 +++++ tests/spec/data/reference/definitions.yaml | 11 +++ 6 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 tests/spec/data/reference/base.yaml create mode 100644 tests/spec/data/reference/definitions.yaml diff --git a/README.md b/README.md index eebdbae..74ed8de 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,41 @@ 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') +); +``` + +### 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 diff --git a/src/Reader.php b/src/Reader.php index 20896b2..3680a4e 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -7,22 +7,101 @@ 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, static::fileName2Uri($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, static::fileName2Uri($fileName))); + } + return $spec; + } + + private static function fileName2Uri($fileName) + { + if (strpos($fileName, '://') !== false) { + return $fileName; + } + if (strncmp($fileName, '/', 1) === 0) { + return "file://$fileName"; + } + throw new UnresolvableReferenceException('Can not resolve references for a specification given as a relative path.'); + } } diff --git a/src/spec/Reference.php b/src/spec/Reference.php index 892e33a..0fcc7e8 100644 --- a/src/spec/Reference.php +++ b/src/spec/Reference.php @@ -11,7 +11,6 @@ use cebe\openapi\exceptions\UnresolvableReferenceException; use cebe\openapi\Reader; use cebe\openapi\ReferenceContext; -use cebe\openapi\SpecBaseObject; use cebe\openapi\SpecObjectInterface; /** diff --git a/tests/spec/ReferenceTest.php b/tests/spec/ReferenceTest.php index b2b5947..621489c 100644 --- a/tests/spec/ReferenceTest.php +++ b/tests/spec/ReferenceTest.php @@ -122,4 +122,46 @@ public function testResolveCyclicReferenceInDocument() $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://' . $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/master/tests/spec/data/reference/base.yaml'; + /** @var $openapi OpenApi */ + $openapi = Reader::readFromYaml(str_replace('##ABSOLUTEPATH##', 'https://' . 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 From 1617745b2d1889c6481c912d4865b774cd1b6409 Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Thu, 22 Nov 2018 13:31:01 +0100 Subject: [PATCH 4/8] resolve relative URLs --- src/Reader.php | 15 +------ src/ReferenceContext.php | 63 +++++++++++++++++++++++++--- tests/ReferenceContextTest.php | 76 ++++++++++++++++++++++++++++++++++ tests/spec/ReferenceTest.php | 2 +- 4 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 tests/ReferenceContextTest.php diff --git a/src/Reader.php b/src/Reader.php index 3680a4e..1c92d3c 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -65,7 +65,7 @@ public static function readFromJsonFile(string $fileName, string $baseType = Ope { $spec = static::readFromJson(file_get_contents($fileName), $baseType); if ($resolveReferences) { - $spec->resolveReferences(new ReferenceContext($spec, static::fileName2Uri($fileName))); + $spec->resolveReferences(new ReferenceContext($spec, $fileName)); } return $spec; } @@ -89,19 +89,8 @@ public static function readFromYamlFile(string $fileName, string $baseType = Ope { $spec = static::readFromYaml(file_get_contents($fileName), $baseType); if ($resolveReferences) { - $spec->resolveReferences(new ReferenceContext($spec, static::fileName2Uri($fileName))); + $spec->resolveReferences(new ReferenceContext($spec, $fileName)); } return $spec; } - - private static function fileName2Uri($fileName) - { - if (strpos($fileName, '://') !== false) { - return $fileName; - } - if (strncmp($fileName, '/', 1) === 0) { - return "file://$fileName"; - } - throw new UnresolvableReferenceException('Can not resolve references for a specification given as a relative path.'); - } } diff --git a/src/ReferenceContext.php b/src/ReferenceContext.php index 615e484..541cb59 100644 --- a/src/ReferenceContext.php +++ b/src/ReferenceContext.php @@ -23,11 +23,30 @@ class ReferenceContext */ 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 = $uri; + $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.'); } /** @@ -55,11 +74,43 @@ public function getUri(): string public function resolveRelativeUri(string $uri): string { $parts = parse_url($uri); - if (!isset($parts['scheme'], $parts['host'])) { - // TODO resolve relative URL - throw new UnresolvableReferenceException('Relative URLs are currently not supported in Reference.'); + if (isset($parts['scheme'])) { + // absolute URL + return $uri; } - 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']; + } else if (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/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/ReferenceTest.php b/tests/spec/ReferenceTest.php index 621489c..8fdd8f4 100644 --- a/tests/spec/ReferenceTest.php +++ b/tests/spec/ReferenceTest.php @@ -145,7 +145,7 @@ public function testResolveFile() public function testResolveFileHttp() { - $file = 'https://raw.githubusercontent.com/cebe/php-openapi/master/tests/spec/data/reference/base.yaml'; + $file = 'https://raw.githubusercontent.com/cebe/php-openapi/290389bbd337cf4d70ecedfd3a3d886715e19552/tests/spec/data/reference/base.yaml'; /** @var $openapi OpenApi */ $openapi = Reader::readFromYaml(str_replace('##ABSOLUTEPATH##', 'https://' . dirname($file), file_get_contents($file))); From b145a0d30e481ff9f9ece0ec050ce6440799bd9f Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Thu, 22 Nov 2018 14:08:44 +0100 Subject: [PATCH 5/8] resolving recursive references partially --- src/spec/Reference.php | 39 +++++++++++++++++++++++++----------- tests/spec/ReferenceTest.php | 4 ++-- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/spec/Reference.php b/src/spec/Reference.php index 0fcc7e8..d385308 100644 --- a/src/spec/Reference.php +++ b/src/spec/Reference.php @@ -9,9 +9,9 @@ use cebe\openapi\exceptions\TypeErrorException; use cebe\openapi\exceptions\UnresolvableReferenceException; -use cebe\openapi\Reader; use cebe\openapi\ReferenceContext; use cebe\openapi\SpecObjectInterface; +use Symfony\Component\Yaml\Yaml; /** * Reference Object @@ -93,19 +93,35 @@ public function getReference() */ public function resolve(ReferenceContext $context) { - $currentReference = $context->getBaseSpec(); 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 { - $file = ($pos === false) ? $this->_ref : substr($this->_ref, 0, $pos); - $jsonPointer = substr($this->_ref, $pos + 1); - $currentReference = $this->fetchReferencedFile($file, $context); - // TODO could be a good idea to cache loaded files in current context to avoid loading the same files over and over again - $currentReference->resolveReferences(new ReferenceContext($currentReference, $file)); + // 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)); } - // resolve JSON Pointer + return $referencedObject; + } + + private function resolveJsonPointer($jsonPointer, $currentReference) + { if ($jsonPointer === '') { // empty pointer references the whole document return $currentReference; @@ -144,16 +160,15 @@ public function resolve(ReferenceContext $context) /** * @throws UnresolvableReferenceException */ - private function fetchReferencedFile($uri, ReferenceContext $context): SpecObjectInterface + private function fetchReferencedFile($uri) { - $uri = $context->resolveRelativeUri($uri); try { $content = file_get_contents($uri); // TODO lazy content detection, should probably be improved if (strpos(ltrim($content), '{') === 0) { - return Reader::readFromJson($content, $this->_to); + return json_decode($content, true); } else { - return Reader::readFromYaml($content, $this->_to); + return Yaml::parse($content); } } catch (\Throwable $e) { throw new UnresolvableReferenceException( diff --git a/tests/spec/ReferenceTest.php b/tests/spec/ReferenceTest.php index 8fdd8f4..2cd0df4 100644 --- a/tests/spec/ReferenceTest.php +++ b/tests/spec/ReferenceTest.php @@ -135,7 +135,7 @@ public function testResolveFile() $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://' . $file)); + $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']); @@ -147,7 +147,7 @@ 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##', 'https://' . dirname($file), file_get_contents($file))); + $openapi = Reader::readFromYaml(str_replace('##ABSOLUTEPATH##', dirname($file), file_get_contents($file))); $result = $openapi->validate(); $this->assertEquals([], $openapi->getErrors()); From 320be70c420729b9828c896f5a836baabaebfdee Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Thu, 22 Nov 2018 14:11:51 +0100 Subject: [PATCH 6/8] note about references in referenced files --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 74ed8de..aae2789 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ $openapi->resolveReferences( ); ``` +> **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. From fb5f1c4dcd48f775986c98ea6451f62681a8a3ea Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Thu, 22 Nov 2018 14:27:23 +0100 Subject: [PATCH 7/8] cleanup --- src/spec/Components.php | 2 +- src/spec/MediaType.php | 4 ++-- src/spec/Operation.php | 4 ++-- src/spec/PathItem.php | 2 +- src/spec/Response.php | 2 +- src/spec/Schema.php | 2 +- tests/spec/MediaTypeTest.php | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) 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/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/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/spec/MediaTypeTest.php b/tests/spec/MediaTypeTest.php index 8cdeee8..e0c5d8d 100644 --- a/tests/spec/MediaTypeTest.php +++ b/tests/spec/MediaTypeTest.php @@ -43,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); From 271bb4a264b87bae0bd17270ad117e4b5fd4ec95 Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Thu, 22 Nov 2018 14:28:16 +0100 Subject: [PATCH 8/8] fix code style --- src/ReferenceContext.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ReferenceContext.php b/src/ReferenceContext.php index 541cb59..3257466 100644 --- a/src/ReferenceContext.php +++ b/src/ReferenceContext.php @@ -81,7 +81,6 @@ public function resolveRelativeUri(string $uri): string $baseUri = $this->getUri(); if (strncmp($baseUri, 'file://', 7) === 0) { - if (isset($parts['path'][0]) && $parts['path'][0] === '/') { // absolute path return 'file://' . $parts['path']; @@ -106,7 +105,7 @@ public function resolveRelativeUri(string $uri): string ]); if (isset($parts['path'][0]) && $parts['path'][0] === '/') { $absoluteUri .= $parts['path']; - } else if (isset($parts['path'])) { + } elseif (isset($parts['path'])) { $absoluteUri .= rtrim(dirname($baseParts['path'] ?? ''), '/') . '/' . $parts['path']; } return $absoluteUri