WIP REST Api for contacts

This commit is contained in:
Ralf Becker 2021-09-16 20:53:43 +02:00
parent fc3aba9c39
commit 0768f5fadf
4 changed files with 239 additions and 48 deletions

View File

@ -73,9 +73,14 @@ class addressbook_groupdav extends Api\CalDAV\Handler
$this->bo = new Api\Contacts();
if (Api\CalDAV::isJSON())
{
self::$path_attr = 'id';
self::$path_extension = '';
}
// since 1.9.007 we allow clients to specify the URL when creating a new contact, as specified by CardDAV
// LDAP does NOT have a carddav_name attribute --> stick with id mapped to LDAP attribute uid
if (version_compare($GLOBALS['egw_info']['apps']['api']['version'], '1.9.007', '<') ||
elseif (version_compare($GLOBALS['egw_info']['apps']['api']['version'], '1.9.007', '<') ||
$this->bo->contact_repository != 'sql' ||
$this->bo->account_repository != 'sql' && strpos($_SERVER['REQUEST_URI'].'/','/addressbook-accounts/') !== false)
{
@ -173,12 +178,12 @@ class addressbook_groupdav extends Api\CalDAV\Handler
if ($options['root']['name'] == 'sync-collection' && $this->bo->total > $nresults)
{
--$this->sync_collection_token;
$files['sync-token-params'][] = true; // tel get_sync_collection_token that we have more entries
$files['sync-token-params'][] = true; // tell get_sync_collection_token that we have more entries
}
}
else
{
// return iterator, calling ourself to return result in chunks
// return iterator, calling ourselves to return result in chunks
$files['files'] = new Api\CalDAV\PropfindIterator($this,$path,$filter,$files['files']);
}
return true;
@ -270,6 +275,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
}
}
$is_jscontact = Api\CalDAV::isJSON();
foreach($contacts as &$contact)
{
// remove contact from requested multiget ids, to be able to report not found urls
@ -284,15 +290,16 @@ class addressbook_groupdav extends Api\CalDAV\Handler
continue;
}
$props = array(
'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', 'text/vcard'),
'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', $is_jscontact ? JsContact::MIME_TYPE_JSCARD : 'text/vcard'),
'getlastmodified' => $contact['modified'],
'displayname' => $contact['n_fn'],
);
if ($address_data)
{
$content = $handler->getVCard($contact['id'],$this->charset,false);
$content = $is_jscontact ? JsContact::getJsCard($contact['id'], false) :
$handler->getVCard($contact['id'],$this->charset,false);
$props['getcontentlength'] = bytes($content);
$props[] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content);
$props['address-data'] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content);
}
$files[] = $this->add_resource($path, $contact, $props);
}
@ -351,16 +358,16 @@ class addressbook_groupdav extends Api\CalDAV\Handler
$etag .= ':'.implode('-',$filter['owner']);
}
$props = array(
'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', 'text/vcard'),
'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', $is_jscontact ? JsContact::MIME_TYPE_JSCARDGROUP : 'text/vcard'),
'getlastmodified' => Api\DateTime::to($list['list_modified'],'ts'),
'displayname' => $list['list_name'],
'getetag' => '"'.$etag.'"',
);
if ($address_data)
{
$content = $handler->getGroupVCard($list);
$content = $is_jscontact ? JsContact::getJsCardGroup($list) : $handler->getGroupVCard($list);
$props['getcontentlength'] = bytes($content);
$props[] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content);
$props['address-data'] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content);
}
$files[] = $this->add_resource($path, $list, $props);
@ -452,7 +459,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
}
else
{
switch($filter['attrs']['collation']) // todo: which other collations allowed, we are allways unicode
switch($filter['attrs']['collation']) // todo: which other collations allowed, we are always unicode
{
case 'i;unicode-casemap':
default:
@ -590,7 +597,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
return $contact;
}
// jsContact or vCard
if (JsContact::isJsContact())
if (Api\CalDAV::isJSON())
{
$options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact) : JsContact::getJsCard($contact);
$options['mimetype'] = $contact['list_id'] ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD;
@ -628,7 +635,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler
return $oldContact;
}
if (JsContact::isJsContact())
if (Api\CalDAV::isJSON())
{
$contact = JsContact::parseJsCard($options['content']);
// just output it again for now

View File

@ -18,6 +18,8 @@ use EGroupware\Api\CalDAV\Principals;
// explicit import non-namespaced classes
require_once(__DIR__.'/WebDAV/Server.php');
use EGroupware\Api\Contacts\JsContact;
use HTTP_WebDAV_Server;
use calendar_hooks;
@ -976,6 +978,21 @@ class CalDAV extends HTTP_WebDAV_Server
parent::http_PROPFIND('REPORT');
}
/**
* Check if clients want's or sends JSON
*
* @return bool
*/
public static function isJSON(string $type=null)
{
if (!isset($type))
{
$type = in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST', 'PROPPATCH']) ?
$_SERVER['HTTP_CONTENT_TYPE'] : $_SERVER['HTTP_ACCEPT'];
}
return (bool)preg_match('#application/([^+ ;]+\+)?json#', $type);
}
/**
* GET method handler
*
@ -989,6 +1006,10 @@ class CalDAV extends HTTP_WebDAV_Server
$id = $app = $user = null;
if (!$this->_parse_path($options['path'],$id,$app,$user) || $app == 'principals')
{
if (self::isJSON())
{
return $this->jsonIndex($options);
}
return $this->autoindex($options);
}
if (($handler = self::app_handler($app)))
@ -999,6 +1020,170 @@ class CalDAV extends HTTP_WebDAV_Server
return '501 Not Implemented';
}
const JSON_OPTIONS = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR;
const JSON_OPTIONS_PRETTY = JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR;
/**
* JSON encode incl. modified pretty-print
*
* @param $data
* @return array|string|string[]|null
*/
public static function json_encode($data, $pretty = true)
{
if (!$pretty)
{
return self::json_encode($data, self::JSON_OPTIONS);
}
return preg_replace('/: {\n\s*(.*?)\n\s*(},?\n)/', ': { $1 $2',
json_encode($data, self::JSON_OPTIONS_PRETTY));
}
/**
* PROPFIND/REPORT like output for GET request on collection with Accept: application/(.*+)?json
*
* For addressbook-collections we give a REST-like output without any other properties
* {
* "/addressbook/ID": {
* JsContact-data
* },
* ...
* }
*
* @param array $options
* @return bool|string|void
*/
protected function jsonIndex(array $options)
{
header('Content-Type: application/json; charset=utf-8');
$is_addressbook = strpos($options['path'], '/addressbook') !== false;
$propfind_options = array(
'path' => $options['path'],
'depth' => 1,
'props' => $is_addressbook ? [
'address-data' => self::mkprop(self::CARDDAV, 'address-data', '')
] : 'all',
'other' => [],
);
// sync-collection report via GET parameter sync-token
if (isset($_GET['sync-token']))
{
$propfind_options['root'] = ['name' => 'sync-collection'];
$propfind_options['other'][] = ['name' => 'sync-token', 'data' => $_GET['sync-token']];
$propfind_options['other'][] = ['name' => 'sync-level', 'data' => $_GET['sync-level'] ?? 1];
// clients want's pagination
if (isset($_GET['nresults']))
{
$propfind_options['other'][] = ['name' => 'nresults', 'data' => (int)$_GET['nresults']];
}
}
// ToDo: client want data filtered
if (isset($_GET['filters']))
{
}
// properties to NOT get the default address-data for addressbook-collections and "all" for the rest
if (isset($_GET['props']))
{
$propfind_options['props'] = [];
foreach((array)$_GET['props'] as $value)
{
$parts = explode(':', $value);
$name = array_pop($parts);
$ns = $parts ? implode(':', $parts) : 'DAV:';
$propfind_options['props'][$name] = self::mkprop($ns, $name, '');
}
}
$files = array();
if (($ret = $this->REPORT($propfind_options,$files)) !== true)
{
return $ret; // no collection
}
echo "{\n";
$prefix = " ";
foreach($files['files'] as $resource)
{
$path = $resource['path'];
echo $prefix.json_encode($path, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE).': ';
if (!isset($resource['props']))
{
echo 'null'; // deleted in sync-report
}
/*elseif (isset($resource['props']['address-data']))
{
echo $resource['props']['address-data']['val'];
}*/
else
{
$props = $propfind_options['props'] === 'all' ? $resource['props'] :
array_intersect_key($resource['props'], $propfind_options['props']);
if (count($props) > 1)
{
$props = self::jsonProps($props);
}
else
{
$props = current($props)['val'];
}
echo self::json_encode($props);
}
$prefix = ",\n ";
}
// add sync-token to response
if (isset($files['sync-token']))
{
echo $prefix.'"sync-token": '.json_encode(!is_callable($files['sync-token']) ? $files['sync-token'] :
call_user_func_array($files['sync-token'], (array)$files['sync-token-params']), JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
}
echo "\n}\n";
// exit now, so WebDAV::GET does NOT add Content-Type: application/octet-stream
exit;
}
/**
* Nicer way to display/encode DAV properties
*
* @param array $props
* @return array
*/
protected function jsonProps(array $props)
{
$json = [];
foreach($props as $key => $prop)
{
if (is_scalar($prop['val']))
{
$value = is_int($key) && $prop['val'] === '' ?
/*$prop['ns'].':'.*/$prop['name'] : $prop['val'];
}
// check if this is a property-object
elseif (count($prop) === 3 && isset($prop['name']) && isset($prop['ns']) && isset($prop['val']))
{
$value = $prop['name'] === 'address-data' ? $prop['val'] : self::jsonProps($prop['val']);
}
else
{
$value = $prop;
}
if (is_int($key))
{
$json[] = $value;
}
else
{
$json[/*($prop['ns'] === 'DAV:' ? '' : $prop['ns'].':').*/$prop['name']] = $value;
}
}
return $json;
}
/**
* Display an automatic index (listing and properties) for a collection
*

View File

@ -711,10 +711,16 @@ abstract class Handler
{
//error_log(__METHOD__."('$path', $user, more_results=$more_results) this->sync_collection_token=".$this->sync_collection_token);
if ($more_results)
{
if (Api\CalDAV::isJSON())
{
$error = ",\n".' "more-results": true';
}
else
{
$error =
' <D:response>
<D:href>'.htmlspecialchars($this->caldav->base_uri.$this->caldav->path).'</D:href>
' <D:response>
<D:href>' . htmlspecialchars($this->caldav->base_uri . $this->caldav->path) . '</D:href>
<D:status>HTTP/1.1 507 Insufficient Storage</D:status>
<D:error><D:number-of-matches-within-limits/></D:error>
</D:response>
@ -723,6 +729,7 @@ abstract class Handler
{
$error = str_replace(array('<D:', '</D:'), array('<', '</'), $error);
}
}
echo $error;
}
return $this->get_sync_token($path, $user, $this->sync_collection_token);

View File

@ -26,39 +26,21 @@ class JsContact
const MIME_TYPE_JSCARDGROUP = "application/jscontact+json;type=cardgroup";
const MIME_TYPE_JSON = "application/json";
/**
* Check if request want's JSON
*
* @param ?string $type default use Content-Type or Accept HTTP header depending on request method
* @return bool true: jsContact, false: other eg. vCard
*/
public static function isJsContact(string $type=null)
{
if (!isset($type))
{
$type = in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST']) ?
$_SERVER['HTTP_CONTENT_TYPE'] : $_SERVER['HTTP_ACCEPT'];
}
return strpos($type, self::MIME_TYPE) !== false ||
strpos($type, self::MIME_TYPE_JSON) !== false;
}
const JSON_OPTIONS = JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE;
/**
* Get jsCard for given contact
*
* @param int|array $contact
* @return string
* @param bool $encode=true true: JSON encode, false: return raw data eg. from listing
* @return string|array
* @throws Api\Exception\NotFound
*/
public static function getJsCard($contact)
public static function getJsCard($contact, $encode=true)
{
if (is_scalar($contact) && !($contact = self::getContacts()->read($contact)))
{
throw new Api\Exception\NotFound();
}
return json_encode(array_filter([
$data = array_filter([
'uid' => $contact['uid'],
'prodId' => 'EGroupware Addressbook '.$GLOBALS['egw_info']['apps']['api']['version'],
'created' => self::UTCDateTime($contact['created']),
@ -90,7 +72,12 @@ class JsContact
'egroupware.org/customfields' => self::customfields($contact),
'egroupware.org/assistant' => $contact['assistent'],
'egroupware.org/fileAs' => $contact['fileas'],
]), self::JSON_OPTIONS);
]);
if ($encode)
{
return Api\CalDAV::json_encode($data);
}
return $data;
}
/**
@ -526,14 +513,19 @@ class JsContact
* @param ?string $street2=null 2. address line
* @return array[] array of objects with attributes type and value
*/
protected static function streetComponents(string $street, ?string $street2=null)
protected static function streetComponents(?string $street, ?string $street2=null)
{
$components = [['type' => 'name', 'value' => $street]];
if (!empty($street2))
$components = [];
foreach(func_get_args() as $street)
{
if ($components)
{
$components[] = ['type' => 'separator', 'value' => "\n"];
$components[] = ['type' => 'name', 'value' => $street2];
}
if (!empty($street))
{
$components[] = ['type' => 'name', 'value' => $street];
}
}
return $components;
}
@ -968,7 +960,7 @@ class JsContact
$vCard->setAttribute('UID',$list['list_uid']);
*/
return json_encode($group, self::JSON_OPTIONS);
return Api\CalDAV::json_encode($group);
}
/**