views.py 9.82 KB
Newer Older
Aurelien Campeas's avatar
Aurelien Campeas committed
1
# -*- coding: utf-8 -*-
2
# copyright 2013-2022 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
Aurelien Campeas's avatar
Aurelien Campeas committed
3
4
5
6
7
8
9
10
11
12
13
14
# contact http://www.logilab.fr -- mailto:contact@logilab.fr
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
David Douard's avatar
David Douard committed
15
16
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
Aurelien Campeas's avatar
Aurelien Campeas committed
17
18

"""cubicweb-rqlcontroller views/forms/actions/components for web ui"""
19

20
import json
21
22
import re

David Douard's avatar
David Douard committed
23
24
25
26
from cubicweb.predicates import (
    ExpectedValuePredicate,
    match_form_params,
    match_http_method,
27
28
    anonymous_user,
    authenticated_user,
David Douard's avatar
David Douard committed
29
)
30
from cubicweb.uilib import exc_message
31
from cubicweb.utils import json_dumps
32
33
from cubicweb.web import RemoteCallFailed, DirectResponse
from cubicweb.web.controller import Controller
Julien Cristau's avatar
Julien Cristau committed
34
from cubicweb.web.views.urlrewrite import rgx_action, SchemaBasedRewriter
35
from cubicweb import Binary
36
from cubicweb_rqlcontroller.rql_schema_holder import RqlIOSchemaHolder
37

38
39
from cubicweb_rqlcontroller.predicates import match_all_http_headers

David Douard's avatar
David Douard committed
40

Noé Gaumont's avatar
Noé Gaumont committed
41
42
ARGRE = re.compile(r"__r(?P<ref>\d+)$")
DATARE = re.compile(r"__f(?P<ref>.+)$")
43

44

45
def rewrite_args(args, output, form):
46
    for k, v in args.items():
47
        if not isinstance(v, str):
48
            continue
49
        match = ARGRE.match(v)
50
        if match:
Noé Gaumont's avatar
Noé Gaumont committed
51
            numref = int(match.group("ref"))
52
53
54
            if 0 <= numref <= len(output):
                rset = output[numref]
                if not rset:
Noé Gaumont's avatar
Noé Gaumont committed
55
                    raise Exception("%s references empty result set %s" % (v, rset))
56
                if len(rset) > 1:
Noé Gaumont's avatar
Noé Gaumont committed
57
58
59
                    raise Exception(
                        "%s references multi lines result set %s" % (v, rset)
                    )
60
61
                row = rset.rows[0]
                if len(row) > 1:
David Douard's avatar
David Douard committed
62
                    raise Exception(
Noé Gaumont's avatar
Noé Gaumont committed
63
64
                        "%s references multi column result set %s" % (v, rset)
                    )
65
                args[k] = row[0]
66
            continue
67
68
69
        match = DATARE.match(v)
        if match:
            args[k] = Binary(form[v][1].read())
70

David Douard's avatar
David Douard committed
71

72
73
class match_request_content_type(ExpectedValuePredicate):
    """check that the request body has the right content type"""
Noé Gaumont's avatar
Noé Gaumont committed
74

75
    def _get_value(self, cls, req, **kwargs):
Noé Gaumont's avatar
Noé Gaumont committed
76
        header = req.get_header("Content-Type", None)
77
        if header is not None:
Noé Gaumont's avatar
Noé Gaumont committed
78
            header = header.split(";", 1)[0].strip()
79
        return header
80

David Douard's avatar
David Douard committed
81

82
class RqlIOSchemaController(Controller):
Noé Gaumont's avatar
Noé Gaumont committed
83
84
    __regid__ = "rqlio_schema"
    __select__ = match_http_method("GET", "HEAD")
85
86

    def publish(self, rset=None):
Noé Gaumont's avatar
Noé Gaumont committed
87
88
89
90
91
        self._cw.set_content_type("application/json")
        self._cw.add_header("Etag", RqlIOSchemaHolder.get_schema_hash(self._cw))
        return json.dumps(RqlIOSchemaHolder.get_schema(self._cw)).encode(
            self._cw.encoding
        )
92
93


94
class BaseRqlIOController(Controller):
95
96
    """posted rql queries and arguments use the following pattern:

Noé Gaumont's avatar
Noé Gaumont committed
97
98
99
100
101
102
103
104
105
    [('INSERT CWUser U: U login %(login)s, U upassword %(pw)s',
      {'login': 'babar', 'pw': 'cubicweb rulez & 42'}),
     ('INSERT CWGroup G: G name %(name)s',
      {'name': 'pachyderms'}),
     ('SET U in_group G WHERE G eid %(g)s, U eid %(u)s',
      {'u': '__r0', 'g': '__r1'}),
     ('INSERT File F: F data %(content)s, F data_name %(fname)s',
      {'content': '__f0', 'fname': 'toto.txt'}),
    ]
106

Noé Gaumont's avatar
Noé Gaumont committed
107
108
109
110
    The later query is an example of query built to upload binety
    data as a file object. It requires to have a multipart query
    in which there is a part holding a file named '__f0'. See
    cwclientlib for examples of such queries.
111

Noé Gaumont's avatar
Noé Gaumont committed
112
113
    Limitations: back references can only work if one entity has been
    created.
114
115

    """
Noé Gaumont's avatar
Noé Gaumont committed
116

117
    __abstract__ = True
Noé Gaumont's avatar
Noé Gaumont committed
118
    __regid__ = "rqlio"
119
    __select__ = match_http_method("POST") & match_form_params("version")
Julien Cristau's avatar
Julien Cristau committed
120

121
    def json(self):
Noé Gaumont's avatar
Noé Gaumont committed
122
123
124
125
126
127
        contenttype = self._cw.get_header("Content-Type", raw=False)
        if (contenttype.mediaType, contenttype.mediaSubtype) == (
            "application",
            "json",
        ):  # noqa: E501
            encoding = contenttype.params.get("charset", "utf-8")
128
129
130
131
132
            content = self._cw.content
        else:
            # Multipart content is usually built by
            # cubicweb.multipart.parse_form_data() which encodes using
            # "utf-8" by default.
Noé Gaumont's avatar
Noé Gaumont committed
133
134
            encoding = "utf-8"
            content = self._cw.form["json"][1]
135
        try:
136
            # here we use .read instead of .gevalue because
137
138
139
            # on some circumstances content is an instance of BufferedRandom
            # which has no getvalue method
            args = json.loads(content.read().decode(encoding))
140
141
        except ValueError as exc:
            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
142
143
        if not isinstance(args, (list, tuple)):
            args = (args,)
144
145
146
147
        return args

    def publish(self, rset=None):
        self._cw.ajax_request = True
Noé Gaumont's avatar
Noé Gaumont committed
148
        self._cw.set_content_type("application/json")
149

Noé Gaumont's avatar
Noé Gaumont committed
150
151
152
        version = self._cw.form["version"]
        if version not in ("1.0", "2.0"):
            raise RemoteCallFailed("unknown rqlio version %r", version)
153

154
        args = self.json()
155
        try:
156
            result = self.rqlio(version, *args)
157
158
159
160
161
        except (RemoteCallFailed, DirectResponse):
            raise
        except Exception as exc:
            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
        if result is None:
Noé Gaumont's avatar
Noé Gaumont committed
162
            return b""
163
        return json_dumps(result).encode(self._cw.encoding)
164

165
    def rqlio(self, version, *rql_args):
166
167
        try:
            output = self._rqlio(rql_args)
David Douard's avatar
David Douard committed
168
        except Exception:
169
170
171
172
            self._cw.cnx.rollback()
            raise
        else:
            self._cw.cnx.commit()
Noé Gaumont's avatar
Noé Gaumont committed
173
174
        if version == "2.0":
            return [{"rows": o.rows, "variables": o.variables} for o in output]
175
176
177
178
        return [o.rows for o in output]

    def _rqlio(self, rql_args):
        output = []
179
        for rql, args in rql_args:
180
181
            if args is None:
                args = {}
182
            rewrite_args(args, output, self._cw.form)
183
184
            output.append(self._cw.execute(rql, args))
        return output
Julien Cristau's avatar
Julien Cristau committed
185

David Douard's avatar
David Douard committed
186

187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
class JsonRqlIOController(BaseRqlIOController):
    """RqlIOController with csrf desactivated for application/json because
    application/json can't be sent through a form
    """

    __select__ = BaseRqlIOController.__select__ & match_request_content_type(
        "application/json", mode="any"
    )
    require_csrf = False


class MultipartRqlIOController(BaseRqlIOController):
    """RqlIOController with csrf activated for cookie authenticated user
    and multipart/form-data.

    To have csrf deactivated with multipart/form-data, install
    cubicweb-signedrequest and use an authentication method.
    """

    __select__ = (
        BaseRqlIOController.__select__
        & ~match_all_http_headers("Authorization")  # no Authorization == cookie
        & match_request_content_type("multipart/form-data", mode="any")
        & authenticated_user()
    )


class AnonMultipartRqlIOController(BaseRqlIOController):
    """RqlIOController with csrf desactivated for anonymous_user.
    This allows public usage of the route.

    It is expected anonymous user should have only read auhorization.
    """

    __select__ = (
        BaseRqlIOController.__select__
        & ~match_all_http_headers("Authorization")
        & match_request_content_type("multipart/form-data", mode="any")
        & anonymous_user()
    )
    require_csrf = False


230
class GetEntitiesRqlIOController(BaseRqlIOController):
231
232
233
234
235
    """GetEntitiesRqlIOController allow to get entities attributes
    from a RQL. Only attributes present in the RQL will be sent.
    """

    __regid__ = "rqlio_entities"
236
237
238
239
    __select__ = match_http_method("POST") & match_request_content_type(
        "application/json", mode="any"
    )
    require_csrf = False
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279

    def publish(self, rset=None):
        self._cw.ajax_request = True
        self._cw.set_content_type("application/json")
        args = self.json()
        rql_args, col = args
        try:
            result = self.get_entities(col, *rql_args)
        except (RemoteCallFailed, DirectResponse):
            raise
        except Exception as exc:
            raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
        if result is None:
            return b""
        return json_dumps(result).encode(self._cw.encoding)

    def get_entities(self, col, *rql_args):
        try:
            return self._get_entities(col, rql_args)
        except Exception:
            self._cw.cnx.rollback()
            raise

    def _get_entities(self, col, rql_args):
        output = []
        for rql, args in rql_args:
            if args is None:
                args = {}
            rewrite_args(args, output, self._cw.form)
            entity_dict_list = []
            for entity in self._cw.execute(rql, args).entities(col=col):
                entity_dict = {"eid": entity.eid}
                for attribute, value in entity.cw_attr_cache.items():
                    if attribute in rql:
                        entity_dict[attribute] = value
                entity_dict_list.append(entity_dict)
            output.append(entity_dict_list)
        return output


Julien Cristau's avatar
Julien Cristau committed
280
281
class RQLIORewriter(SchemaBasedRewriter):
    rules = [
Noé Gaumont's avatar
Noé Gaumont committed
282
        (re.compile("/rqlio/schema"), rgx_action(controller="rqlio_schema")),
283
        (re.compile(r"/rqlio/get_entities"), rgx_action(controller="rqlio_entities")),
Noé Gaumont's avatar
Noé Gaumont committed
284
285
286
287
        (
            re.compile("/rqlio/(?P<version>.+)$"),
            rgx_action(controller="rqlio", formgroups=("version",)),
        ),
David Douard's avatar
David Douard committed
288
    ]