Contacts REST API:

- implement missing PUT/POST of distribution list / CardGroups
- implement changes from next JsContact draft:
  + string plus extra localizations attribute instead of localizedString object
  + use "<domain>:<name>" for vendor attributes
- add/parse urn:uuid: prefix if UID is a UUID
This commit is contained in:
Ralf Becker 2021-09-20 16:01:22 +02:00
parent 0a75520a01
commit 82c8ed51d2
5 changed files with 243 additions and 76 deletions

View File

@ -61,6 +61,11 @@ class addressbook_groupdav extends Api\CalDAV\Handler
*/ */
var $home_set_pref; var $home_set_pref;
/**
* Prefix for JsCardGroup id
*/
const JS_CARDGROUP_ID_PREFIX = 'list-';
/** /**
* Constructor * Constructor
* *
@ -350,7 +355,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
{ {
foreach($lists as $list) foreach($lists as $list)
{ {
$list[self::$path_attr] = $is_jscontact ? 'list-'.$list['list_id'] : $list['list_carddav_name']; $list[self::$path_attr] = $is_jscontact ? self::JS_CARDGROUP_ID_PREFIX.$list['list_id'] : $list['list_carddav_name'];
$etag = $list['list_id'].':'.$list['list_etag']; $etag = $list['list_id'].':'.$list['list_etag'];
// for all-in-one addressbook, add selected ABs to etag // for all-in-one addressbook, add selected ABs to etag
if (isset($filter['owner']) && is_array($filter['owner'])) if (isset($filter['owner']) && is_array($filter['owner']))
@ -637,9 +642,33 @@ class addressbook_groupdav extends Api\CalDAV\Handler
return $oldContact; return $oldContact;
} }
if (Api\CalDAV::isJSON()) $type = null;
if (($is_json=Api\CalDAV::isJSON($type)))
{ {
$contact = JsContact::parseJsCard($options['content']); if (strpos($type, JsContact::MIME_TYPE_JSCARD) === false && strpos($type, JsContact::MIME_TYPE_JSCARDGROUP) === false)
{
if (!empty($id))
{
$type = strpos($id, self::JS_CARDGROUP_ID_PREFIX) === 0 ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD;
}
else
{
$json = json_decode($options['content'], true);
$type = is_array($json['members']) ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD;
}
}
$contact = $type === JsContact::MIME_TYPE_JSCARD ?
JsContact::parseJsCard($options['content']) : JsContact::parseJsCardGroup($options['content']);
if (!empty($id) && strpos($id, self::JS_CARDGROUP_ID_PREFIX) === 0)
{
$id = substr($id, strlen(self::JS_CARDGROUP_ID_PREFIX));
}
elseif (empty($id))
{
$contact['cardav_name'] = $contact['uid'].'.vcf';
$contact['owner'] = $user;
}
/* uncomment to return parsed data for testing /* uncomment to return parsed data for testing
header('Content-Type: application/json'); header('Content-Type: application/json');
@ -687,7 +716,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
$contactId = -1; $contactId = -1;
$retval = '201 Created'; $retval = '201 Created';
} }
$is_group = $contact['##X-ADDRESSBOOKSERVER-KIND'] == 'group'; $is_group = isset($type) && $type === JsContact::MIME_TYPE_JSCARDGROUP || $contact['##X-ADDRESSBOOKSERVER-KIND'] === 'group';
if ($oldContact && $is_group !== isset($oldContact['list_id'])) if ($oldContact && $is_group !== isset($oldContact['list_id']))
{ {
throw new Api\Exception\AssertionFailed(__METHOD__."(,'$id',$user,'$prefix') can contact into group or visa-versa!"); throw new Api\Exception\AssertionFailed(__METHOD__."(,'$id',$user,'$prefix') can contact into group or visa-versa!");
@ -756,7 +785,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
if ($this->http_if_match) $contact['etag'] = self::etag2value($this->http_if_match); if ($this->http_if_match) $contact['etag'] = self::etag2value($this->http_if_match);
// ignore photo for JSON/REST, it's not yet supported // ignore photo for JSON/REST, it's not yet supported
$contact['photo_unchanged'] = Api\CalDAV::isJSON(); //false; // photo needs saving $contact['photo_unchanged'] = $is_json; //false; // photo needs saving
if (!($save_ok = $is_group ? $this->save_group($contact, $oldContact) : $this->bo->save($contact))) if (!($save_ok = $is_group ? $this->save_group($contact, $oldContact) : $this->bo->save($contact)))
{ {
if ($this->debug) error_log(__METHOD__."(,$id) save(".array2string($contact).") failed, Ok=$save_ok"); if ($this->debug) error_log(__METHOD__."(,$id) save(".array2string($contact).") failed, Ok=$save_ok");
@ -775,7 +804,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
{ {
if (($contact = $this->bo->read_list($save_ok))) if (($contact = $this->bo->read_list($save_ok)))
{ {
// re-read group to get correct etag (not dublicate etag code here) // re-read group to get correct etag (not duplicate etag code here)
$contact = $this->read($contact['list_'.self::$path_attr], $options['path']); $contact = $this->read($contact['list_'.self::$path_attr], $options['path']);
} }
} }
@ -787,14 +816,17 @@ class addressbook_groupdav extends Api\CalDAV\Handler
} }
// send evtl. necessary response headers: Location, etag, ... // send evtl. necessary response headers: Location, etag, ...
$this->put_response_headers($contact, $options['path'], $retval, self::$path_attr != 'id'); $this->put_response_headers($contact, $options['path'], $retval,
// JSON uses 'id', while CardDAV uses carddav_name !== 'id'
(self::$path_attr !== 'id') === !$is_json, null,
$is_group && $is_json ? self::JS_CARDGROUP_ID_PREFIX : '');
if ($this->debug > 1) error_log(__METHOD__."(,'$id', $user, '$prefix') returning ".array2string($retval)); if ($this->debug > 1) error_log(__METHOD__."(,'$id', $user, '$prefix') returning ".array2string($retval));
return $retval; return $retval;
} }
/** /**
* Save distribition-list / group * Save distribution-list / group
* *
* @param array $contact * @param array $contact
* @param array|false $oldContact * @param array|false $oldContact
@ -813,19 +845,22 @@ class addressbook_groupdav extends Api\CalDAV\Handler
$contact['owner'], null, $data))) $contact['owner'], null, $data)))
{ {
// update members given in $contact['##X-ADDRESSBOOKSERVER-MEMBER'] // update members given in $contact['##X-ADDRESSBOOKSERVER-MEMBER']
$new_members = $contact['##X-ADDRESSBOOKSERVER-MEMBER']; $new_members = $contact['members'] ?: $contact['##X-ADDRESSBOOKSERVER-MEMBER'];
if ($new_members[1] == ':' && ($n = unserialize($new_members))) if (is_string($new_members) && $new_members[1] === ':' && ($n = unserialize($new_members)))
{ {
$new_members = $n['values']; $new_members = $n['values'];
} }
else else
{ {
$new_members = array($new_members); $new_members = (array)$new_members;
} }
foreach($new_members as &$uid) foreach($new_members as &$uid)
{
if (substr($uid, 0, 9) === 'urn:uuid:')
{ {
$uid = substr($uid,9); // cut off "urn:uuid:" prefix $uid = substr($uid,9); // cut off "urn:uuid:" prefix
} }
}
if ($oldContact) if ($oldContact)
{ {
$to_add = array_diff($new_members,$oldContact['members']); $to_add = array_diff($new_members,$oldContact['members']);
@ -861,7 +896,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
// reread as update of list-members updates etag and modified // reread as update of list-members updates etag and modified
if (($contact = $this->bo->read_list($list_id))) if (($contact = $this->bo->read_list($list_id)))
{ {
// re-read group to get correct etag (not dublicate etag code here) // re-read group to get correct etag (not duplicate etag code here)
$contact = $this->read($contact['list_'.self::$path_attr]); $contact = $this->read($contact['list_'.self::$path_attr]);
} }
} }
@ -1059,7 +1094,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
$keys = ['tid' => $non_deleted_tids]; $keys = ['tid' => $non_deleted_tids];
// with REST/JSON we only use our id, but DELETE request has neither Accept nor Content-Type header to detect JSON request // with REST/JSON we only use our id, but DELETE request has neither Accept nor Content-Type header to detect JSON request
if (preg_match('/^(list-)?(\d+)$/', $id, $matches)) if (preg_match('/^('.self::JS_CARDGROUP_ID_PREFIX.')?(\d+)$/', $id, $matches))
{ {
if (!empty($matches[1])) if (!empty($matches[1]))
{ {

View File

@ -996,9 +996,10 @@ class CalDAV extends HTTP_WebDAV_Server
/** /**
* Check if client want or sends JSON * Check if client want or sends JSON
* *
* @param string &$type=null
* @return bool|string false: no json, true: application/json, string: application/(string)+json * @return bool|string false: no json, true: application/json, string: application/(string)+json
*/ */
public static function isJSON(string $type=null) public static function isJSON(string &$type=null)
{ {
if (!isset($type)) if (!isset($type))
{ {

View File

@ -570,8 +570,9 @@ abstract class Handler
* @param int|string $retval * @param int|string $retval
* @param boolean $path_attr_is_name =true true: path_attr is ca(l|rd)dav_name, false: id (GroupDAV needs Location header) * @param boolean $path_attr_is_name =true true: path_attr is ca(l|rd)dav_name, false: id (GroupDAV needs Location header)
* @param string $etag =null etag, to not calculate it again (if != null) * @param string $etag =null etag, to not calculate it again (if != null)
* @param string $prefix='' prefix for id
*/ */
function put_response_headers($entry, $path, $retval, $path_attr_is_name=true, $etag=null) function put_response_headers($entry, $path, $retval, $path_attr_is_name=true, $etag=null, string $prefix='')
{ {
//error_log(__METHOD__."(".array2string($entry).", '$path', ".array2string($retval).", path_attr_is_name=$path_attr_is_name, etag=".array2string($etag).")"); //error_log(__METHOD__."(".array2string($entry).", '$path', ".array2string($retval).", path_attr_is_name=$path_attr_is_name, etag=".array2string($etag).")");
// we should not return an etag here, as EGroupware never stores ical/vcard byte-by-byte // we should not return an etag here, as EGroupware never stores ical/vcard byte-by-byte
@ -589,9 +590,9 @@ abstract class Handler
// send Location header only on success AND if we dont use caldav_name as path-attribute or // send Location header only on success AND if we dont use caldav_name as path-attribute or
if ((is_bool($retval) ? $retval : $retval[0] === '2') && (!$path_attr_is_name || if ((is_bool($retval) ? $retval : $retval[0] === '2') && (!$path_attr_is_name ||
// POST with add-member query parameter // POST with add-member query parameter
$_SERVER['REQUEST_METHOD'] == 'POST' && isset($_GET['add-member'])) || $_SERVER['REQUEST_METHOD'] == 'POST') ||
// in case we choose to use a different name for the resourece, give the client a hint // in case we choose to use a different name for the resource, give the client a hint
basename($path) !== $this->new_id) basename($path) !== $prefix.$this->new_id)
{ {
$path = preg_replace('|(.*)/[^/]*|', '\1/', $path); $path = preg_replace('|(.*)/[^/]*|', '\1/', $path);
header('Location: '.$this->base_uri.$path.$this->new_id); header('Location: '.$this->base_uri.$path.$this->new_id);

View File

@ -41,14 +41,14 @@ class JsContact
throw new Api\Exception\NotFound(); throw new Api\Exception\NotFound();
} }
$data = array_filter([ $data = array_filter([
'uid' => $contact['uid'], 'uid' => self::uid($contact['uid']),
'prodId' => 'EGroupware Addressbook '.$GLOBALS['egw_info']['apps']['api']['version'], 'prodId' => 'EGroupware Addressbook '.$GLOBALS['egw_info']['apps']['api']['version'],
'created' => self::UTCDateTime($contact['created']), 'created' => self::UTCDateTime($contact['created']),
'updated' => self::UTCDateTime($contact['modified']), 'updated' => self::UTCDateTime($contact['modified']),
//'kind' => '', // 'individual' or 'org' //'kind' => '', // 'individual' or 'org'
//'relatedTo' => [], //'relatedTo' => [],
'name' => self::nameComponents($contact), 'name' => self::nameComponents($contact),
'fullName' => self::localizedString($contact['n_fn']), 'fullName' => $contact['n_fn'],
//'nickNames' => [], //'nickNames' => [],
'organizations' => array_filter(['org' => self::organization($contact)]), 'organizations' => array_filter(['org' => self::organization($contact)]),
'titles' => self::titles($contact), 'titles' => self::titles($contact),
@ -74,11 +74,11 @@ class JsContact
]), ]),
'photos' => self::photos($contact), 'photos' => self::photos($contact),
'anniversaries' => self::anniversaries($contact), 'anniversaries' => self::anniversaries($contact),
'notes' => empty($contact['note']) ? null : [self::localizedString($contact['note'])], 'notes' => empty($contact['note']) ? null : [$contact['note']],
'categories' => self::categories($contact['cat_id']), 'categories' => self::categories($contact['cat_id']),
'egroupware.org/customfields' => self::customfields($contact), 'egroupware.org:customfields' => self::customfields($contact),
'egroupware.org/assistant' => $contact['assistent'], 'egroupware.org:assistant' => $contact['assistent'],
'egroupware.org/fileAs' => $contact['fileas'], 'egroupware.org:fileAs' => $contact['fileas'],
]); ]);
if ($encode) if ($encode)
{ {
@ -119,7 +119,7 @@ class JsContact
break; break;
case 'fullName': case 'fullName':
$contact['n_fn'] = self::parseLocalizedString($value); $contact['n_fn'] = self::parseString($value);
break; break;
case 'organizations': case 'organizations':
@ -156,7 +156,7 @@ class JsContact
case 'notes': case 'notes':
$contact['note'] = implode("\n", array_map(static function ($note) { $contact['note'] = implode("\n", array_map(static function ($note) {
return self::parseLocalizedString($note); return self::parseString($note);
}, $value)); }, $value));
break; break;
@ -164,15 +164,15 @@ class JsContact
$contact['cat_id'] = self::parseCategories($value); $contact['cat_id'] = self::parseCategories($value);
break; break;
case 'egroupware.org/customfields': case 'egroupware.org:customfields':
$contact += self::parseCustomfields($value); $contact += self::parseCustomfields($value);
break; break;
case 'egroupware.org/assistant': case 'egroupware.org:assistant':
$contact['assistent'] = $value; $contact['assistent'] = $value;
break; break;
case 'egroupware.org/fileAs': case 'egroupware.org:fileAs':
$contact['fileas'] = $value; $contact['fileas'] = $value;
break; break;
@ -188,28 +188,47 @@ class JsContact
} }
} }
} }
catch (\JsonException $e) {
throw new JsContactParseException("Error parsing JSON: ".$e->getMessage(), 422, $e);
}
catch (\InvalidArgumentException $e) {
throw new JsContactParseException("Error parsing JsContact field '$name': ".
str_replace('"', "'", $e->getMessage()), 422);
}
catch (\TypeError $e) {
$message = $e->getMessage();
if (preg_match('/must be of the type ([^ ]+), ([^ ]+) given/', $message, $matches))
{
$message = "$matches[1] expected, but got $matches[2]: ".
str_replace('"', "'", json_encode($value, self::JSON_OPTIONS_ERROR));
}
throw new JsContactParseException("Error parsing JsContact field '$name': $message", 422, $e);
}
catch (\Throwable $e) { catch (\Throwable $e) {
throw new JsContactParseException("Error parsing JsContact field '$name': ". $e->getMessage(), 422, $e); self::handleExceptions($e, 'JsContact Card', $name, $value);
} }
return $contact; return $contact;
} }
const URN_UUID_PREFIX = 'urn:uuid:';
const UUID_PREG = '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i';
/**
* Get UID with either "urn:uuid:" prefix for UUIDs or just the text
*
* @param string $uid
* @return string
*/
protected static function uid(string $uid)
{
return preg_match(self::UUID_PREG, $uid) ? self::URN_UUID_PREFIX.$uid : $uid;
}
/**
* Parse and optionally generate UID
*
* @param string|null $uid
* @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)
{
if (empty($uid) || strlen($uid) < 12)
{
if (!$generate_when_empty)
{
throw new \InvalidArgumentException("Invalid or missing UID: ".json_encode($uid));
}
$uid = \HTTP_WebDAV_Server::_new_uuid();
}
return strpos($uid, self::URN_UUID_PREFIX) === 0 ? substr($uid, 9) : $uid;
}
/** /**
* JSON options for errors thrown as exceptions * JSON options for errors thrown as exceptions
*/ */
@ -229,8 +248,8 @@ class JsContact
return null; // name is mandatory return null; // name is mandatory
} }
return array_filter([ return array_filter([
'name' => self::localizedString($contact['org_name']), 'name' => $contact['org_name'],
'units' => empty($contact['org_unit']) ? null : ['org_unit' => self::localizedString($contact['org_unit'])], 'units' => empty($contact['org_unit']) ? null : ['org_unit' => $contact['org_unit']],
]); ]);
} }
@ -247,10 +266,10 @@ class JsContact
$contact = []; $contact = [];
foreach($orgas as $orga) foreach($orgas as $orga)
{ {
$contact['org_name'] = self::parseLocalizedString($orga['name']); $contact['org_name'] = self::parseString($orga['name']);
$contact['org_unit'] = implode(' ', array_map(static function($unit) $contact['org_unit'] = implode(' ', array_map(static function($unit)
{ {
return self::parseLocalizedString($unit); return self::parseString($unit);
}, $orga['units'])); }, $orga['units']));
break; break;
} }
@ -270,8 +289,8 @@ class JsContact
protected static function titles(array $contact) protected static function titles(array $contact)
{ {
return array_filter([ return array_filter([
'title' => self::localizedString($contact['title']), 'title' => $contact['title'],
'role' => self::localizedString($contact['role']), 'role' => $contact['role'],
]); ]);
} }
@ -286,21 +305,21 @@ class JsContact
$contact = []; $contact = [];
if (isset($titles[$id='title']) || isset($contact[$id='jobTitle'])) if (isset($titles[$id='title']) || isset($contact[$id='jobTitle']))
{ {
$contact['title'] = self::parseLocalizedString($titles[$id]); $contact['title'] = self::parseString($titles[$id]);
unset($titles[$id]); unset($titles[$id]);
} }
if (isset($titles[$id='role'])) if (isset($titles[$id='role']))
{ {
$contact['role'] = self::parseLocalizedString($titles[$id]); $contact['role'] = self::parseString($titles[$id]);
unset($titles[$id]); unset($titles[$id]);
} }
if (!isset($contact['title']) && $titles) if (!isset($contact['title']) && $titles)
{ {
$contact['title'] = self::parseLocalizedString(array_shift($titles)); $contact['title'] = self::parseString(array_shift($titles));
} }
if (!isset($contact['role']) && $titles) if (!isset($contact['role']) && $titles)
{ {
$contact['role'] = self::parseLocalizedString(array_shift($titles)); $contact['role'] = self::parseString(array_shift($titles));
} }
if (count($titles)) if (count($titles))
{ {
@ -481,10 +500,10 @@ class JsContact
'street' => self::streetComponents($contact[$prefix.'street'], $contact[$prefix.'street2']), 'street' => self::streetComponents($contact[$prefix.'street'], $contact[$prefix.'street2']),
]); ]);
// only add contexts and preference to non-empty address // only add contexts and preference to non-empty address
return !$address ? [] : $address+[ return !$address ? [] : array_filter($address+[
'contexts' => [$type => true], 'contexts' => [$type => true],
'pref' => $preference, 'pref' => $preference,
]; ]);
} }
/** /**
@ -946,16 +965,12 @@ class JsContact
* *
* We're not currently storing/allowing any localization --> they get ignored/thrown away! * We're not currently storing/allowing any localization --> they get ignored/thrown away!
* *
* @param array $value object with attribute "value" * @param string $value =null
* @return string * @return string
*/ */
protected static function parseLocalizedString(array $value) protected static function parseString(string $value=null)
{ {
if (!is_string($value['value'])) return $value;
{
throw new \InvalidArgumentException("Invalid localizedString: ".json_encode($value, self::JSON_OPTIONS_ERROR));
}
return $value['value'];
} }
/** /**
@ -996,10 +1011,10 @@ class JsContact
throw new Api\Exception\NotFound(); throw new Api\Exception\NotFound();
} }
$data = array_filter([ $data = array_filter([
'uid' => $group['list_uid'], 'uid' => self::uid($group['list_uid']),
'name' => $group['list_name'], 'name' => $group['list_name'],
'card' => self::getJsCard([ 'card' => self::getJsCard([
'uid' => $group['list_uid'], 'uid' => self::uid($group['list_uid']),
'n_fn' => $group['list_name'], // --> fullName 'n_fn' => $group['list_name'], // --> fullName
'modified' => $group['list_modified'], // no other way to send modification date 'modified' => $group['list_modified'], // no other way to send modification date
], false), ], false),
@ -1007,7 +1022,7 @@ class JsContact
]); ]);
foreach($group['members'] as $uid) foreach($group['members'] as $uid)
{ {
$data['members'][$uid] = true; $data['members'][self::uid($uid)] = true;
} }
if ($encode) if ($encode)
{ {
@ -1016,6 +1031,116 @@ class JsContact
return $data; return $data;
} }
/**
* Parse JsCard
*
* @param string $json
* @return array
*/
public static function parseJsCardGroup(string $json)
{
try
{
$data = json_decode($json, true, 10, JSON_THROW_ON_ERROR);
if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist
// make sure missing mandatory members give an error
$data += ['uid' => null, 'members' => null];
$group = [];
foreach ($data as $name => $value)
{
switch ($name)
{
case 'uid':
$group['uid'] = self::parseUid($value);
break;
case 'name':
$group['n_fn'] = $value;
break;
case 'card':
$card = self::parseJsCard(json_encode($value, self::JSON_OPTIONS_ERROR));
// prefer name over card-fullName
if (!empty($card['n_fn']) && empty($group['n_fn']))
{
$group['n_fn'] = $card['n_fn'];
}
break;
case 'members':
$group['members'] = self::parseMembers($value);
break;
default:
error_log(__METHOD__ . "() $name=" . json_encode($value, self::JSON_OPTIONS_ERROR) . ' --> ignored');
break;
}
}
}
catch (\Throwable $e) {
self::handleExceptions($e, 'JsContact CardGroup', $name, $value);
}
return $group;
}
/**
* Parse members object
*
* @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-3.1.2
* @param array $values uid => true pairs
* @return array of uid's
*/
protected static function parseMembers(array $values)
{
$members = [];
foreach($values as $uid => $value)
{
if (!is_string($uid) || $value !== true)
{
throw new \InvalidArgumentException('Invalid members object: '.json_encode($values, self::JSON_OPTIONS_ERROR));
}
$members[] = self::parseUid($uid);
}
return $members;
}
/**
* Map all kind of exceptions while parsing to a JsContactParseException
*
* @param \Throwable $e
* @param string $type
* @param string $name
* @param mixed $value
* @throws JsContactParseException
*/
protected static function handleExceptions(\Throwable $e, $type='JsContact', string $name, $value)
{
try {
throw $e;
}
catch (\JsonException $e) {
throw new JsContactParseException("Error parsing JSON: ".$e->getMessage(), 422, $e);
}
catch (\InvalidArgumentException $e) {
throw new JsContactParseException("Error parsing $type attribute '$name': ".
str_replace('"', "'", $e->getMessage()), 422);
}
catch (\TypeError $e) {
$message = $e->getMessage();
if (preg_match('/must be of the type ([^ ]+( or [^ ]+)*), ([^ ]+) given/', $message, $matches))
{
$message = "$matches[1] expected, but got $matches[3]: ".
str_replace('"', "'", json_encode($value, self::JSON_OPTIONS_ERROR));
}
throw new JsContactParseException("Error parsing $type attribute '$name': $message", 422, $e);
}
catch (\Throwable $e) {
throw new JsContactParseException("Error parsing $type attribute '$name': ". $e->getMessage(), 422, $e);
}
}
/** /**
* @return Api\Contacts * @return Api\Contacts
*/ */

View File

@ -41,7 +41,8 @@ from the data of a allprop PROPFIND, allow browsing CalDAV/CardDAV tree with a r
> currently implemented only for contacts! > currently implemented only for contacts!
Following RFCs / drafts used/planned for JSON encoding of ressources Following RFCs / drafts used/planned for JSON encoding of ressources
* [draft-ietf-jmap-jscontact: JSContact: A JSON Representation of Contact Data](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) * [draft-ietf-jmap-jscontact: JSContact: A JSON Representation of Contact Data](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) ([*](#implemented-changes-to-jscontact-draft-07-from-next-draft))
* [draft-ietf-jmap-jscontact-vcard-06: JSContact: Converting from and to vCard](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-vcard/)
* [rfc8984: JSCalendar: A JSON Representation of Calendar Data](https://datatracker.ietf.org/doc/html/rfc8984) * [rfc8984: JSCalendar: A JSON Representation of Calendar Data](https://datatracker.ietf.org/doc/html/rfc8984)
### Supported request methods and examples ### Supported request methods and examples
@ -66,9 +67,9 @@ curl https://example.org/egroupware/groupdav.php/<username>/addressbook/ -H "Acc
"fullName": { "value": "Default Tester" }, "fullName": { "value": "Default Tester" },
"organizations": { "organizations": {
"org": { "org": {
"name": { "value": "default.org" }, "name": "default.org",
"units": { "units": {
"org_unit": { "value": "department.default.org" } "org_unit": "department.default.org"
} }
} }
}, },
@ -83,7 +84,7 @@ curl https://example.org/egroupware/groupdav.php/<username>/addressbook/ -H "Acc
"url": { "resource": "https://www.test.com/", "type": "uri", "contexts": { "work": true } } "url": { "resource": "https://www.test.com/", "type": "uri", "contexts": { "work": true } }
}, },
"notes": [ "notes": [
{ "value": "Test test TEST\n\\server\\share\n\\\nother\nblah" } "Test test TEST\n\\server\\share\n\\\nother\nblah"
], ],
}, },
"/<username>/addressbook/list-36": { "/<username>/addressbook/list-36": {
@ -96,7 +97,7 @@ curl https://example.org/egroupware/groupdav.php/<username>/addressbook/ -H "Acc
"fullName": { "value": "Example distribution list" } "fullName": { "value": "Example distribution list" }
}, },
"members": { "members": {
"5638-8623c4830472a8ede9f9f8b30d435ea4": true "urn:uuid:5638-8623c4830472a8ede9f9f8b30d435ea4": true
} }
} }
} }
@ -194,7 +195,7 @@ curl 'https://example.org/egroupware/groupdav.php/addressbook/?sync-token=https:
{ "type": "personal", "value": "Default" }, { "type": "personal", "value": "Default" },
{ "type": "surname", "value": "Tester" } { "type": "surname", "value": "Tester" }
], ],
"fullName": { "value": "Default Tester" }, "fullName": "Default Tester",
.... ....
} }
}, },
@ -218,7 +219,7 @@ curl 'https://example.org/egroupware/groupdav.php/addressbook/5593' -H "Accept:
{ "type": "personal", "value": "Default" }, { "type": "personal", "value": "Default" },
{ "type": "surname", "value": "Tester" } { "type": "surname", "value": "Tester" }
], ],
"fullName": { "value": "Default Tester" }, "fullName": "Default Tester",
.... ....
} }
``` ```
@ -255,3 +256,7 @@ Location: https://example.org/egroupware/groupdav.php/<username>/addressbook/123
* **DELETE** requests delete single resources * **DELETE** requests delete single resources
* one can use ```Accept: application/pretty+json``` to receive pretty-printed JSON eg. for debugging and exploring the API * one can use ```Accept: application/pretty+json``` to receive pretty-printed JSON eg. for debugging and exploring the API
#### Implemented changes to [JsContact draft 07](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) from next draft:
* localizedString type / object is removed in favor or regular String type and a [localizations object like in JsCalendar](https://datatracker.ietf.org/doc/html/rfc8984#section-4.6.1)
* [Vendor-specific Property Extensions and Values](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-1.3) use ```<domain-name>:<name>``` like in JsCalendar