diff --git a/lib/Requests/Calendar/CalendarSet.php b/lib/Requests/Calendar/CalendarSet.php index b50de47..d9368fa 100644 --- a/lib/Requests/Calendar/CalendarSet.php +++ b/lib/Requests/Calendar/CalendarSet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Calendar; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -46,6 +47,18 @@ public function update(string $id, mixed $object = null): CalendarParameters return parent::update($id, $object); } + /** + * Patch a calendar + * + * @param string $id Calendar identifier + * @param CalendarParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } + /** * Delete a calendar * diff --git a/lib/Requests/Calendar/EventSet.php b/lib/Requests/Calendar/EventSet.php index 159fe15..ce170f9 100644 --- a/lib/Requests/Calendar/EventSet.php +++ b/lib/Requests/Calendar/EventSet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Calendar; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -46,6 +47,18 @@ public function update(string $id, mixed $object = null): EventParameters return parent::update($id, $object); } + /** + * Patch an event + * + * @param string $id Event identifier + * @param EventParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } + /** * Delete an event * diff --git a/lib/Requests/Contacts/AddressBookSet.php b/lib/Requests/Contacts/AddressBookSet.php index 83af7ca..32e6fcc 100644 --- a/lib/Requests/Contacts/AddressBookSet.php +++ b/lib/Requests/Contacts/AddressBookSet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Contacts; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -46,6 +47,18 @@ public function update(string $id, mixed $object = null): AddressBookParameters return parent::update($id, $object); } + /** + * Patch an address book + * + * @param string $id Address book identifier + * @param AddressBookParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } + /** * Delete an address book * diff --git a/lib/Requests/Contacts/ContactSet.php b/lib/Requests/Contacts/ContactSet.php index fe2d9b0..6b48da9 100644 --- a/lib/Requests/Contacts/ContactSet.php +++ b/lib/Requests/Contacts/ContactSet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Contacts; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -46,6 +47,18 @@ public function update(string $id, mixed $object = null): ContactParameters return parent::update($id, $object); } + /** + * Patch a contact + * + * @param string $id Contact identifier + * @param ContactParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } + /** * Delete a contact * diff --git a/lib/Requests/Files/NodeSet.php b/lib/Requests/Files/NodeSet.php index 84ceab1..e06de18 100644 --- a/lib/Requests/Files/NodeSet.php +++ b/lib/Requests/Files/NodeSet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Files; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -35,6 +36,18 @@ public function delete(string $id): static return parent::delete($id); } + /** + * Patch a node + * + * @param string $id Node identifier + * @param NodeParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } + public function destroyChildren(bool $value): static { $this->_command['onDestroyRemoveChildren'] = $value; diff --git a/lib/Requests/Interfaces/RequestParametersInterface.php b/lib/Requests/Interfaces/RequestParametersInterface.php index 8bd7902..28bdd4a 100644 --- a/lib/Requests/Interfaces/RequestParametersInterface.php +++ b/lib/Requests/Interfaces/RequestParametersInterface.php @@ -24,4 +24,11 @@ interface RequestParametersInterface * @return self For method chaining */ public function bind(&$anchor): static; + + /** + * Create a patch snapshot from the current structured parameters + * + * @return RequestPatchInterface The patch representation of these parameters + */ + public function patch(): RequestPatchInterface; } diff --git a/lib/Requests/Interfaces/RequestPatchInterface.php b/lib/Requests/Interfaces/RequestPatchInterface.php new file mode 100644 index 0000000..2ff0da5 --- /dev/null +++ b/lib/Requests/Interfaces/RequestPatchInterface.php @@ -0,0 +1,43 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace JmapClient\Requests\Interfaces; + +/** + * Interface for JMAP patch request parameters + */ +interface RequestPatchInterface +{ + /** + * Bind the patch to an anchor object + * + * @param object $anchor Reference to the anchor object + * @return self For method chaining + */ + public function bind(&$anchor): static; + + /** + * Set a JMAP patch path to a value + * + * @param string $path The JMAP patch path + * @param mixed $value The value for the path + * + * @return self For method chaining + */ + public function path(string $path, mixed $value): static; + + /** + * Replace the patch object with raw patch data + * + * @param array $value Patch data keyed by JMAP patch path + * + * @return self For method chaining + */ + public function parametersRaw(array $value): static; +} \ No newline at end of file diff --git a/lib/Requests/Mail/MailIdentitySet.php b/lib/Requests/Mail/MailIdentitySet.php index 1076d18..49d5204 100644 --- a/lib/Requests/Mail/MailIdentitySet.php +++ b/lib/Requests/Mail/MailIdentitySet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Mail; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -44,6 +45,18 @@ public function update(string $id, mixed $object = null): MailIdentityParameters return parent::update($id, $object); } + /** + * Patch an mail identity + * + * @param string $id Mail identity identifier + * @param MailIdentityParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } + /** * Delete a mail identity * diff --git a/lib/Requests/Mail/MailSet.php b/lib/Requests/Mail/MailSet.php index 61df445..88c0102 100644 --- a/lib/Requests/Mail/MailSet.php +++ b/lib/Requests/Mail/MailSet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Mail; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -44,6 +45,18 @@ public function update(string $id, mixed $object = null): MailParameters return parent::update($id, $object); } + /** + * Patch an email + * + * @param string $id Email identifier + * @param MailParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } + /** * Delete an email * diff --git a/lib/Requests/Mail/MailVacationSet.php b/lib/Requests/Mail/MailVacationSet.php index 642fab6..3f00afb 100644 --- a/lib/Requests/Mail/MailVacationSet.php +++ b/lib/Requests/Mail/MailVacationSet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Mail; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -32,4 +33,16 @@ public function update(string $id, mixed $object = null): MailVacationParameters { return parent::update($id, $object); } + + /** + * Patch an vacation response (there's only one with id "singleton") + * + * @param string $id Vacation response identifier (should be "singleton") + * @param MailVacationParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } } diff --git a/lib/Requests/Mail/MailboxSet.php b/lib/Requests/Mail/MailboxSet.php index 3f297a6..83d6c7d 100644 --- a/lib/Requests/Mail/MailboxSet.php +++ b/lib/Requests/Mail/MailboxSet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Mail; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -45,6 +46,18 @@ public function update(string $id, mixed $object = null): MailboxParameters { return parent::update($id, $object); } + + /** + * Patch a mailbox + * + * @param string $id Mailbox identifier + * @param MailboxParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } /** * Delete a mailbox diff --git a/lib/Requests/RequestParameters.php b/lib/Requests/RequestParameters.php index 20f1872..1aebc7b 100644 --- a/lib/Requests/RequestParameters.php +++ b/lib/Requests/RequestParameters.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests; use JmapClient\Requests\Interfaces\RequestParametersInterface; +use JmapClient\Requests\Interfaces\RequestPatchInterface; use stdClass; class RequestParameters implements RequestParametersInterface @@ -34,6 +35,50 @@ public function bind(&$anchor): static return $this; } + public function patch(): RequestPatchInterface + { + $flatten = function (object|array $parameters, string $prefix = '') use (&$flatten): array { + $patch = []; + + $entries = is_object($parameters) ? get_object_vars($parameters) : $parameters; + + foreach ($entries as $name => $value) { + $path = $prefix === '' ? self::escapePatchSegment($name) : $prefix . '/' . self::escapePatchSegment($name); + + if (is_object($value)) { + $entries = $flatten($value, $path); + if ($entries === []) { + $patch[$path] = new stdClass(); + continue; + } + + $patch = array_merge($patch, $entries); + continue; + } + + if (is_array($value) && array_is_list($value) === false) { + $entries = $flatten($value, $path); + if ($entries === []) { + $patch[$path] = new stdClass(); + continue; + } + + $patch = array_merge($patch, $entries); + continue; + } + + $patch[$path] = $value; + } + + return $patch; + }; + + $patch = new RequestPatch(); + $patch->parametersRaw($flatten($this->_parameters)); + + return $patch; + } + protected function parameter(string $name, mixed $value): static { $this->_parameters->$name = $value; @@ -65,4 +110,9 @@ public function parametersRaw(array $value): static $this->_parameters = (object) $value; return $this; } + + private static function escapePatchSegment(string $segment): string + { + return str_replace(['~', '/'], ['~0', '~1'], $segment); + } } diff --git a/lib/Requests/RequestPatch.php b/lib/Requests/RequestPatch.php new file mode 100644 index 0000000..069ee5a --- /dev/null +++ b/lib/Requests/RequestPatch.php @@ -0,0 +1,45 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace JmapClient\Requests; + +use JmapClient\Requests\Interfaces\RequestPatchInterface; +use stdClass; + +class RequestPatch implements RequestPatchInterface +{ + protected object $_parameters; + + public function __construct(&$parameters = null) + { + if ($parameters === null) { + $this->_parameters = new stdClass(); + } else { + $this->_parameters = & $parameters; + } + } + + public function bind(&$anchor): static + { + $anchor = $this->_parameters; + return $this; + } + + public function path(string $path, mixed $value): static + { + $this->_parameters->$path = $value; + return $this; + } + + public function parametersRaw(array $value): static + { + $this->_parameters = (object) $value; + return $this; + } +} \ No newline at end of file diff --git a/lib/Requests/RequestSet.php b/lib/Requests/RequestSet.php index 9c4246b..5469cf3 100644 --- a/lib/Requests/RequestSet.php +++ b/lib/Requests/RequestSet.php @@ -11,6 +11,7 @@ use JmapClient\Exceptions\InvalidParameterTypeException; use JmapClient\Requests\Interfaces\RequestParametersInterface; +use JmapClient\Requests\Interfaces\RequestPatchInterface; use JmapClient\Requests\Interfaces\RequestSetInterface; /** @@ -19,8 +20,12 @@ */ class RequestSet extends Request implements RequestSetInterface { + private const UPDATE_MODE_STRUCTURED = 'structured'; + private const UPDATE_MODE_PATCH = 'patch'; + protected string $_method = 'set'; protected string $_parametersClass = RequestParameters::class; + protected array $_updateModes = []; public function state(string $state): static { @@ -59,7 +64,7 @@ public function create(string $id, mixed $object = null): RequestParametersInter } /** - * Update an existing object + * Update an existing object(s) * * @param string $id Object identifier * @param TParameters|null $object Optional parameters object @@ -68,6 +73,11 @@ public function create(string $id, mixed $object = null): RequestParametersInter */ protected function update(string $id, RequestParametersInterface|null $object = null): RequestParametersInterface { + if (isset($this->_updateModes[$id]) && $this->_updateModes[$id] !== self::UPDATE_MODE_STRUCTURED) { + throw new \LogicException("Update \"$id\" is a patch object and must be accessed via patch() method"); + } + $this->_updateModes[$id] = self::UPDATE_MODE_STRUCTURED; + // get the class to use (override or default) $class = RequestClasses::getParameter($this->_class . '.object') ?? $this->_parametersClass; @@ -88,6 +98,40 @@ protected function update(string $id, RequestParametersInterface|null $object = return $instance; } + /** + * Update an existing object(s) with a patch + * + * @param string $id Object identifier + * @param RequestParametersInterface|RequestPatchInterface|null $object Optional structured or patch object + * + * @return RequestPatchInterface The patch object for method chaining + */ + protected function patch(string $id, RequestParametersInterface|RequestPatchInterface|null $object = null): RequestPatchInterface + { + if (isset($this->_updateModes[$id]) && $this->_updateModes[$id] !== self::UPDATE_MODE_PATCH) { + throw new \LogicException("Update \"$id\" is a structured object and must be accessed via update() method"); + } + $this->_updateModes[$id] = self::UPDATE_MODE_PATCH; + + // get the class to use (override or default) + $class = RequestClasses::getParameter($this->_class . '.object') ?? $this->_parametersClass; + + // validate object type if provided + if ($object !== null && !($object instanceof $class) && !($object instanceof RequestPatchInterface)) { + throw new InvalidParameterTypeException($class . '|' . RequestPatchInterface::class, $object, 'object'); + } + + if (!isset($this->_command['update'][$id]) && $object === null) { + $this->_command['update'][$id] = new \stdClass(); + } elseif ($object instanceof RequestPatchInterface) { + $object->bind($this->_command['update'][$id]); + } elseif ($object instanceof RequestParametersInterface) { + $object->patch()->bind($this->_command['update'][$id]); + } + + return new RequestPatch($this->_command['update'][$id]); + } + /** * Delete an existing object * @@ -100,4 +144,18 @@ protected function delete(string $id): static $this->_command['destroy'][] = $id; return $this; } + + private function assertUpdateMode(string $id, string $mode): void + { + if (!isset($this->_updateModes[$id]) || $this->_updateModes[$id] === $mode) { + return; + } + + throw new \LogicException(sprintf( + 'Update "%s" is already bound in %s mode and cannot be rebound in %s mode', + $id, + $this->_updateModes[$id], + $mode + )); + } } diff --git a/lib/Requests/Tasks/TaskListSet.php b/lib/Requests/Tasks/TaskListSet.php index 53f0e05..8e47553 100644 --- a/lib/Requests/Tasks/TaskListSet.php +++ b/lib/Requests/Tasks/TaskListSet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Tasks; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -46,6 +47,18 @@ public function update(string $id, mixed $object = null): TaskListParameters return parent::update($id, $object); } + /** + * Patch a task list + * + * @param string $id Task list identifier + * @param TaskListParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } + /** * Delete a task list * diff --git a/lib/Requests/Tasks/TaskSet.php b/lib/Requests/Tasks/TaskSet.php index eacc623..80d70c0 100644 --- a/lib/Requests/Tasks/TaskSet.php +++ b/lib/Requests/Tasks/TaskSet.php @@ -10,6 +10,7 @@ namespace JmapClient\Requests\Tasks; use JmapClient\Requests\RequestSet; +use JmapClient\Requests\Interfaces\RequestPatchInterface; /** * @extends RequestSet @@ -46,6 +47,18 @@ public function update(string $id, mixed $object = null): TaskParameters return parent::update($id, $object); } + /** + * Patch an task + * + * @param string $id Task identifier + * @param TaskParameters|RequestPatchInterface|null $object Optional structured or patch object + * @return RequestPatchInterface The patch object for method chaining + */ + public function patch(string $id, mixed $object = null): RequestPatchInterface + { + return parent::patch($id, $object); + } + /** * Delete a task *