REST API allow sending a JSON patch to update or create a new contact (currently only via POST or PUT, not as PATCH!)

allows eg. to create a contact from a simple Wordpress contact-form only supporting POST requests and a flat object, see new example in the documentation
This commit is contained in:
Ralf Becker 2021-09-24 18:06:13 +02:00
parent d9f759f517
commit 1280de46d6
3 changed files with 174 additions and 51 deletions

View File

@ -658,7 +658,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
}
}
$contact = $type === JsContact::MIME_TYPE_JSCARD ?
JsContact::parseJsCard($options['content']) : JsContact::parseJsCardGroup($options['content']);
JsContact::parseJsCard($options['content'], $oldContact ?: []) : JsContact::parseJsCardGroup($options['content']);
if (!empty($id) && strpos($id, self::JS_CARDGROUP_ID_PREFIX) === 0)
{

View File

@ -79,15 +79,27 @@ class JsContact
* Parse JsCard
*
* @param string $json
* @param bool $check_at_type true: check if objects have their proper @type attribute
* @param array $old=[] existing contact
* @param bool $strict true: check if objects have their proper @type attribute
* @return array
*/
public static function parseJsCard(string $json, bool $check_at_type=true)
public static function parseJsCard(string $json, array $old=[], bool $strict=true)
{
try
{
$data = json_decode($json, true, 10, JSON_THROW_ON_ERROR);
// check if we have a patch: keys contain slashes
if (array_filter(array_keys($data), static function ($key)
{
return strpos($key, '/') !== false;
}))
{
// apply patch on JsCard of contact
$data = self::patch($data, $old ? self::getJsCard($old, false) : [], !$old);
$strict = false;
}
if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist
$contact = [];
@ -96,11 +108,11 @@ class JsContact
switch ($name)
{
case 'uid':
$contact['uid'] = self::parseUid($value);
$contact['uid'] = self::parseUid($value, $old['uid'], !$strict);
break;
case 'name':
$contact += self::parseNameComponents($value, $check_at_type);
$contact += self::parseNameComponents($value, $strict);
break;
case 'fullName':
@ -108,41 +120,41 @@ class JsContact
break;
case 'organizations':
$contact += self::parseOrganizations($value, $check_at_type);
$contact += self::parseOrganizations($value, $strict);
break;
case 'titles':
$contact += self::parseTitles($value, $check_at_type);
$contact += self::parseTitles($value, $strict);
break;
case 'emails':
$contact += self::parseEmails($value, $check_at_type);
$contact += self::parseEmails($value, $strict);
break;
case 'phones':
$contact += self::parsePhones($value, $check_at_type);
$contact += self::parsePhones($value, $strict);
break;
case 'online':
$contact += self::parseOnline($value, $check_at_type);
$contact += self::parseOnline($value, $strict);
break;
case 'addresses':
$contact += self::parseAddresses($value, $check_at_type);
$contact += self::parseAddresses($value, $strict);
break;
case 'photos':
$contact += self::parsePhotos($value, $check_at_type);
$contact += self::parsePhotos($value, $strict);
break;
case 'anniversaries':
$contact += self::parseAnniversaries($value);
$contact += self::parseAnniversaries($value, $strict);
break;
case 'notes':
$contact['note'] = implode("\n", array_map(static function ($note) {
return self::parseString($note);
}, $value));
}, (array)$value));
break;
case 'categories':
@ -197,11 +209,12 @@ class JsContact
* Parse and optionally generate UID
*
* @param string|null $uid
* @param string|null $old old value, if given it must NOT change
* @param bool $generate_when_empty true: generate UID if empty, false: throw error
* @return string without urn:uuid: prefix
* @throws \InvalidArgumentException
*/
protected static function parseUid(string $uid=null, $generate_when_empty=false)
protected static function parseUid(string $uid=null, string $old=null, bool $generate_when_empty=false)
{
if (empty($uid) || strlen($uid) < 12)
{
@ -211,7 +224,15 @@ class JsContact
}
$uid = \HTTP_WebDAV_Server::_new_uuid();
}
return strpos($uid, self::URN_UUID_PREFIX) === 0 ? substr($uid, 9) : $uid;
if (strpos($uid, self::URN_UUID_PREFIX) === 0)
{
$uid = substr($uid, strlen(self::URN_UUID_PREFIX));
}
if (isset($old) && $old !== $uid)
{
throw new \InvalidArgumentException("You must NOT change the UID ('$old'): ".json_encode($uid));
}
return $uid;
}
/**
@ -248,15 +269,15 @@ class JsContact
* As we store only one organization, the rest get lost, multiple units get concatenated by space.
*
* @param array $orgas
* @param bool $check_at_type true: check if objects have their proper @type attribute
* @param bool $stict true: check if objects have their proper @type attribute
* @return array
*/
protected static function parseOrganizations(array $orgas, bool $check_at_type=true)
protected static function parseOrganizations(array $orgas, bool $stict=true)
{
$contact = [];
foreach($orgas as $orga)
{
if ($check_at_type && $orga[self::AT_TYPE] !== self::TYPE_ORGANIZATION)
if ($stict && $orga[self::AT_TYPE] !== self::TYPE_ORGANIZATION)
{
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($orga, self::JSON_OPTIONS_ERROR));
}
@ -306,15 +327,19 @@ class JsContact
* Parse titles, thought we only have "title" and "role" available for storage.
*
* @param array $titles
* @param bool $check_at_type true: check if objects have their proper @type attribute
* @param bool $stict true: check if objects have their proper @type attribute
* @return array
*/
protected static function parseTitles(array $titles, bool $check_at_type=true)
protected static function parseTitles(array $titles, bool $stict=true)
{
$contact = [];
foreach($titles as $id => $title)
{
if ($check_at_type && $title[self::AT_TYPE] !== self::TYPE_TITLE)
if (!$stict && is_string($title))
{
$title = ['title' => $title];
}
if ($stict && $title[self::AT_TYPE] !== self::TYPE_TITLE)
{
throw new \InvalidArgumentException("Missing or invalid @type: " . json_encode($title[self::AT_TYPE]));
}
@ -526,21 +551,21 @@ class JsContact
* Parse addresses object containing multiple addresses
*
* @param array $addresses
* @param bool $check_at_type true: check if objects have their proper @type attribute
* @param bool $stict true: check if objects have their proper @type attribute
* @return array
*/
protected static function parseAddresses(array $addresses, bool $check_at_type=true)
protected static function parseAddresses(array $addresses, bool $stict=true)
{
$n = 0;
$last_type = null;
$contact = [];
foreach($addresses as $id => $address)
{
if ($check_at_type && $address[self::AT_TYPE] !== self::TYPE_ADDRESS)
if ($stict && $address[self::AT_TYPE] !== self::TYPE_ADDRESS)
{
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($address));
}
$contact += ($values=self::parseAddress($address, $id, $last_type));
$contact += ($values=self::parseAddress($address, $id, $last_type, $stict));
if (++$n > 2)
{
@ -567,9 +592,10 @@ class JsContact
* @param array $address address-object
* @param string $id index
* @param ?string $last_type "work" or "home"
* @param bool $stict true: check if objects have their proper @type attribute
* @return array
*/
protected static function parseAddress(array $address, string $id, string &$last_type=null)
protected static function parseAddress(array $address, string $id, string &$last_type=null, bool $stict=true)
{
$type = !isset($last_type) && (empty($address['contexts']['private']) || $id === 'work') ||
$last_type === 'home' ? 'work' : 'home';
@ -577,7 +603,7 @@ class JsContact
$prefix = $type === 'work' ? 'adr_one_' : 'adr_two_';
$contact = [$prefix.'street' => null, $prefix.'street2' => null];
list($contact[$prefix.'street'], $contact[$prefix.'street2']) = self::parseStreetComponents($address['street']);
list($contact[$prefix.'street'], $contact[$prefix.'street2']) = self::parseStreetComponents($address['street'], $stict);
foreach(self::$jsAddress2attr+self::$jsAddress2workAttr as $js => $attr)
{
if (isset($address[$js]) && !is_string($address[$js]))
@ -637,12 +663,20 @@ class JsContact
* As we have only 2 address-lines, we combine all components, with one space as separator, if none given.
* Then we split it into 2 lines.
*
* @param array $components
* @param bool $check_at_type true: check if objects have their proper @type attribute
* @param array|string $components string only for relaxed parsing
* @param bool $stict true: check if objects have their proper @type attribute
* @return string[] street and street2 values
*/
protected static function parseStreetComponents(array $components, bool $check_at_type=true)
protected static function parseStreetComponents($components, bool $stict=true)
{
if (!$stict && is_string($components))
{
$components = [['type' => 'name', 'value' => $components]];
}
if (!is_array($components))
{
throw new \InvalidArgumentException("Invalid street-components: ".json_encode($components, self::JSON_OPTIONS_ERROR));
}
$street = [];
$last_type = null;
foreach($components as $component)
@ -651,7 +685,7 @@ class JsContact
{
throw new \InvalidArgumentException("Invalid street-component: ".json_encode($component, self::JSON_OPTIONS_ERROR));
}
if ($check_at_type && $component[self::AT_TYPE] !== self::TYPE_STREET_COMPONENT)
if ($stict && $component[self::AT_TYPE] !== self::TYPE_STREET_COMPONENT)
{
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($component, self::JSON_OPTIONS_ERROR));
}
@ -712,21 +746,25 @@ class JsContact
* Parse phone objects
*
* @param array $phones $id => object with attribute "phone" and optional "features" and "context"
* @param bool $check_at_type true: check if objects have their proper @type attribute
* @param bool $stict true: check if objects have their proper @type attribute
* @return array
*/
protected static function parsePhones(array $phones, bool $check_at_type=true)
protected static function parsePhones(array $phones, bool $stict=true)
{
$contact = [];
// check for good matches
foreach($phones as $id => $phone)
{
if (!$stict && is_string($phone))
{
$phone = ['phone' => $phone];
}
if (!is_array($phone) || !is_string($phone['phone']))
{
throw new \InvalidArgumentException("Invalid phone: " . json_encode($phone, self::JSON_OPTIONS_ERROR));
}
if ($check_at_type && $phone[self::AT_TYPE] !== self::TYPE_PHONE)
if ($stict && $phone[self::AT_TYPE] !== self::TYPE_PHONE)
{
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($phone, self::JSON_OPTIONS_ERROR));
}
@ -818,19 +856,23 @@ class JsContact
* We currently only support 2 URLs, rest get's ignored!
*
* @param array $values
* @param bool $check_at_type true: check if objects have their proper @type attribute
* @param bool $stict true: check if objects have their proper @type attribute
* @return array
*/
protected static function parseOnline(array $values, bool $check_at_type)
protected static function parseOnline(array $values, bool $stict)
{
$contact = [];
foreach($values as $id => $value)
{
if (!$stict && is_string($value))
{
$value = ['resource' => $value];
}
if (!is_array($value) || !is_string($value['resource']))
{
throw new \InvalidArgumentException("Invalid online resource with id '$id': ".json_encode($value, self::JSON_OPTIONS_ERROR));
}
if ($check_at_type && $value[self::AT_TYPE] !== self::TYPE_RESOURCE)
if ($stict && $value[self::AT_TYPE] !== self::TYPE_RESOURCE)
{
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($value, self::JSON_OPTIONS_ERROR));
}
@ -889,15 +931,19 @@ class JsContact
*
* @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.3.1
* @param array $emails id => object with attribute "email" and optional "context"
* @param bool $check_at_type true: check if objects have their proper @type attribute
* @param bool $stict true: check if objects have their proper @type attribute
* @return array
*/
protected static function parseEmails(array $emails, bool $check_at_type=true)
protected static function parseEmails(array $emails, bool $stict=true)
{
$contact = [];
foreach($emails as $id => $value)
{
if ($check_at_type && $value[self::AT_TYPE] !== self::TYPE_EMAIL)
if (!$stict && is_string($value))
{
$value = ['email' => $value];
}
if ($stict && $value[self::AT_TYPE] !== self::TYPE_EMAIL)
{
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($value, self::JSON_OPTIONS_ERROR));
}
@ -953,11 +999,11 @@ class JsContact
* @return array
* @ToDo
*/
protected static function parsePhotos(array $photos, bool $check_at_type)
protected static function parsePhotos(array $photos, bool $stict)
{
foreach($photos as $id => $photo)
{
if ($check_at_type && $photo[self::AT_TYPE] !== self::TYPE_FILE)
if ($stict && $photo[self::AT_TYPE] !== self::TYPE_FILE)
{
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($photo, self::JSON_OPTIONS_ERROR));
}
@ -1008,18 +1054,23 @@ class JsContact
* @param array $components
* @return array
*/
protected static function parseNameComponents(array $components, bool $check_at_type=true)
protected static function parseNameComponents(array $components, bool $stict=true)
{
$contact = array_combine(array_values(self::$nameType2attribute),
array_fill(0, count(self::$nameType2attribute), null));
foreach($components as $component)
foreach($components as $type => $component)
{
// for relaxed checks, allow $type => $value pairs
if (!$stict && is_string($type) && is_scalar($component))
{
$component = ['type' => $type, 'value' => $component];
}
if (empty($component['type']) || isset($component) && !is_string($component['value']))
{
throw new \InvalidArgumentException("Invalid name-component (must have type and value attributes): ".json_encode($component, self::JSON_OPTIONS_ERROR));
}
if ($check_at_type && $component[self::AT_TYPE] !== self::TYPE_NAME_COMPONENT)
if ($stict && $component[self::AT_TYPE] !== self::TYPE_NAME_COMPONENT)
{
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($component, self::JSON_OPTIONS_ERROR));
}
@ -1052,14 +1103,28 @@ class JsContact
*
* @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.1
* @param array $anniversaries id => object with attribute date and optional type
* @param bool $check_at_type true: check if objects have their proper @type attribute
* @param bool $stict true: check if objects have their proper @type attribute
* @return array
*/
protected static function parseAnniversaries(array $anniversaries, bool $check_at_type=true)
protected static function parseAnniversaries(array $anniversaries, bool $stict=true)
{
$contact = [];
foreach($anniversaries as $id => $anniversary)
{
if (!$stict && is_string($anniversary))
{
// allow German date format "dd.mm.yyyy"
if (preg_match('/^(\d+)\.(\d+).(\d+)$/', $anniversary, $matches))
{
$matches = sprintf('%04d-%02d-%02d', (int)$matches[3], (int)$matches[2], (int)$matches[1]);
}
// allow US date format "mm/dd/yyyy"
elseif (preg_match('#^(\d+)/(\d+)/(\d+)$#', $anniversary, $matches))
{
$matches = sprintf('%04d-%02d-%02d', (int)$matches[3], (int)$matches[1], (int)$matches[2]);
}
$anniversary = ['type' => $id, 'date' => $anniversary];
}
if (!is_array($anniversary) || !is_string($anniversary['date']) ||
!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anniversary['date']) ||
(!list($year, $month, $day) = explode('-', $anniversary['date'])) ||
@ -1067,7 +1132,7 @@ class JsContact
{
throw new \InvalidArgumentException("Invalid anniversary object with id '$id': ".json_encode($anniversary, self::JSON_OPTIONS_ERROR));
}
if ($check_at_type && $anniversary[self::AT_TYPE] !== self::TYPE_ANNIVERSARY)
if ($stict && $anniversary[self::AT_TYPE] !== self::TYPE_ANNIVERSARY)
{
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($anniversary, self::JSON_OPTIONS_ERROR));
}
@ -1251,16 +1316,51 @@ class JsContact
return $members;
}
/**
* Patch JsCard
*
* @param array $patches JSON path
* @param array $jscard to patch
* @param bool $create =false true: create missing components
* @return array patched $jscard
*/
public static function patch(array $patches, array $jscard, bool $create=false)
{
foreach($patches as $path => $value)
{
$parts = explode('/', $path);
$target = &$jscard;
foreach($parts as $n => $part)
{
if (!isset($target[$part]) && $n < count($parts)-1 && !$create)
{
throw new \InvalidArgumentException("Trying to patch not existing attribute with path $path!");
}
$parent = $target;
$target = &$target[$part];
}
if (isset($value))
{
$target = $value;
}
else
{
unset($parent[$part]);
}
}
return $jscard;
}
/**
* Map all kind of exceptions while parsing to a JsContactParseException
*
* @param \Throwable $e
* @param string $type
* @param string $name
* @param ?string $name
* @param mixed $value
* @throws JsContactParseException
*/
protected static function handleExceptions(\Throwable $e, $type='JsContact', string $name, $value)
protected static function handleExceptions(\Throwable $e, $type='JsContact', ?string $name, $value)
{
try {
throw $e;

View File

@ -253,6 +253,29 @@ Location: https://example.org/egroupware/groupdav.php/<username>/addressbook/123
```
</details>
<details>
<summary>Example: POST request to create a new resource using flat attributes (JSON patch syntax) eg. for a simple Wordpress contact-form</summary>
```
cat <<EOF | curl -i 'https://example.org/egroupware/groupdav.php/<username>/addressbook/' -X POST -d @- -H "Content-Type: application/json" --user <username>
{
"name/personal": "First",
"name/surname": "Tester",
"organizations/org/name": "Test Organization",
"emails/work": "test.user@test-user.org",
"addresses/work/locality": "Test-Town",
"addresses/work/postcode": "12345",
"addresses/work/street": "Teststr. 123",
"phones/tel_work": "+49 123 4567890",
"online/url": "https://www.example.org/"
}
EOF
HTTP/1.1 201 Created
Location: https://example.org/egroupware/groupdav.php/<username>/addressbook/1234
```
</details>
* **PUT** requests with a ```Content-Type: application/json``` header allow modifying single resources
* **DELETE** requests delete single resources