Source code for cubes.wsme.controller

""" '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)