diff --git a/src/SpecBaseObject.php b/src/SpecBaseObject.php index 29f70b4..6e87c1f 100644 --- a/src/SpecBaseObject.php +++ b/src/SpecBaseObject.php @@ -302,13 +302,23 @@ protected function hasProperty(string $name): bool return isset($this->_properties[$name]) || isset($this->attributes()[$name]); } - protected function requireProperties(array $names) + protected function requireProperties(array $names, array $atLeastOne = []) { foreach ($names as $name) { if (!isset($this->_properties[$name])) { $this->addError(" is missing required property: $name", get_class($this)); } } + + if (count($atLeastOne) > 0) { + foreach ($atLeastOne as $name) { + if (array_key_exists($name, $this->_properties)) { + return; + } + } + + $this->addError(" is missing at least one of the following required properties: " . implode(', ', $atLeastOne), get_class($this)); + } } protected function validateEmail(string $property) diff --git a/src/spec/OpenApi.php b/src/spec/OpenApi.php index c45fbad..9938dbe 100644 --- a/src/spec/OpenApi.php +++ b/src/spec/OpenApi.php @@ -20,6 +20,7 @@ * @property Server[] $servers * @property Paths|PathItem[] $paths * @property Components|null $components + * @property WebHooks|null $webhooks * @property SecurityRequirement[] $security * @property Tag[] $tags * @property ExternalDocumentation|null $externalDocs @@ -46,6 +47,7 @@ protected function attributes(): array 'info' => Info::class, 'servers' => [Server::class], 'paths' => Paths::class, + 'webhooks' => WebHooks::class, 'components' => Components::class, 'security' => [SecurityRequirement::class], 'tags' => [Tag::class], @@ -83,7 +85,12 @@ public function __get($name) */ public function performValidation() { - $this->requireProperties(['openapi', 'info', 'paths']); + if ($this->getMajorVersion() === static::VERSION_3_0) { + $this->requireProperties(['openapi', 'info', 'paths']); + } else { + $this->requireProperties(['openapi', 'info'], ['paths', 'webhooks']); + } + if (!empty($this->openapi) && !preg_match(static::PATTERN_VERSION, $this->openapi)) { $this->addError('Unsupported openapi version: ' . $this->openapi); } diff --git a/src/spec/Schema.php b/src/spec/Schema.php index 59eb425..4b41309 100644 --- a/src/spec/Schema.php +++ b/src/spec/Schema.php @@ -40,7 +40,7 @@ * @property string[] $required list of required properties * @property array $enum * - * @property string $type + * @property string|string[] $type * @property Schema[]|Reference[] $allOf * @property Schema[]|Reference[] $oneOf * @property Schema[]|Reference[] $anyOf diff --git a/src/spec/Type.php b/src/spec/Type.php index 9861000..2cdcb91 100644 --- a/src/spec/Type.php +++ b/src/spec/Type.php @@ -21,6 +21,7 @@ class Type const BOOLEAN = 'boolean'; const OBJECT = 'object'; const ARRAY = 'array'; + const NULL = 'null'; /** * Indicate whether a type is a scalar type, i.e. not an array or object. @@ -38,6 +39,7 @@ public static function isScalar(string $type): bool self::NUMBER, self::STRING, self::BOOLEAN, + self::NULL, ]); } } diff --git a/src/spec/WebHooks.php b/src/spec/WebHooks.php new file mode 100644 index 0000000..640bbd4 --- /dev/null +++ b/src/spec/WebHooks.php @@ -0,0 +1,302 @@ + and contributors + * @license https://github.com/cebe/php-openapi/blob/master/LICENSE + */ + +namespace cebe\openapi\spec; + +use ArrayAccess; +use ArrayIterator; +use cebe\openapi\DocumentContextInterface; +use cebe\openapi\exceptions\TypeErrorException; +use cebe\openapi\exceptions\UnresolvableReferenceException; +use cebe\openapi\json\JsonPointer; +use cebe\openapi\ReferenceContext; +use cebe\openapi\SpecObjectInterface; +use Countable; +use IteratorAggregate; +use Traversable; + +/** + * Holds the webhook events to the individual endpoints and their operations. + * + * @link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#oasWebhooks + * + */ +class WebHooks implements SpecObjectInterface, DocumentContextInterface, ArrayAccess, Countable, IteratorAggregate +{ + /** + * @var (PathItem|null)[] + */ + private $_webHooks = []; + /** + * @var array + */ + private $_errors = []; + /** + * @var SpecObjectInterface|null + */ + private $_baseDocument; + /** + * @var JsonPointer|null + */ + private $_jsonPointer; + + + /** + * Create an object from spec data. + * @param (PathItem|array|null)[] $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) { + if ($object === null) { + $this->_webHooks[$path] = null; + } elseif (is_array($object)) { + $this->_webHooks[$path] = new PathItem($object); + } elseif ($object instanceof PathItem) { + $this->_webHooks[$path] = $object; + } else { + $givenType = gettype($object); + if ($givenType === 'object') { + $givenType = get_class($object); + } + throw new TypeErrorException(sprintf('Path MUST be either array or PathItem object, "%s" given', $givenType)); + } + } + } + + /** + * @return mixed returns the serializable data of this object for converting it + * to JSON or YAML. + */ + public function getSerializableData() + { + $data = []; + foreach ($this->_webHooks as $path => $pathItem) { + $data[$path] = ($pathItem === null) ? null : $pathItem->getSerializableData(); + } + return (object) $data; + } + + /** + * @param string $name path name + * @return bool + */ + public function hasWebHook(string $name): bool + { + return isset($this->_webHooks[$name]); + } + + /** + * @param string $name path name + * @return PathItem + */ + public function getWebHook(string $name): ?PathItem + { + return $this->_webHooks[$name] ?? null; + } + + /** + * @param string $name path name + * @param PathItem $pathItem the path item to add + */ + public function addWebHook(string $name, PathItem $pathItem): void + { + $this->_webHooks[$name] = $pathItem; + } + + /** + * @param string $name path name + */ + public function removeWebHook(string $name): void + { + unset($this->_webHooks[$name]); + } + + /** + * @return PathItem[] + */ + public function getWebHooks(): array + { + return $this->_webHooks; + } + + /** + * 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 + { + $valid = true; + $this->_errors = []; + foreach ($this->_webHooks as $key => $path) { + if ($path === null) { + continue; + } + if (!$path->validate()) { + $valid = false; + } + } + return $valid && empty($this->_errors); + } + + /** + * @return string[] list of validation errors according to OpenAPI spec. + * @see validate() + */ + public function getErrors(): array + { + if (($pos = $this->getDocumentPosition()) !== null) { + $errors = [ + array_map(function ($e) use ($pos) { + return "[{$pos}] $e"; + }, $this->_errors) + ]; + } else { + $errors = [$this->_errors]; + } + + foreach ($this->_webHooks as $path) { + if ($path === null) { + continue; + } + $errors[] = $path->getErrors(); + } + return array_merge(...$errors); + } + + /** + * Whether a offset exists + * @link http://php.net/manual/en/arrayaccess.offsetexists.php + * @param mixed $offset An offset to check for. + * @return boolean true on success or false on failure. + * The return value will be casted to boolean if non-boolean was returned. + */ + public function offsetExists($offset) + { + return $this->hasWebHook($offset); + } + + /** + * Offset to retrieve + * @link http://php.net/manual/en/arrayaccess.offsetget.php + * @param mixed $offset The offset to retrieve. + * @return PathItem Can return all value types. + */ + public function offsetGet($offset) + { + return $this->getWebHook($offset); + } + + /** + * Offset to set + * @link http://php.net/manual/en/arrayaccess.offsetset.php + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + */ + public function offsetSet($offset, $value) + { + $this->addWebHook($offset, $value); + } + + /** + * Offset to unset + * @link http://php.net/manual/en/arrayaccess.offsetunset.php + * @param mixed $offset The offset to unset. + */ + public function offsetUnset($offset) + { + $this->removeWebHook($offset); + } + + /** + * Count elements of an object + * @link http://php.net/manual/en/countable.count.php + * @return int The custom count as an integer. + * The return value is cast to an integer. + */ + public function count() + { + return count($this->_webHooks); + } + + /** + * Retrieve an external iterator + * @link http://php.net/manual/en/iteratoraggregate.getiterator.php + * @return Traversable An instance of an object implementing Iterator or Traversable + */ + public function getIterator() + { + return new ArrayIterator($this->_webHooks); + } + + /** + * Resolves all Reference Objects in this object and replaces them with their resolution. + * @throws UnresolvableReferenceException + */ + public function resolveReferences(ReferenceContext $context = null) + { + foreach ($this->_webHooks as $key => $path) { + if ($path === null) { + continue; + } + $path->resolveReferences($context); + } + } + + /** + * Set context for all Reference Objects in this object. + */ + public function setReferenceContext(ReferenceContext $context) + { + foreach ($this->_webHooks as $key => $path) { + if ($path === null) { + continue; + } + $path->setReferenceContext($context); + } + } + + /** + * Provide context information to the object. + * + * Context information contains a reference to the base object where it is contained in + * as well as a JSON pointer to its position. + * @param SpecObjectInterface $baseDocument + * @param JsonPointer $jsonPointer + */ + public function setDocumentContext(SpecObjectInterface $baseDocument, JsonPointer $jsonPointer) + { + $this->_baseDocument = $baseDocument; + $this->_jsonPointer = $jsonPointer; + + foreach ($this->_webHooks as $key => $path) { + if ($path instanceof DocumentContextInterface) { + $path->setDocumentContext($baseDocument, $jsonPointer->append($key)); + } + } + } + + /** + * @return SpecObjectInterface|null returns the base document where this object is located in. + * Returns `null` if no context information was provided by [[setDocumentContext]]. + */ + public function getBaseDocument(): ?SpecObjectInterface + { + return $this->_baseDocument; + } + + /** + * @return JsonPointer|null returns a JSON pointer describing the position of this object in the base document. + * Returns `null` if no context information was provided by [[setDocumentContext]]. + */ + public function getDocumentPosition(): ?JsonPointer + { + return $this->_jsonPointer; + } +} diff --git a/tests/spec/OpenApiTest.php b/tests/spec/OpenApiTest.php index b2915e7..3ed9d9d 100644 --- a/tests/spec/OpenApiTest.php +++ b/tests/spec/OpenApiTest.php @@ -17,7 +17,7 @@ public function testEmpty() $this->assertEquals([ 'OpenApi is missing required property: openapi', 'OpenApi is missing required property: info', - 'OpenApi is missing required property: paths', + 'OpenApi is missing at least one of the following required properties: paths, webhooks', ], $openapi->getErrors()); // check default value of servers diff --git a/tests/spec/WebHooksTest.php b/tests/spec/WebHooksTest.php new file mode 100644 index 0000000..fbf4bb7 --- /dev/null +++ b/tests/spec/WebHooksTest.php @@ -0,0 +1,253 @@ +validate(); + $this->assertEquals([], $webHooks->getErrors()); + $this->assertTrue($result); + + $this->assertTrue($webHooks->hasWebHook('check_run')); + $this->assertTrue(isset($webHooks['check_run'])); + $this->assertFalse($webHooks->hasWebHook('run_check')); + $this->assertFalse(isset($webHooks['run_check'])); + + $this->assertInstanceOf(PathItem::class, $webHooks->getWebHook('check_run')); + $this->assertInstanceOf(PathItem::class, $webHooks['check_run']); + $this->assertInstanceOf(Operation::class, $webHooks->getWebHook('check_run')->get); + $this->assertNull($webHooks->getWebHook('run_check')); + $this->assertNull($webHooks['run_check']); + + $this->assertCount(1, $webHooks->getPaths()); + $this->assertCount(1, $webHooks); + foreach($webHooks as $path => $pathItem) { + $this->assertEquals('/pets', $path); + $this->assertInstanceOf(PathItem::class, $pathItem); + } + } + + public function testCreateionFromObjects() + { + $paths = new Paths([ + '/pets' => new PathItem([ + 'get' => new Operation([ + 'responses' => new Responses([ + 200 => new Response(['description' => 'A list of pets.']), + 404 => ['description' => 'The pets list is gone 🙀'], + ]) + ]) + ]) + ]); + + $this->assertTrue($paths->hasPath('/pets')); + $this->assertInstanceOf(PathItem::class, $paths->getPath('/pets')); + $this->assertInstanceOf(PathItem::class, $paths['/pets']); + $this->assertInstanceOf(Operation::class, $paths->getPath('/pets')->get); + + $this->assertSame('A list of pets.', $paths->getPath('/pets')->get->responses->getResponse(200)->description); + $this->assertSame('The pets list is gone 🙀', $paths->getPath('/pets')->get->responses->getResponse(404)->description); + } + + public function badPathsConfigProvider() + { + yield [['/pets' => 'foo'], 'Path MUST be either array or PathItem object, "string" given']; + yield [['/pets' => 42], 'Path MUST be either array or PathItem object, "integer" given']; + yield [['/pets' => false], 'Path MUST be either array or PathItem object, "boolean" given']; + yield [['/pets' => new stdClass()], 'Path MUST be either array or PathItem object, "stdClass" given']; + // The last one can be supported in future, but now SpecBaseObjects::__construct() requires array explicitly + } + + /** + * @dataProvider badPathsConfigProvider + */ + public function testPathsCanNotBeCreatedFromBullshit($config, $expectedException) + { + $this->expectException(\cebe\openapi\exceptions\TypeErrorException::class); + $this->expectExceptionMessage($expectedException); + + new Paths($config); + } + + public function testInvalidPath() + { + /** @var $paths Paths */ + $paths = Reader::readFromJson(<<<'JSON' +{ + "pets": { + "get": { + "description": "Returns all pets from the system that the user has access to", + "responses": { + "200": { + "description": "A list of pets." + } + } + } + } +} +JSON + , Paths::class); + + $result = $paths->validate(); + $this->assertEquals([ + 'Path must begin with /: pets' + ], $paths->getErrors()); + $this->assertFalse($result); + } + + public function testPathItemReference() + { + $file = __DIR__ . '/data/paths/openapi.yaml'; + /** @var $openapi \cebe\openapi\spec\OpenApi */ + $openapi = Reader::readFromYamlFile($file, \cebe\openapi\spec\OpenApi::class, false); + + $result = $openapi->validate(); + $this->assertEquals([], $openapi->getErrors(), print_r($openapi->getErrors(), true)); + $this->assertTrue($result); + + $this->assertInstanceOf(Paths::class, $openapi->paths); + $this->assertInstanceOf(PathItem::class, $fooPath = $openapi->paths['/foo']); + $this->assertInstanceOf(PathItem::class, $barPath = $openapi->paths['/bar']); + $this->assertSame([ + 'x-extension-1' => 'Extension1', + 'x-extension-2' => 'Extension2' + ], $openapi->getExtensions()); + + $this->assertEmpty($fooPath->getOperations()); + $this->assertEmpty($barPath->getOperations()); + + $this->assertInstanceOf(\cebe\openapi\spec\Reference::class, $fooPath->getReference()); + $this->assertInstanceOf(\cebe\openapi\spec\Reference::class, $barPath->getReference()); + + $this->assertNull($fooPath->getReference()->resolve()); + $this->assertInstanceOf(PathItem::class, $ReferencedBarPath = $barPath->getReference()->resolve()); + + $this->assertCount(1, $ReferencedBarPath->getOperations()); + $this->assertInstanceOf(Operation::class, $ReferencedBarPath->get); + $this->assertEquals('getBar', $ReferencedBarPath->get->operationId); + + $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); + + $result = $openapi->validate(); + $this->assertEquals([], $openapi->getErrors(), print_r($openapi->getErrors(), true)); + $this->assertTrue($result); + + $this->assertInstanceOf(Paths::class, $openapi->paths); + $this->assertInstanceOf(PathItem::class, $fooPath = $openapi->paths['/foo']); + $this->assertInstanceOf(PathItem::class, $barPath = $openapi->paths['/bar']); + + $this->assertEmpty($fooPath->getOperations()); + $this->assertCount(1, $barPath->getOperations()); + $this->assertInstanceOf(Operation::class, $barPath->get); + $this->assertEquals('getBar', $barPath->get->operationId); + + $this->assertEquals('A bar', $barPath->get->responses['200']->description); + $this->assertEquals('non-existing resource', $barPath->get->responses['404']->description); + } +}