forked from jfinkels/flask-restless
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathviews.py
1454 lines (1206 loc) · 62.2 KB
/
views.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
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
230
231
232
233
234
235
236
237
238
239
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
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
flask.ext.restless.views
~~~~~~~~~~~~~~~~~~~~~~~~
Provides the following view classes, subclasses of
:class:`flask.MethodView` which provide generic endpoints for interacting
with an entity of the database:
:class:`flask.ext.restless.views.API`
Provides the endpoints for each of the basic HTTP methods. This is the
main class used by the
:meth:`flask.ext.restless.manager.APIManager.create_api` method to create
endpoints.
:class:`flask.ext.restless.views.FunctionAPI`
Provides a :http:method:`get` endpoint which returns the result of
evaluating some function on the entire collection of a given model.
:copyright: 2011 by Lincoln de Sousa <[email protected]>
:copyright: 2012 Jeffrey Finkelstein <[email protected]>
:license: GNU AGPLv3+ or BSD
"""
from __future__ import division
from collections import defaultdict
from functools import wraps
import math
import warnings
from flask import current_app
from flask import json
from flask import jsonify as _jsonify
from flask import request
from flask.views import MethodView
from mimerender import FlaskMimeRender
from sqlalchemy import Column
from sqlalchemy.exc import DataError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import ProgrammingError
from sqlalchemy.orm.exc import MultipleResultsFound
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm.query import Query
from werkzeug.exceptions import BadRequest
from werkzeug.exceptions import HTTPException
from werkzeug.urls import url_quote_plus
from .helpers import count
from .helpers import evaluate_functions
from .helpers import get_by
from .helpers import get_columns
from .helpers import get_or_create
from .helpers import get_related_model
from .helpers import get_relations
from .helpers import has_field
from .helpers import is_like_list
from .helpers import partition
from .helpers import primary_key_name
from .helpers import query_by_primary_key
from .helpers import session_query
from .helpers import strings_to_dates
from .helpers import to_dict
from .helpers import upper_keys
from .search import create_query
from .search import search
#: Format string for creating Link headers in paginated responses.
LINKTEMPLATE = '<{0}?page={1}&results_per_page={2}>; rel="{3}"'
#: String used internally as a dictionary key for passing header information
#: from view functions to the :func:`jsonpify` function.
_HEADERS = '__restless_headers'
#: String used internally as a dictionary key for passing status code
#: information from view functions to the :func:`jsonpify` function.
_STATUS = '__restless_status_code'
class ProcessingException(HTTPException):
"""Raised when a preprocessor or postprocessor encounters a problem.
This exception should be raised by functions supplied in the
``preprocessors`` and ``postprocessors`` keyword arguments to
:class:`APIManager.create_api`. When this exception is raised, all
preprocessing or postprocessing halts, so any processors appearing later in
the list will not be invoked.
`status_code` is the HTTP status code of the response supplied to the
client in the case that this exception is raised. `message` is an error
message describing the cause of this exception. This message will appear in
the JSON object in the body of the response to the client.
"""
def __init__(self, description='', code=400, *args, **kwargs):
super(ProcessingException, self).__init__(*args, **kwargs)
self.code = code
self.description = description
def _is_msie8or9():
"""Returns ``True`` if and only if the user agent of the client making the
request indicates that it is Microsoft Internet Explorer 8 or 9.
.. note::
We have no way of knowing if the user agent is lying, so we just make
our best guess based on the information provided.
"""
# request.user_agent.version comes as a string, so we have to parse it
version = lambda ua: tuple(int(d) for d in ua.version.split('.'))
return (request.user_agent is not None
and request.user_agent.version is not None
and request.user_agent.browser == 'msie'
and (8, 0) <= version(request.user_agent) < (10, 0))
def create_link_string(page, last_page, per_page):
"""Returns a string representing the value of the ``Link`` header.
`page` is the number of the current page, `last_page` is the last page in
the pagination, and `per_page` is the number of results per page.
"""
linkstring = ''
if page < last_page:
next_page = page + 1
linkstring = LINKTEMPLATE.format(request.base_url, next_page,
per_page, 'next') + ', '
linkstring += LINKTEMPLATE.format(request.base_url, last_page,
per_page, 'last')
return linkstring
def catch_processing_exceptions(func):
"""Decorator that catches :exc:`ProcessingException`s and subsequently
returns a JSON-ified error response.
"""
@wraps(func)
def decorator(*args, **kw):
try:
return func(*args, **kw)
except ProcessingException as exception:
current_app.logger.exception(str(exception))
status = exception.code
message = exception.description or str(exception)
return jsonify(message=message), status
return decorator
def catch_integrity_errors(session):
"""Returns a decorator that catches database integrity errors.
`session` is the SQLAlchemy session in which all database transactions will
be performed.
View methods can be wrapped like this::
@catch_integrity_errors(session)
def get(self, *args, **kw):
return '...'
Specifically, functions wrapped with the returned decorator catch
:exc:`IntegrityError`s, :exc:`DataError`s, and
:exc:`ProgrammingError`s. After the exceptions are caught, the session is
rolled back, the exception is logged on the current Flask application, and
an error response is returned to the client.
"""
def decorator(func):
@wraps(func)
def wrapped(*args, **kw):
try:
return func(*args, **kw)
except (DataError, IntegrityError, ProgrammingError) as exception:
session.rollback()
current_app.logger.exception(str(exception))
return dict(message=type(exception).__name__), 400
return wrapped
return decorator
def set_headers(response, headers):
"""Sets the specified headers on the specified response.
`response` is a Flask response object, and `headers` is a dictionary of
headers to set on the specified response. Any existing headers that
conflict with `headers` will be overwritten.
"""
for key, value in headers.items():
response.headers[key] = value
def jsonify(*args, **kw):
"""Same as :func:`flask.jsonify`, but sets response headers.
If ``headers`` is a keyword argument, this function will construct the JSON
response via :func:`flask.jsonify`, then set the specified ``headers`` on
the response. ``headers`` must be a dictionary mapping strings to strings.
"""
response = _jsonify(*args, **kw)
if 'headers' in kw:
set_headers(response, kw['headers'])
return response
# This code is (lightly) adapted from the ``requests`` library, in the
# ``requests.utils`` module. See <http://python-requests.org> for more
# information.
def _link_to_json(value):
"""Returns a list representation of the specified HTTP Link header
information.
`value` is a string containing the link header information. If the link
header information (the part of after ``Link:``) looked like this::
<url1>; rel="next", <url2>; rel="foo"; bar="baz"
then this function returns a list that looks like this::
[{"url": "url1", "rel": "next"},
{"url": "url2", "rel": "foo", "bar": "baz"}]
This example is adapted from the documentation of GitHub's API.
"""
links = []
replace_chars = " '\""
for val in value.split(","):
try:
url, params = val.split(";", 1)
except ValueError:
url, params = val, ''
link = {}
link["url"] = url.strip("<> '\"")
for param in params.split(";"):
try:
key, value = param.split("=")
except ValueError:
break
link[key.strip(replace_chars)] = value.strip(replace_chars)
links.append(link)
return links
def _headers_to_json(headers):
"""Returns a dictionary representation of the specified dictionary of HTTP
headers ready for use as a JSON object.
Pre-condition: headers is not ``None``.
"""
link = headers.pop('Link', None)
# Shallow copy is fine here because the `headers` dictionary maps strings
# to strings to strings.
result = headers.copy()
if link:
result['Link'] = _link_to_json(link)
return result
def jsonpify(*args, **kw):
"""Passes the specified arguments directly to :func:`jsonify` with a status
code of 200, then wraps the response with the name of a JSON-P callback
function specified as a query parameter called ``'callback'`` (or does
nothing if no such callback function is specified in the request).
If the keyword arguments include the string specified by :data:`_HEADERS`,
its value must be a dictionary specifying headers to set before sending the
JSONified response to the client. Headers on the response will be
overwritten by headers specified in this dictionary.
If the keyword arguments include the string specified by :data:`_STATUS`,
its value must be an integer representing the status code of the response.
Otherwise, the status code of the response will be :http:status:`200`.
"""
# HACK In order to make the headers and status code available in the
# content of the response, we need to send it from the view function to
# this jsonpify function via its keyword arguments. This is a limitation of
# the mimerender library: it has no way of making the headers and status
# code known to the rendering functions.
headers = kw.pop(_HEADERS, {})
status_code = kw.pop(_STATUS, 200)
response = jsonify(*args, **kw)
callback = request.args.get('callback', False)
if callback:
# Reload the data from the constructed JSON string so we can wrap it in
# a JSONP function.
data = json.loads(response.data)
# Force the 'Content-Type' header to be 'application/javascript'.
#
# Note that this is different from the mimetype used in Flask for JSON
# responses; Flask uses 'application/json'. We use
# 'application/javascript' because a JSONP response is valid
# Javascript, but not valid JSON.
headers['Content-Type'] = 'application/javascript'
# Add the headers and status code as metadata to the JSONP response.
meta = _headers_to_json(headers) if headers is not None else {}
meta['status'] = status_code
inner = json.dumps(dict(meta=meta, data=data))
content = '{0}({1})'.format(callback, inner)
# Note that this is different from the mimetype used in Flask for JSON
# responses; Flask uses 'application/json'. We use
# 'application/javascript' because a JSONP response is not valid JSON.
mimetype = 'application/javascript'
response = current_app.response_class(content, mimetype=mimetype)
# Set the headers on the HTTP response as well.
if headers:
set_headers(response, headers)
response.status_code = status_code
return response
def _parse_includes(column_names):
"""Returns a pair, consisting of a list of column names to include on the
left and a dictionary mapping relation name to a list containing the names
of fields on the related model which should be included.
`column_names` must be a list of strings.
If the name of a relation appears as a key in the dictionary, then it will
not appear in the list.
"""
dotted_names, columns = partition(column_names, lambda name: '.' in name)
# Create a dictionary mapping relation names to fields on the related
# model.
relations = defaultdict(list)
for name in dotted_names:
relation, field = name.split('.', 1)
# Only add the relation if it's column has been specified.
if relation in columns:
relations[relation].append(field)
# Included relations need only be in the relations dictionary, not the
# columns list.
for relation in relations:
if relation in columns:
columns.remove(relation)
return columns, relations
def _parse_excludes(column_names):
"""Returns a pair, consisting of a list of column names to exclude on the
left and a dictionary mapping relation name to a list containing the names
of fields on the related model which should be excluded.
`column_names` must be a list of strings.
If the name of a relation appears in the list then it will not appear in
the dictionary.
"""
dotted_names, columns = partition(column_names, lambda name: '.' in name)
# Create a dictionary mapping relation names to fields on the related
# model.
relations = defaultdict(list)
for name in dotted_names:
relation, field = name.split('.', 1)
# Only add the relation if it's column has not been specified.
if relation not in columns:
relations[relation].append(field)
# Relations which are to be excluded entirely need only be in the columns
# list, not the relations dictionary.
for column in columns:
if column in relations:
del relations[column]
return columns, relations
#: Creates the mimerender object necessary for decorating responses with a
#: function that automatically formats the dictionary in the appropriate format
#: based on the ``Accept`` header.
#:
#: Technical details: the first pair of parantheses instantiates the
#: :class:`mimerender.FlaskMimeRender` class. The second pair of parentheses
#: creates the decorator, so that we can simply use the variable ``mimerender``
#: as a decorator.
# TODO fill in xml renderer
mimerender = FlaskMimeRender()(default='json', json=jsonpify)
class ModelView(MethodView):
"""Base class for :class:`flask.MethodView` classes which represent a view
of a SQLAlchemy model.
The model class for this view can be accessed from the :attr:`model`
attribute, and the session in which all database transactions will be
performed when dealing with this model can be accessed from the
:attr:`session` attribute.
When subclasses wish to make queries to the database model specified in the
constructor, they should access the ``self.query`` function, which
delegates to the appropriate SQLAlchemy query object or Flask-SQLAlchemy
query object, depending on how the model has been defined.
"""
#: List of decorators applied to every method of this class.
decorators = [mimerender]
def __init__(self, session, model, *args, **kw):
"""Calls the constructor of the superclass and specifies the model for
which this class provides a ReSTful API.
`session` is the SQLAlchemy session in which all database transactions
will be performed.
`model` is the SQLALchemy declarative model class of the database model
for which this instance of the class is an API.
"""
super(ModelView, self).__init__(*args, **kw)
self.session = session
self.model = model
def query(self, model=None):
"""Returns either a SQLAlchemy query or Flask-SQLAlchemy query object
(depending on the type of the model) on the specified `model`, or if
`model` is ``None``, the model specified in the constructor of this
class.
"""
return session_query(self.session, model or self.model)
class FunctionAPI(ModelView):
"""Provides method-based dispatching for :http:method:`get` requests which
wish to apply SQL functions to all instances of a model.
.. versionadded:: 0.4
"""
def get(self):
"""Returns the result of evaluating the SQL functions specified in the
body of the request.
For a description of the request and response formats, see
:ref:`functionevaluation`.
"""
if 'q' not in request.args or not request.args.get('q'):
return dict(message='Empty query parameter'), 400
# if parsing JSON fails, return a 400 error in JSON format
try:
data = json.loads(str(request.args.get('q'))) or {}
except (TypeError, ValueError, OverflowError) as exception:
current_app.logger.exception(str(exception))
return dict(message='Unable to decode data'), 400
try:
result = evaluate_functions(self.session, self.model,
data.get('functions', []))
if not result:
return {}, 204
return result
except AttributeError as exception:
current_app.logger.exception(str(exception))
message = 'No such field "{0}"'.format(exception.field)
return dict(message=message), 400
except OperationalError as exception:
current_app.logger.exception(str(exception))
message = 'No such function "{0}"'.format(exception.function)
return dict(message=message), 400
class API(ModelView):
"""Provides method-based dispatching for :http:method:`get`,
:http:method:`post`, :http:method:`patch`, :http:method:`put`, and
:http:method:`delete` requests, for both collections of models and
individual models.
"""
#: List of decorators applied to every method of this class.
decorators = ModelView.decorators + [catch_processing_exceptions]
def __init__(self, session, model, exclude_columns=None,
include_columns=None, include_methods=None,
validation_exceptions=None, results_per_page=10,
max_results_per_page=100, post_form_preprocessor=None,
preprocessors=None, postprocessors=None, primary_key=None,
*args, **kw):
"""Instantiates this view with the specified attributes.
`session` is the SQLAlchemy session in which all database transactions
will be performed.
`model` is the SQLAlchemy model class for which this instance of the
class is an API. This model should live in `database`.
`validation_exceptions` is the tuple of exceptions raised by backend
validation (if any exist). If exceptions are specified here, any
exceptions which are caught when writing to the database. Will be
returned to the client as a :http:statuscode:`400` response with a
message specifying the validation error which occurred. For more
information, see :ref:`validation`.
If either `include_columns` or `exclude_columns` is not ``None``,
exactly one of them must be specified. If both are not ``None``, then
the behavior of this function is undefined. `exclude_columns` must be
an iterable of strings specifying the columns of `model` which will
*not* be present in the JSON representation of the model provided in
response to :http:method:`get` requests. Similarly, `include_columns`
specifies the *only* columns which will be present in the returned
dictionary. In other words, `exclude_columns` is a blacklist and
`include_columns` is a whitelist; you can only use one of them per API
endpoint. If either `include_columns` or `exclude_columns` contains a
string which does not name a column in `model`, it will be ignored.
If `include_columns` is an iterable of length zero (like the empty
tuple or the empty list), then the returned dictionary will be
empty. If `include_columns` is ``None``, then the returned dictionary
will include all columns not excluded by `exclude_columns`.
If `include_methods` is an iterable of strings, the methods with names
corresponding to those in this list will be called and their output
included in the response.
See :ref:`includes` for information on specifying included or excluded
columns on fields of related models.
`results_per_page` is a positive integer which represents the default
number of results which are returned per page. Requests made by clients
may override this default by specifying ``results_per_page`` as a query
argument. `max_results_per_page` is a positive integer which represents
the maximum number of results which are returned per page. This is a
"hard" upper bound in the sense that even if a client specifies that
greater than `max_results_per_page` should be returned, only
`max_results_per_page` results will be returned. For more information,
see :ref:`serverpagination`.
.. deprecated:: 0.9.2
The `post_form_preprocessor` keyword argument is deprecated in
version 0.9.2. It will be removed in version 1.0. Replace code that
looks like this::
manager.create_api(Person, post_form_preprocessor=foo)
with code that looks like this::
manager.create_api(Person, preprocessors=dict(POST=[foo]))
See :ref:`processors` for more information and examples.
`post_form_preprocessor` is a callback function which takes
POST input parameters loaded from JSON and enhances them with other
key/value pairs. The example use of this is when your ``model``
requires to store user identity and for security reasons the identity
is not read from the post parameters (where malicious user can tamper
with them) but from the session.
`preprocessors` is a dictionary mapping strings to lists of
functions. Each key is the name of an HTTP method (for example,
``'GET'`` or ``'POST'``). Each value is a list of functions, each of
which will be called before any other code is executed when this API
receives the corresponding HTTP request. The functions will be called
in the order given here. The `postprocessors` keyword argument is
essentially the same, except the given functions are called after all
other code. For more information on preprocessors and postprocessors,
see :ref:`processors`.
`primary_key` is a string specifying the name of the column of `model`
to use as the primary key for the purposes of creating URLs. If the
`model` has exactly one primary key, there is no need to provide a
value for this. If `model` has two or more primary keys, you must
specify which one to use.
.. versionadded:: 0.13.0
Added the `primary_key` keyword argument.
.. versionadded:: 0.10.2
Added the `include_methods` keyword argument.
.. versionchanged:: 0.10.0
Removed `authentication_required_for` and `authentication_function`
keyword arguments.
Use the `preprocesors` and `postprocessors` keyword arguments
instead. For more information, see :ref:`authentication`.
.. versionadded:: 0.9.2
Added the `preprocessors` and `postprocessors` keyword arguments.
.. versionadded:: 0.9.0
Added the `max_results_per_page` keyword argument.
.. versionadded:: 0.7
Added the `exclude_columns` keyword argument.
.. versionadded:: 0.6
Added the `results_per_page` keyword argument.
.. versionadded:: 0.5
Added the `include_columns`, and `validation_exceptions` keyword
arguments.
.. versionadded:: 0.4
Added the `authentication_required_for` and
`authentication_function` keyword arguments.
"""
super(API, self).__init__(session, model, *args, **kw)
if exclude_columns is None:
self.exclude_columns, self.exclude_relations = (None, None)
else:
self.exclude_columns, self.exclude_relations = _parse_excludes(
[self._get_column_name(column) for column in exclude_columns])
if include_columns is None:
self.include_columns, self.include_relations = (None, None)
else:
self.include_columns, self.include_relations = _parse_includes(
[self._get_column_name(column) for column in include_columns])
self.include_methods = include_methods
self.validation_exceptions = tuple(validation_exceptions or ())
self.results_per_page = results_per_page
self.max_results_per_page = max_results_per_page
self.primary_key = primary_key
self.postprocessors = defaultdict(list)
self.preprocessors = defaultdict(list)
self.postprocessors.update(upper_keys(postprocessors or {}))
self.preprocessors.update(upper_keys(preprocessors or {}))
# move post_form_preprocessor to preprocessors['POST'] for backward
# compatibility
if post_form_preprocessor:
msg = ('post_form_preprocessor is deprecated and will be removed'
' in version 1.0; use preprocessors instead.')
warnings.warn(msg, DeprecationWarning)
self.preprocessors['POST'].append(post_form_preprocessor)
# postprocessors for PUT are applied to PATCH because PUT is just a
# redirect to PATCH
for postprocessor in self.postprocessors['PUT_SINGLE']:
self.postprocessors['PATCH_SINGLE'].append(postprocessor)
for preprocessor in self.preprocessors['PUT_SINGLE']:
self.preprocessors['PATCH_SINGLE'].append(preprocessor)
for postprocessor in self.postprocessors['PUT_MANY']:
self.postprocessors['PATCH_MANY'].append(postprocessor)
for preprocessor in self.preprocessors['PUT_MANY']:
self.preprocessors['PATCH_MANY'].append(preprocessor)
# HACK: We would like to use the :attr:`API.decorators` class attribute
# in order to decorate each view method with a decorator that catches
# database integrity errors. However, in order to rollback the session,
# we need to have a session object available to roll back. Therefore we
# need to manually decorate each of the view functions here.
decorate = lambda name, f: setattr(self, name, f(getattr(self, name)))
for method in ['get', 'post', 'patch', 'put', 'delete']:
decorate(method, catch_integrity_errors(self.session))
def _get_column_name(self, column):
"""Retrieve a column name from a column attribute of SQLAlchemy
model class, or a string.
Raises `TypeError` when argument does not fall into either of those
options.
Raises `ValueError` if argument is a column attribute that belongs
to an incorrect model class.
"""
if hasattr(column, '__clause_element__'):
clause_element = column.__clause_element__()
if not isinstance(clause_element, Column):
msg = ('Column must be a string or a column attribute'
' of SQLAlchemy ORM class')
raise TypeError(msg)
model = column.class_
if model is not self.model:
msg = ('Cannot specify column of model %s'
' while creating API for model %s' % (
model.__name__, self.model.__name__))
raise ValueError(msg)
return clause_element.key
return column
def _add_to_relation(self, query, relationname, toadd=None):
"""Adds a new or existing related model to each model specified by
`query`.
This function does not commit the changes made to the database. The
calling function has that responsibility.
`query` is a SQLAlchemy query instance that evaluates to all instances
of the model specified in the constructor of this class that should be
updated.
`relationname` is the name of a one-to-many relationship which exists
on each model specified in `query`.
`toadd` is a list of dictionaries, each representing the attributes of
an existing or new related model to add. If a dictionary contains the
key ``'id'``, that instance of the related model will be
added. Otherwise, the :func:`helpers.get_or_create` class method will
be used to get or create a model to add.
"""
submodel = get_related_model(self.model, relationname)
if isinstance(toadd, dict):
toadd = [toadd]
for dictionary in toadd or []:
subinst = get_or_create(self.session, submodel, dictionary)
try:
for instance in query:
getattr(instance, relationname).append(subinst)
except AttributeError as exception:
current_app.logger.exception(str(exception))
setattr(instance, relationname, subinst)
def _remove_from_relation(self, query, relationname, toremove=None):
"""Removes a related model from each model specified by `query`.
This function does not commit the changes made to the database. The
calling function has that responsibility.
`query` is a SQLAlchemy query instance that evaluates to all instances
of the model specified in the constructor of this class that should be
updated.
`relationname` is the name of a one-to-many relationship which exists
on each model specified in `query`.
`toremove` is a list of dictionaries, each representing the attributes
of an existing model to remove. If a dictionary contains the key
``'id'``, that instance of the related model will be
removed. Otherwise, the instance to remove will be retrieved using the
other attributes specified in the dictionary. If multiple instances
match the specified attributes, only the first instance will be
removed.
If one of the dictionaries contains a mapping from ``'__delete__'`` to
``True``, then the removed object will be deleted after being removed
from each instance of the model in the specified query.
"""
submodel = get_related_model(self.model, relationname)
for dictionary in toremove or []:
remove = dictionary.pop('__delete__', False)
if 'id' in dictionary:
subinst = get_by(self.session, submodel, dictionary['id'])
else:
subinst = self.query(submodel).filter_by(**dictionary).first()
for instance in query:
getattr(instance, relationname).remove(subinst)
if remove:
self.session.delete(subinst)
def _set_on_relation(self, query, relationname, toset=None):
"""Sets the value of the relation specified by `relationname` on each
instance specified by `query` to have the new or existing related
models specified by `toset`.
This function does not commit the changes made to the database. The
calling function has that responsibility.
`query` is a SQLAlchemy query instance that evaluates to all instances
of the model specified in the constructor of this class that should be
updated.
`relationname` is the name of a one-to-many relationship which exists
on each model specified in `query`.
`toset` is either a dictionary or a list of dictionaries, each
representing the attributes of an existing or new related model to
set. If a dictionary contains the key ``'id'``, that instance of the
related model will be added. Otherwise, the
:func:`helpers.get_or_create` method will be used to get or create a
model to set.
"""
submodel = get_related_model(self.model, relationname)
if isinstance(toset, list):
value = [get_or_create(self.session, submodel, d) for d in toset]
else:
value = get_or_create(self.session, submodel, toset)
for instance in query:
setattr(instance, relationname, value)
# TODO change this to have more sensible arguments
def _update_relations(self, query, params):
"""Adds, removes, or sets models which are related to the model
specified in the constructor of this class.
This function does not commit the changes made to the database. The
calling function has that responsibility.
This method returns a :class:`frozenset` of strings representing the
names of relations which were modified.
`query` is a SQLAlchemy query instance that evaluates to all instances
of the model specified in the constructor of this class that should be
updated.
`params` is a dictionary containing a mapping from name of the relation
to modify (as a string) to either a list or another dictionary. In the
former case, the relation will be assigned the instances specified by
the elements of the list, which are dictionaries as described below.
In the latter case, the inner dictionary contains at most two mappings,
one with the key ``'add'`` and one with the key ``'remove'``. Each of
these is a mapping to a list of dictionaries which represent the
attributes of the object to add to or remove from the relation.
If one of the dictionaries specified in ``add`` or ``remove`` (or the
list to be assigned) includes an ``id`` key, the object with that
``id`` will be attempt to be added or removed. Otherwise, an existing
object with the specified attribute values will be attempted to be
added or removed. If adding, a new object will be created if a matching
object could not be found in the database.
If a dictionary in one of the ``'remove'`` lists contains a mapping
from ``'__delete__'`` to ``True``, then the removed object will be
deleted after being removed from each instance of the model in the
specified query.
"""
relations = get_relations(self.model)
tochange = frozenset(relations) & frozenset(params)
for columnname in tochange:
# Check if 'add' or 'remove' is being used
if (isinstance(params[columnname], dict)
and any(k in params[columnname] for k in ['add', 'remove'])):
toadd = params[columnname].get('add', [])
toremove = params[columnname].get('remove', [])
self._add_to_relation(query, columnname, toadd=toadd)
self._remove_from_relation(query, columnname,
toremove=toremove)
else:
toset = params[columnname]
self._set_on_relation(query, columnname, toset=toset)
return tochange
def _handle_validation_exception(self, exception):
"""Rolls back the session, extracts validation error messages, and
returns a :func:`flask.jsonify` response with :http:statuscode:`400`
containing the extracted validation error messages.
Again, *this method calls
:meth:`sqlalchemy.orm.session.Session.rollback`*.
"""
self.session.rollback()
errors = self._extract_error_messages(exception) or \
'Could not determine specific validation errors'
return dict(validation_errors=errors), 400
def _extract_error_messages(self, exception):
"""Tries to extract a dictionary mapping field name to validation error
messages from `exception`, which is a validation exception as provided
in the ``validation_exceptions`` keyword argument in the constructor of
this class.
Since the type of the exception is provided by the user in the
constructor of this class, we don't know for sure where the validation
error messages live inside `exception`. Therefore this method simply
attempts to access a few likely attributes and returns the first one it
finds (or ``None`` if no error messages dictionary can be extracted).
"""
# 'errors' comes from sqlalchemy_elixir_validations
if hasattr(exception, 'errors'):
return exception.errors
# 'message' comes from savalidation
if hasattr(exception, 'message'):
# TODO this works only if there is one validation error
try:
left, right = str(exception).rsplit(':', 1)
left_bracket = left.rindex('[')
right_bracket = right.rindex(']')
except ValueError as exception:
current_app.logger.exception(str(exception))
# could not parse the string; we're not trying too hard here...
return None
msg = right[:right_bracket].strip(' "')
fieldname = left[left_bracket + 1:].strip()
return {fieldname: msg}
return None
def _compute_results_per_page(self):
"""Helper function which returns the number of results per page based
on the request argument ``results_per_page`` and the server
configuration parameters :attr:`results_per_page` and
:attr:`max_results_per_page`.
"""
try:
results_per_page = int(request.args.get('results_per_page'))
except:
results_per_page = self.results_per_page
if results_per_page <= 0:
results_per_page = self.results_per_page
return min(results_per_page, self.max_results_per_page)
# TODO it is ugly to have `deep` as an arg here; can we remove it?
def _paginated(self, instances, deep):
"""Returns a paginated JSONified response from the specified list of
model instances.
`instances` is either a Python list of model instances or a
:class:`~sqlalchemy.orm.Query`.
`deep` is the dictionary which defines the depth of submodels to output
in the JSON format of the model instances in `instances`; it is passed
directly to :func:`helpers.to_dict`.
The response data is JSON of the form:
.. sourcecode:: javascript
{
"page": 2,
"total_pages": 3,
"num_results": 8,
"objects": [{"id": 1, "name": "Jeffrey", "age": 24}, ...]
}
"""
if isinstance(instances, list):
num_results = len(instances)
else:
num_results = count(self.session, instances)
results_per_page = self._compute_results_per_page()
if results_per_page > 0:
# get the page number (first page is page 1)
page_num = int(request.args.get('page', 1))
start = (page_num - 1) * results_per_page
end = min(num_results, start + results_per_page)
total_pages = int(math.ceil(num_results / results_per_page))
else:
page_num = 1
start = 0
end = num_results
total_pages = 1
objects = [to_dict(x, deep, exclude=self.exclude_columns,
exclude_relations=self.exclude_relations,
include=self.include_columns,
include_relations=self.include_relations,
include_methods=self.include_methods)
for x in instances[start:end]]
return dict(page=page_num, objects=objects, total_pages=total_pages,
num_results=num_results)
def _inst_to_dict(self, inst):
"""Returns the dictionary representation of the specified instance.
This method respects the include and exclude columns specified in the
constructor of this class.
"""
# create a placeholder for the relations of the returned models
relations = frozenset(get_relations(self.model))
# do not follow relations that will not be included in the response
if self.include_columns is not None:
cols = frozenset(self.include_columns)
rels = frozenset(self.include_relations)
relations &= (cols | rels)
elif self.exclude_columns is not None:
relations -= frozenset(self.exclude_columns)
deep = dict((r, {}) for r in relations)
return to_dict(inst, deep, exclude=self.exclude_columns,
exclude_relations=self.exclude_relations,
include=self.include_columns,
include_relations=self.include_relations,
include_methods=self.include_methods)
def _instid_to_dict(self, instid):
"""Returns the dictionary representation of the instance specified by
`instid`.
If no such instance of the model exists, this method aborts with a
:http:statuscode:`404`.
"""
inst = get_by(self.session, self.model, instid, self.primary_key)
if inst is None:
return {_STATUS: 404}, 404
return self._inst_to_dict(inst)
def _search(self):
"""Defines a generic search function for the database model.
If the query string is empty, or if the specified query is invalid for
some reason (for example, searching for all person instances with), the
response will be the JSON string ``{"objects": []}``.
To search for entities meeting some criteria, the client makes a
request to :http:get:`/api/<modelname>` with a query string containing
the parameters of the search. The parameters of the search can involve
filters. In a filter, the client specifies the name of the field by
which to filter, the operation to perform on the field, and the value
which is the argument to that operation. In a function, the client
specifies the name of a SQL function which is executed on the search
results; the result of executing the function is returned to the
client.