forked from extern/egroupware
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:
parent
d9f759f517
commit
1280de46d6
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user