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);
+ }
+}