""" 'webservice' controller implementation
"""
import inspect
import itertools
import sys
from cubicweb.web.controller import Controller
from cubicweb.web import NotFound
from cubicweb.predicates import yes
from cubicweb import ValidationError, Forbidden
import cubicweb
import wsme
import wsme.rest.json
import wsme.rest.xml
from wsme.types import text
from cubes.wsme.types import PassThroughType, JsonData, wsattr, Any, Base
from cubes.wsme.predicates import match_ws_etype
from rqlquery.filter import FilterParser
from rqlquery import query
from rqlquery.entity import fill_relation_cache
restformats = {
'json': wsme.rest.json,
'xml': wsme.rest.xml
}
def get_fetchable_relations(user, eschema, fetchtree):
relations = []
for rschema in eschema.ordered_relations():
card = eschema.rdef(rschema.type, takefirst=True).cardinality[0]
if (card in ('*', '+')
# cardinality '?' with polymorphic relations raises
# https://www.cubicweb.org/ticket/4482382
or card == '?' and len(rschema.objects(eschema)) > 1):
continue
if not rschema.final and fetchtree.get(rschema.type):
continue
if rschema.type in (
'eid', 'has_text', 'cw_source', 'is'):
continue
if rschema.objects()[0].type in ('Password',):
continue
if not rschema.final and any(
not user.matching_groups(es.get_groups('read'))
for es in rschema.objects(eschema)):
continue
relations.append(rschema)
return relations
def tree_touch_path(tree, path):
node = tree
for name in path:
node = node.setdefault(name, {})
def to_fetchtree(fetchlist):
fetchtree = {}
for rel in fetchlist:
tree_touch_path(fetchtree, rel.split('.'))
return fetchtree
def prefetch(cnx, entities, fetchtree):
schema = cnx.vreg.schema
eschema = schema.eschema(entities[0].cw_etype)
for rel, subfetch in fetchtree.items():
if rel == '' or '[' in rel:
continue
role = 'object' if rel.startswith('<') else 'subject'
rtype = rel.strip('<>')
try:
rschema = schema.rschema(rtype)
except KeyError:
continue
if rschema.final:
continue
tgt_schemas = rschema.targets(eschema, role)
tgt_schema = tgt_schemas[0] if len(tgt_schemas) == 1 else None
attrs = [
attr for attr in subfetch
if attr == '' or schema.rschema(attr).final
]
fetchall = ('' in attrs or subfetch and not attrs) and tgt_schema
if fetchall:
fetchattrs = [
rs.type for rs in get_fetchable_relations(
cnx.user, tgt_schema, subfetch)
]
elif not attrs:
fetchattrs = ('modification_date',)
else:
fetchattrs = attrs
fill_relation_cache(cnx, entities, rtype, role, fetchattrs=fetchattrs)
if tgt_schema:
rel_targets = list(itertools.chain(
*[e.related(rtype, role, entities=True) for e in entities]))
if rel_targets:
prefetch(cnx, rel_targets, subfetch)
class signature(wsme.api.signature):
def __call__(self, func):
arg_names = self.options.pop('arg_names', None)
if arg_names:
arg_defaults = self.options.pop('arg_defaults', ())
arg_types = self.arg_types
self.arg_types = None
func = super(signature, self).__call__(func)
if arg_names:
fd = wsme.api.FunctionDefinition.get(func)
if fd.body_type is not None:
arg_types.append(fd.body_type)
for argname, datatype, default in itertools.izip_longest(
arg_names, arg_types, arg_defaults):
if datatype is wsme.types.HostRequest:
fd.pass_request = argname
else:
fd.arguments.append(wsme.api.FunctionArgument(
argname, datatype, False, default))
return func
class ParamsAdapter(dict):
""" Adapter for passing cubicweb request params to
:func:`wsme.rest.args.get_args`
"""
def getall(self, path):
v = self[path]
if not isinstance(v, list):
v = [v]
return v
[docs]class WSController(Controller):
"""
A controller that rely on WSME to provide webservice API for an entity.
"""
__regid__ = 'webservice'
__abstract__ = True
@classmethod
[docs] def resolve_types(cls, registry):
""" Late-resolve the types of the function signatures.
This function is called at regitering time (by :meth:`__registered__`).
"""
for name, attr in inspect.getmembers(cls, wsme.api.iswsmefunction):
funcdef = wsme.api.FunctionDefinition.get(attr)
funcdef.resolve_types(registry)
@classmethod
def __registered__(cls, reg):
cls.resolve_types(reg.vreg.wsme_registry)
[docs] def publish(self, rset):
""" Main entry-point of the controller.
Will dispatch the request to the adequate function depending on the
http method and the form/rset content.
It also takes care of converting the inputs (form & body) to call
arguments using the WSME api, based on the function signatures.
The following `form` values are used, which are normaly set by
:class:`cubes.wsme.views.RestPathEvaluator`:
- `_ws_method`: the HTTP method
- `_ws_etype`: the etype (ignored, only used by the selector)
- `_ws_rtype`: the relation type if provided
- `_ws_rtype_target`: An option relation target id
If the :arg:`rset` contains an entity, it will be considered as the
target of the API call.
"""
content_type = self._cw.get_header('Content-Type')
accept = self._cw.get_header('Accept')
restformat = None
for accept in self._cw.parse_accept_header('Accept'):
for candidate in restformats.values():
if accept in candidate.accept_content_types:
restformat = candidate
break
if restformat is not None:
break
if restformat is None:
for candidate in restformats.values():
if self._cw.content_type in candidate.accept_content_types:
restformat = candidate
break
if restformat is None:
restformat = restformats['json']
try:
methodname = self._cw.form.pop('_ws_method')
self._cw.form.pop('_ws_etype')
func = self._cw.form.pop('_ws_func', None)
rtype = self._cw.form.pop('_ws_rtype', None)
rtype_target = self._cw.form.pop('_ws_rtype_target', None)
prefix, args = '', []
if rset is not None:
if rset.rowcount == 1:
prefix = 'entity_'
args.append(rset.one())
elif rset.rowcount == 0:
raise NotFound()
if func:
prefix += 'func_' + func.replace('.', '_') + '_'
args.append(func)
if rtype:
prefix += 'rtype_'
args.append(rtype)
if rtype_target:
prefix += 'target_'
args.append(rtype_target)
methodname = prefix + methodname
method = getattr(self, methodname, None)
if method is None or not wsme.api.iswsmefunction(method):
# XXX Raise a 405 http error
raise NotFound()
funcdef = wsme.api.FunctionDefinition.get(method)
if func:
args.append(funcdef)
if '_' in self._cw.form:
del self._cw.form['_']
self._cw.content.seek(0)
content = self._cw.content.read()
self._cw.content.seek(0)
args, kwargs = wsme.rest.args.get_args(
funcdef, args, {}, ParamsAdapter(self._cw.form), None,
content, content_type
)
self._cw.set_content_type(restformat.content_type)
result = method(*args, **kwargs)
if isinstance(result, wsme.api.Response):
self._cw.status_out = result.status_code
result = result.obj
return restformat.encode_result(result, funcdef.return_type)
except NotFound:
self._cw.status_out = 404
return restformat.encode_error(None, {
'faultcode': 'Client',
'faultstring': 'Not Found'
})
except ValidationError, e:
fmtexc = {
'faultcode': 'Client',
'faultstring': "ValidationError",
'errors': e.errors
}
self._cw.status_out = 400
return restformat.encode_error(funcdef, fmtexc)
except:
self.exception('Error while calling method {}'.format(methodname))
self._cw.cnx.rollback()
exc_info = sys.exc_info()
fmtexc = wsme.api.format_exception(
exc_info, debug=self._cw.vreg.config.debugmode)
if isinstance(exc_info[1], cubicweb.Unauthorized):
fmtexc['faultcode'] = 'Client'
self._cw.status_out = 401
elif fmtexc['faultcode'] == 'Client':
self._cw.status_out = 400
elif fmtexc['faultcode'] == 'Server':
self._cw.status_out = 500
return restformat.encode_error(funcdef, fmtexc)
[docs]class WSCRUDController(WSController):
"""
An entity type CRUD controller
The displatch is summarized in this table, where 'entity' means that an
entity exists in the rset:
.. csv-table::
form-rset / verb, GET, POST, PUT, DELETE
, :meth:`_get`, :meth:`_post`, ,
entity, :meth:`_entity_get`, , :meth:`_entity_put`, :meth:`_entity_delete`
"entity, _ws_rtype", :meth:`_entity_rtype_get`, :meth:`_entity_rtype_post`
"entity, _ws_rtype, _ws_rtype_target", , , , :meth:`_entity_rtype_target_delete`
"""
#: The entity type
__cwetype__ = None
__select__ = yes()
allowed_functions = {
'IWorkflowable.fire_transition': (
('TrInfo', text), ('tr', 'comment', 'commentformat'))
}
[docs] def _get_entity(self, data):
"""
Get an entity and update/create it and its related entities all along.
:param data: A webservice type instance
"""
eid, values, relation_values = self._handle_data(data)
if eid:
entity = self._cw.entity_from_eid(eid)
if values:
entity.cw_set(**values)
else:
entity = self._cw.create_entity(data.__etype__, **values)
for (rtype, role, inlined), targets in relation_values.items():
if role == 'subject' and inlined:
entity.cw_set(**{rtype: targets})
if not isinstance(targets, list):
if targets is None:
targets = ()
else:
targets = [targets]
d = {'x': entity.eid}
if role == 'subject':
relation = 'X %s Y' % rtype
else:
relation = 'Y %s X' % rtype
if not targets:
entity._cw.execute(
'DELETE %s WHERE X eid %%(x)s' % relation, d)
else:
d.update({'y%s' % i: t.eid for i, t in enumerate(targets)})
entity._cw.execute(
"SET %(relation)s WHERE NOT %(relation)s, X eid %%(x)s, "
"Y eid IN (%(list)s)" % {
'relation': relation,
'list': ', '.join(
'%%(y%s)s' % x for x in range(len(targets))
)},
d)
entity._cw.execute(
"DELETE %(relation)s WHERE %(relation)s, X eid %%(x)s, "
"NOT Y eid IN (%(list)s)" % {
'relation': relation,
'list': ', '.join(
'%%(y%s)s' % x for x in range(len(targets))
)},
d)
return entity
[docs] def _get_entities(self, datalist):
"""
Get a list of entities from a list a webservice data
"""
return [self._get_entity(data) for data in datalist]
[docs] def _handle_data(self, data):
"""
Handle webservice data.
It returns a tuple `(eid, values, relation_values)`, where eid can be
None if the data had none, `values` contains the final and inlined
values, and `relation_values` the relation values. These variables are
dictionnaries that can be fed cw_set().
While handling the entity data, the related entities present in the
data will be updated/create (via :meth:`_get_entity`).
"""
eid = data.eid if data.eid else None
values = {}
relation_values = {}
for attr in data._wsme_attributes:
if not isinstance(attr, wsattr):
continue
if attr.rtype == 'eid':
continue
value = attr.__get__(data, data.__class__)
if value is wsme.types.Unset:
continue
if attr.isfinal:
values[attr.rtype] = value
else:
if value is not None:
if wsme.types.isarray(attr.datatype):
value = self._get_entities(value)
else:
value = self._get_entity(value)
if attr.inlined:
values[attr.rtype] = value
else:
relation_values[
(attr.rtype, attr.role, attr.inlined)] = value
return eid, values, relation_values
[docs] def _update(self, data):
""" Update an existing entity from ws data"""
assert data.eid, "missing eid on data"
return self._get_entity(data)
[docs] def _create(self, data):
""" Create an entity from ws data"""
if data.eid:
raise ValueError(
"Cannot create with a fixed eid. Please remove it")
return self._get_entity(data)
def _call_func(self, _entity, _function_name, _funcdef, **kwargs):
iname, fname = _function_name.split('.')
if _function_name not in self.allowed_functions:
raise Forbidden()
adapter = _entity.cw_adapt_to(iname)
f = getattr(adapter, fname)
result = f(**kwargs)
self._cw.cnx.commit()
if inspect.isclass(_funcdef.return_type) and \
issubclass(_funcdef.return_type, Base) and \
not isinstance(result, _funcdef.return_type):
result = _funcdef.return_type(result)
return result
@classmethod
def __registered__(cls, reg):
if not hasattr(cls, 'get'):
cls.get = wsme.api.signature(
[cls.__cwetype__], [text], JsonData, int, int, [text],
bool, wrap=True
)(cls._get)
if not hasattr(cls, 'post'):
cls.post = wsme.api.signature(
cls.__cwetype__, [text], bool, body=cls.__cwetype__, wrap=True
)(cls._post)
if not hasattr(cls, 'entity_get'):
cls.entity_get = wsme.api.signature(
cls.__cwetype__, PassThroughType, [text], wrap=True
)(cls._entity_get)
if not hasattr(cls, 'entity_put'):
cls.entity_put = wsme.api.signature(
cls.__cwetype__, PassThroughType, [text], body=cls.__cwetype__,
wrap=True
)(cls._entity_put)
if not hasattr(cls, 'entity_delete'):
cls.entity_delete = wsme.api.signature(
None, PassThroughType, [text], wrap=True
)(cls._entity_delete)
if not hasattr(cls, 'entity_rtype_post'):
cls.entity_rtype_post = wsme.api.signature(
None, PassThroughType, text, body=int, wrap=True
)(cls._entity_rtype_post)
if not hasattr(cls, 'entity_rtype_get'):
cls.entity_rtype_get = wsme.api.signature(
[Any], PassThroughType, text, [text], int, int,
bool, wrap=True
)(cls._entity_rtype_get)
if not hasattr(cls, 'entity_rtype_target_delete'):
cls.entity_rtype_target_delete = wsme.api.signature(
None, PassThroughType, text, int, wrap=True
)(cls._entity_rtype_target_delete)
for name, (argtypes, argnames) in cls.allowed_functions.items():
if len(argtypes) == 1:
method = 'get'
else:
method = 'post'
# The function name will be passed
argtypes = (
(argtypes[0],
PassThroughType, PassThroughType, PassThroughType)
+ argtypes[1:])
argnames = ('_entity', '_function_name', '_funcdef',) + argnames
mname = 'entity_func_%s_%s' % (
name.replace('.', '_'), method)
setattr(cls, mname, signature(
*argtypes, wrap=True, arg_names=argnames)(cls._call_func))
if cls.__select__ is WSCRUDController.__select__:
cls.__select__ = match_ws_etype(cls.__cwetype__.__name__)
super(WSCRUDController, cls).__registered__(reg)
[docs] def _get(self, orderby=None, filter=None, limit=0, offset=0, fetch=[],
keyonly=False):
"""List entities with an optional filter.
Default implementation of `GET /etype`.
:param filter:
:param fetch: A list of relations and subrelations of which the target
entities will be returned.
"""
fetchtree = to_fetchtree(fetch)
q = query.Query(self._cw.vreg.schema, self.__cwetype__.__etype__)
if not keyonly:
eschema = self._cw.vreg.schema.eschema(self.__cwetype__.__etype__)
cols = [
rs.type for rs in get_fetchable_relations(
self._cw.user, eschema, fetchtree)
]
q = q.add_column(*cols)
if orderby:
q = q.orderby(*orderby)
if filter:
q = q.filter(FilterParser(
self._cw.vreg.schema, self.__cwetype__.__etype__, filter
).parse())
if limit:
q = q.limit(limit)
if offset:
q = q.offset(offset)
entities = list(q.all(self._cw))
if entities:
prefetch(self._cw, entities, fetchtree)
return [
self.__cwetype__(e, keyonly=keyonly, fetch=fetch)
for e in entities
]
[docs] def _post(self, fetch=[], keyonly=False, data=None):
""" Default implementation of `POST /etype`."""
try:
entity = self._create(data)
except:
self._cw.cnx.rollback()
raise
else:
# XXX We dont really want a commit, just make sure all hooks and
# operations are done.
self._cw.cnx.commit()
return self.__cwetype__(entity, keyonly=keyonly, fetch=fetch)
[docs] def _entity_get(self, entity, fetch=[]):
""" Default implementation of `GET /etype/eid`."""
return self.__cwetype__(entity, fetch=fetch)
[docs] def _entity_put(self, entity, fetch=[], data=None):
""" Default implementation of `PUT /etype/eid`."""
if not data.eid:
data.eid = entity.eid
entity = self._update(data)
# XXX We dont really want a commit, just make sure all hooks and
# operations are done.
self._cw.cnx.commit()
# XXX We should clear the cache of entity to really have the db data if
# they were modified by some hooks
return self.__cwetype__(entity, fetch=fetch)
[docs] def _entity_delete(self, entity):
""" Default implementation of `DELETE /etype/eid`."""
entity.cw_delete()
return wsme.api.Response(None, 204)
[docs] def _entity_rtype_post(self, entity, rtype, eid):
""" Default implementation of `POST /etype/eid/rtype`."""
if rtype.startswith('<'):
rtype = 'reverse_' + rtype[1:]
entity.cw_set(**{rtype: eid})
[docs] def _entity_rtype_get(self, entity, rtype, orderby=None, limit=None,
offset=None, keyonly=False):
""" Default implementation of `GET /etype/eid/rtype`."""
if rtype.startswith('<'):
rtype, role = rtype[1:], 'object'
else:
role = 'subject'
rql = entity.cw_related_rql(rtype, role, limit=limit)
if offset:
rql = rql.replace(
'LIMIT %s' % limit,
'LIMIT %s OFFSET %s' % (limit, offset))
if orderby:
raise NotImplementedError('yet')
return [
Any(e, keyonly=keyonly) for e in self._cw.execute(
rql, {'x': entity.eid}).entities()
]
[docs] def _entity_rtype_target_delete(self, entity, rtype, eid):
""" Default implementation of `DELETE /etype/eid/rtype/eid`."""
if rtype.startswith('<'):
rtype = rtype[1:]
relation = 'X %s E'
else:
relation = 'E %s X'
relation = relation % rtype
rql = "DELETE " + relation + " WHERE X eid %(x)s, E eid %(e)s"
self._cw.execute(rql, {'e': entity.eid, 'x': eid})
return wsme.api.Response(None, 204)