kinto 23.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kinto/__init__.py +92 -0
- kinto/__main__.py +249 -0
- kinto/authorization.py +134 -0
- kinto/config/__init__.py +94 -0
- kinto/config/kinto.tpl +270 -0
- kinto/contribute.json +27 -0
- kinto/core/__init__.py +246 -0
- kinto/core/authentication.py +48 -0
- kinto/core/authorization.py +311 -0
- kinto/core/cache/__init__.py +131 -0
- kinto/core/cache/memcached.py +112 -0
- kinto/core/cache/memory.py +104 -0
- kinto/core/cache/postgresql/__init__.py +178 -0
- kinto/core/cache/postgresql/schema.sql +23 -0
- kinto/core/cache/testing.py +208 -0
- kinto/core/cornice/__init__.py +93 -0
- kinto/core/cornice/cors.py +144 -0
- kinto/core/cornice/errors.py +40 -0
- kinto/core/cornice/pyramidhook.py +373 -0
- kinto/core/cornice/renderer.py +89 -0
- kinto/core/cornice/resource.py +205 -0
- kinto/core/cornice/service.py +641 -0
- kinto/core/cornice/util.py +138 -0
- kinto/core/cornice/validators/__init__.py +94 -0
- kinto/core/cornice/validators/_colander.py +142 -0
- kinto/core/cornice/validators/_marshmallow.py +182 -0
- kinto/core/cornice_swagger/__init__.py +92 -0
- kinto/core/cornice_swagger/converters/__init__.py +21 -0
- kinto/core/cornice_swagger/converters/exceptions.py +6 -0
- kinto/core/cornice_swagger/converters/parameters.py +90 -0
- kinto/core/cornice_swagger/converters/schema.py +249 -0
- kinto/core/cornice_swagger/swagger.py +725 -0
- kinto/core/cornice_swagger/templates/index.html +73 -0
- kinto/core/cornice_swagger/templates/index_script_template.html +21 -0
- kinto/core/cornice_swagger/util.py +42 -0
- kinto/core/cornice_swagger/views.py +78 -0
- kinto/core/decorators.py +74 -0
- kinto/core/errors.py +216 -0
- kinto/core/events.py +301 -0
- kinto/core/initialization.py +738 -0
- kinto/core/listeners/__init__.py +9 -0
- kinto/core/metrics.py +94 -0
- kinto/core/openapi.py +115 -0
- kinto/core/permission/__init__.py +202 -0
- kinto/core/permission/memory.py +167 -0
- kinto/core/permission/postgresql/__init__.py +489 -0
- kinto/core/permission/postgresql/migrations/migration_001_002.sql +18 -0
- kinto/core/permission/postgresql/schema.sql +41 -0
- kinto/core/permission/testing.py +487 -0
- kinto/core/resource/__init__.py +1311 -0
- kinto/core/resource/model.py +412 -0
- kinto/core/resource/schema.py +502 -0
- kinto/core/resource/viewset.py +230 -0
- kinto/core/schema.py +119 -0
- kinto/core/scripts.py +50 -0
- kinto/core/statsd.py +1 -0
- kinto/core/storage/__init__.py +436 -0
- kinto/core/storage/exceptions.py +53 -0
- kinto/core/storage/generators.py +58 -0
- kinto/core/storage/memory.py +651 -0
- kinto/core/storage/postgresql/__init__.py +1131 -0
- kinto/core/storage/postgresql/client.py +120 -0
- kinto/core/storage/postgresql/migrations/migration_001_002.sql +10 -0
- kinto/core/storage/postgresql/migrations/migration_002_003.sql +33 -0
- kinto/core/storage/postgresql/migrations/migration_003_004.sql +18 -0
- kinto/core/storage/postgresql/migrations/migration_004_005.sql +20 -0
- kinto/core/storage/postgresql/migrations/migration_005_006.sql +11 -0
- kinto/core/storage/postgresql/migrations/migration_006_007.sql +74 -0
- kinto/core/storage/postgresql/migrations/migration_007_008.sql +66 -0
- kinto/core/storage/postgresql/migrations/migration_008_009.sql +41 -0
- kinto/core/storage/postgresql/migrations/migration_009_010.sql +98 -0
- kinto/core/storage/postgresql/migrations/migration_010_011.sql +14 -0
- kinto/core/storage/postgresql/migrations/migration_011_012.sql +9 -0
- kinto/core/storage/postgresql/migrations/migration_012_013.sql +71 -0
- kinto/core/storage/postgresql/migrations/migration_013_014.sql +14 -0
- kinto/core/storage/postgresql/migrations/migration_014_015.sql +95 -0
- kinto/core/storage/postgresql/migrations/migration_015_016.sql +4 -0
- kinto/core/storage/postgresql/migrations/migration_016_017.sql +81 -0
- kinto/core/storage/postgresql/migrations/migration_017_018.sql +25 -0
- kinto/core/storage/postgresql/migrations/migration_018_019.sql +8 -0
- kinto/core/storage/postgresql/migrations/migration_019_020.sql +7 -0
- kinto/core/storage/postgresql/migrations/migration_020_021.sql +68 -0
- kinto/core/storage/postgresql/migrations/migration_021_022.sql +62 -0
- kinto/core/storage/postgresql/migrations/migration_022_023.sql +5 -0
- kinto/core/storage/postgresql/migrations/migration_023_024.sql +6 -0
- kinto/core/storage/postgresql/migrations/migration_024_025.sql +6 -0
- kinto/core/storage/postgresql/migrator.py +98 -0
- kinto/core/storage/postgresql/pool.py +55 -0
- kinto/core/storage/postgresql/schema.sql +143 -0
- kinto/core/storage/testing.py +1857 -0
- kinto/core/storage/utils.py +37 -0
- kinto/core/testing.py +182 -0
- kinto/core/utils.py +553 -0
- kinto/core/views/__init__.py +0 -0
- kinto/core/views/batch.py +163 -0
- kinto/core/views/errors.py +145 -0
- kinto/core/views/heartbeat.py +106 -0
- kinto/core/views/hello.py +69 -0
- kinto/core/views/openapi.py +35 -0
- kinto/core/views/version.py +50 -0
- kinto/events.py +3 -0
- kinto/plugins/__init__.py +0 -0
- kinto/plugins/accounts/__init__.py +94 -0
- kinto/plugins/accounts/authentication.py +63 -0
- kinto/plugins/accounts/scripts.py +61 -0
- kinto/plugins/accounts/utils.py +13 -0
- kinto/plugins/accounts/views.py +136 -0
- kinto/plugins/admin/README.md +3 -0
- kinto/plugins/admin/VERSION +1 -0
- kinto/plugins/admin/__init__.py +40 -0
- kinto/plugins/admin/build/VERSION +1 -0
- kinto/plugins/admin/build/assets/index-CYFwtKtL.css +6 -0
- kinto/plugins/admin/build/assets/index-DJ0m93zA.js +149 -0
- kinto/plugins/admin/build/assets/logo-VBRiKSPX.png +0 -0
- kinto/plugins/admin/build/index.html +18 -0
- kinto/plugins/admin/public/help.html +25 -0
- kinto/plugins/admin/views.py +42 -0
- kinto/plugins/default_bucket/__init__.py +191 -0
- kinto/plugins/flush.py +28 -0
- kinto/plugins/history/__init__.py +65 -0
- kinto/plugins/history/listener.py +181 -0
- kinto/plugins/history/views.py +66 -0
- kinto/plugins/openid/__init__.py +131 -0
- kinto/plugins/openid/utils.py +14 -0
- kinto/plugins/openid/views.py +193 -0
- kinto/plugins/prometheus.py +300 -0
- kinto/plugins/statsd.py +85 -0
- kinto/schema_validation.py +135 -0
- kinto/views/__init__.py +34 -0
- kinto/views/admin.py +195 -0
- kinto/views/buckets.py +45 -0
- kinto/views/collections.py +58 -0
- kinto/views/contribute.py +39 -0
- kinto/views/groups.py +90 -0
- kinto/views/permissions.py +235 -0
- kinto/views/records.py +133 -0
- kinto-23.2.1.dist-info/METADATA +232 -0
- kinto-23.2.1.dist-info/RECORD +142 -0
- kinto-23.2.1.dist-info/WHEEL +5 -0
- kinto-23.2.1.dist-info/entry_points.txt +5 -0
- kinto-23.2.1.dist-info/licenses/LICENSE +13 -0
- kinto-23.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1311 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import warnings
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
import colander
|
|
8
|
+
import venusian
|
|
9
|
+
from pyramid import exceptions as pyramid_exceptions
|
|
10
|
+
from pyramid.authorization import Everyone
|
|
11
|
+
from pyramid.decorator import reify
|
|
12
|
+
from pyramid.httpexceptions import (
|
|
13
|
+
HTTPNotFound,
|
|
14
|
+
HTTPNotModified,
|
|
15
|
+
HTTPPreconditionFailed,
|
|
16
|
+
HTTPServiceUnavailable,
|
|
17
|
+
)
|
|
18
|
+
from pyramid.settings import asbool
|
|
19
|
+
|
|
20
|
+
from kinto.core import Service
|
|
21
|
+
from kinto.core.errors import ERRORS, http_error, raise_invalid, request_GET, send_alert
|
|
22
|
+
from kinto.core.events import ACTIONS
|
|
23
|
+
from kinto.core.storage import MISSING, Filter, Sort
|
|
24
|
+
from kinto.core.storage import exceptions as storage_exceptions
|
|
25
|
+
from kinto.core.utils import (
|
|
26
|
+
COMPARISON,
|
|
27
|
+
apply_json_patch,
|
|
28
|
+
classname,
|
|
29
|
+
decode64,
|
|
30
|
+
dict_subset,
|
|
31
|
+
encode64,
|
|
32
|
+
find_nested_value,
|
|
33
|
+
json,
|
|
34
|
+
recursive_update_dict,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from .model import Model
|
|
38
|
+
from .schema import JsonPatchRequestSchema, ResourceSchema
|
|
39
|
+
from .viewset import ViewSet
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def register(depth=1, **kwargs):
|
|
46
|
+
"""Ressource class decorator.
|
|
47
|
+
|
|
48
|
+
Register the decorated class in the cornice registry.
|
|
49
|
+
Pass all its keyword arguments to the register_resource
|
|
50
|
+
function.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def wrapped(resource):
|
|
54
|
+
register_resource(resource, depth=depth + 1, **kwargs)
|
|
55
|
+
return resource
|
|
56
|
+
|
|
57
|
+
return wrapped
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def register_resource(resource_cls, settings=None, viewset=None, depth=1, **kwargs):
|
|
61
|
+
"""Register a resource in the cornice registry.
|
|
62
|
+
|
|
63
|
+
:param resource_cls:
|
|
64
|
+
The resource class to register.
|
|
65
|
+
It should be a class or have a "name" attribute.
|
|
66
|
+
|
|
67
|
+
:param viewset:
|
|
68
|
+
A ViewSet object, which will be used to find out which arguments should
|
|
69
|
+
be appended to the views, and where the views are.
|
|
70
|
+
|
|
71
|
+
:param depth:
|
|
72
|
+
A depth offset. It will be used to determine what is the level of depth
|
|
73
|
+
in the call tree. (set to 1 by default.)
|
|
74
|
+
|
|
75
|
+
Any additional keyword parameters will be used to override the viewset
|
|
76
|
+
attributes.
|
|
77
|
+
"""
|
|
78
|
+
if viewset is None:
|
|
79
|
+
viewset = resource_cls.default_viewset(**kwargs)
|
|
80
|
+
else:
|
|
81
|
+
viewset.update(**kwargs)
|
|
82
|
+
|
|
83
|
+
resource_name = viewset.get_name(resource_cls)
|
|
84
|
+
|
|
85
|
+
def register_service(endpoint_type, settings):
|
|
86
|
+
"""Registers a service in cornice, for the given type."""
|
|
87
|
+
path_pattern = getattr(viewset, f"{endpoint_type}_path")
|
|
88
|
+
path_values = {"resource_name": resource_name}
|
|
89
|
+
path = path_pattern.format_map(path_values)
|
|
90
|
+
|
|
91
|
+
name = viewset.get_service_name(endpoint_type, resource_cls)
|
|
92
|
+
|
|
93
|
+
service = Service(name, path, depth=depth, **viewset.get_service_arguments())
|
|
94
|
+
|
|
95
|
+
# Attach viewset and resource to the service for later reference.
|
|
96
|
+
service.viewset = viewset
|
|
97
|
+
service.resource = resource_cls
|
|
98
|
+
service.type = endpoint_type
|
|
99
|
+
# Attach plural and object paths.
|
|
100
|
+
service.plural_path = viewset.plural_path.format_map(path_values)
|
|
101
|
+
service.object_path = (
|
|
102
|
+
viewset.object_path.format_map(path_values)
|
|
103
|
+
if viewset.object_path is not None
|
|
104
|
+
else None
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
methods = getattr(viewset, f"{endpoint_type}_methods")
|
|
108
|
+
for method in methods:
|
|
109
|
+
if not viewset.is_endpoint_enabled(
|
|
110
|
+
endpoint_type, resource_name, method.lower(), settings
|
|
111
|
+
):
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
argument_getter = getattr(viewset, f"{endpoint_type}_arguments")
|
|
115
|
+
view_args = argument_getter(resource_cls, method)
|
|
116
|
+
|
|
117
|
+
view = viewset.get_view(endpoint_type, method.lower())
|
|
118
|
+
service.add_view(method, view, klass=resource_cls, **view_args)
|
|
119
|
+
|
|
120
|
+
# We support JSON-patch on PATCH views. Since the body payload
|
|
121
|
+
# of JSON Patch is not a dict (mapping) but an array, we can't
|
|
122
|
+
# use the same schema as for other PATCH protocols. We add another
|
|
123
|
+
# dedicated view for PATCH, but targetting a different content_type
|
|
124
|
+
# predicate.
|
|
125
|
+
if method.lower() == "patch":
|
|
126
|
+
view_args["content_type"] = "application/json-patch+json"
|
|
127
|
+
view_args["schema"] = JsonPatchRequestSchema()
|
|
128
|
+
service.add_view(method, view, klass=resource_cls, **view_args)
|
|
129
|
+
|
|
130
|
+
return service
|
|
131
|
+
|
|
132
|
+
def callback(context, name, ob):
|
|
133
|
+
# get the callbacks registred by the inner services
|
|
134
|
+
# and call them from here when the @resource classes are being
|
|
135
|
+
# scanned by venusian.
|
|
136
|
+
config = context.config.with_package(info.module)
|
|
137
|
+
|
|
138
|
+
# Storage is mandatory for resources.
|
|
139
|
+
if not hasattr(config.registry, "storage"):
|
|
140
|
+
msg = "Mandatory storage backend is missing from configuration."
|
|
141
|
+
raise pyramid_exceptions.ConfigurationError(msg)
|
|
142
|
+
|
|
143
|
+
# A service for the list.
|
|
144
|
+
service = register_service("plural", config.registry.settings)
|
|
145
|
+
config.add_cornice_service(service)
|
|
146
|
+
# An optional one for object endpoint.
|
|
147
|
+
if getattr(viewset, "object_path") is not None:
|
|
148
|
+
service = register_service("object", config.registry.settings)
|
|
149
|
+
config.add_cornice_service(service)
|
|
150
|
+
|
|
151
|
+
info = venusian.attach(resource_cls, callback, category="pyramid", depth=depth)
|
|
152
|
+
return callback
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Resource:
|
|
156
|
+
"""Resource class providing every HTTP endpoint.
|
|
157
|
+
|
|
158
|
+
A resource provides all the necessary mechanism for:
|
|
159
|
+
- storage and retrieval of objects according to HTTP verbs
|
|
160
|
+
- permission checking and tracking
|
|
161
|
+
- concurrency control
|
|
162
|
+
- synchronization
|
|
163
|
+
- OpenAPI metadata
|
|
164
|
+
|
|
165
|
+
Permissions are verified in :class:`kinto.core.authorization.AuthorizationPolicy` based on the
|
|
166
|
+
verb and context (eg. a put can create or update). The resulting context
|
|
167
|
+
is passed in the `context` constructor parameter.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
default_viewset = ViewSet
|
|
171
|
+
"""Default :class:`kinto.core.resource.viewset.ViewSet` class to use when
|
|
172
|
+
the resource is registered."""
|
|
173
|
+
|
|
174
|
+
default_model = Model
|
|
175
|
+
"""Default :class:`kinto.core.resource.model.Model` class to use for
|
|
176
|
+
interacting the :mod:`kinto.core.storage` and :mod:`kinto.core.permission`
|
|
177
|
+
backends."""
|
|
178
|
+
|
|
179
|
+
schema = ResourceSchema
|
|
180
|
+
"""Schema to validate objects."""
|
|
181
|
+
|
|
182
|
+
permissions = ("read", "write")
|
|
183
|
+
"""List of allowed permissions names."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, request, context=None):
|
|
186
|
+
"""
|
|
187
|
+
:param request:
|
|
188
|
+
The current request object.
|
|
189
|
+
:param context:
|
|
190
|
+
The resulting context obtained from :class:`kinto.core.authorization.AuthorizationPolicy`.
|
|
191
|
+
"""
|
|
192
|
+
self.request = request
|
|
193
|
+
self.context = context
|
|
194
|
+
|
|
195
|
+
content_type = str(self.request.headers.get("Content-Type")).lower()
|
|
196
|
+
self._is_json_patch = content_type == "application/json-patch+json"
|
|
197
|
+
self._is_merge_patch = content_type == "application/merge-patch+json"
|
|
198
|
+
|
|
199
|
+
# Models are isolated by user.
|
|
200
|
+
parent_id = self.get_parent_id(request)
|
|
201
|
+
|
|
202
|
+
# The principal of an anonymous is system.Everyone
|
|
203
|
+
current_principal = self.request.prefixed_userid or Everyone
|
|
204
|
+
|
|
205
|
+
if not hasattr(self, "model"):
|
|
206
|
+
self.model = self.default_model(
|
|
207
|
+
storage=request.registry.storage,
|
|
208
|
+
permission=request.registry.permission,
|
|
209
|
+
id_generator=self.id_generator,
|
|
210
|
+
resource_name=classname(self),
|
|
211
|
+
parent_id=parent_id,
|
|
212
|
+
current_principal=current_principal,
|
|
213
|
+
prefixed_principals=request.prefixed_principals,
|
|
214
|
+
explicit_perm=asbool(request.registry.settings["explicit_permissions"]),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Initialize timestamp as soon as possible.
|
|
218
|
+
self.timestamp
|
|
219
|
+
|
|
220
|
+
if self.context:
|
|
221
|
+
self.model.get_permission_object_id = functools.partial(
|
|
222
|
+
self.context.get_permission_object_id, self.request
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
@reify
|
|
226
|
+
def id_generator(self):
|
|
227
|
+
# ID generator by resource name in settings.
|
|
228
|
+
default_id_generator = self.request.registry.id_generators[""]
|
|
229
|
+
resource_name = self.request.current_resource_name
|
|
230
|
+
id_generator = self.request.registry.id_generators.get(resource_name, default_id_generator)
|
|
231
|
+
return id_generator
|
|
232
|
+
|
|
233
|
+
@reify
|
|
234
|
+
def timestamp(self):
|
|
235
|
+
"""Return the current resource timestamp.
|
|
236
|
+
|
|
237
|
+
:rtype: int
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
return self.model.timestamp()
|
|
241
|
+
except storage_exceptions.ReadonlyError as e:
|
|
242
|
+
# If the instance is configured to be readonly, and if the
|
|
243
|
+
# resource is empty, the backend will try to bump the timestamp.
|
|
244
|
+
# It fails if the configured db user has not write privileges.
|
|
245
|
+
logger.exception(e)
|
|
246
|
+
error_msg = (
|
|
247
|
+
"Resource timestamp cannot be written. "
|
|
248
|
+
"Plural endpoint must be hit at least once from a "
|
|
249
|
+
"writable instance."
|
|
250
|
+
)
|
|
251
|
+
raise http_error(HTTPServiceUnavailable(), errno=ERRORS.BACKEND, message=error_msg)
|
|
252
|
+
|
|
253
|
+
@reify
|
|
254
|
+
def object_id(self):
|
|
255
|
+
"""Return the object id for this request. It's either in the match dict
|
|
256
|
+
or in the posted body.
|
|
257
|
+
"""
|
|
258
|
+
if self.request.method.lower() == "post":
|
|
259
|
+
try:
|
|
260
|
+
# Since ``id`` does not belong to schema, it is not in validated
|
|
261
|
+
# data. Must look up in body directly instead of request.validated.
|
|
262
|
+
_id = self.request.json["data"][self.model.id_field]
|
|
263
|
+
self._raise_400_if_invalid_id(_id)
|
|
264
|
+
return _id
|
|
265
|
+
except (KeyError, ValueError):
|
|
266
|
+
return None
|
|
267
|
+
return self.request.matchdict.get("id")
|
|
268
|
+
|
|
269
|
+
def get_parent_id(self, request):
|
|
270
|
+
"""Return the parent_id of the resource with regards to the current
|
|
271
|
+
request.
|
|
272
|
+
|
|
273
|
+
The resource will isolate the objects from one parent id to another.
|
|
274
|
+
For example, in Kinto, the ``group``s and ``collection``s are isolated by ``bucket``.
|
|
275
|
+
|
|
276
|
+
In order to obtain a resource where users can only see their own objects, just
|
|
277
|
+
return the user id as the parent id:
|
|
278
|
+
|
|
279
|
+
.. code-block:: python
|
|
280
|
+
|
|
281
|
+
def get_parent_id(self, request):
|
|
282
|
+
return request.prefixed_userid
|
|
283
|
+
|
|
284
|
+
:param request:
|
|
285
|
+
The request used to access the resource.
|
|
286
|
+
|
|
287
|
+
:rtype: str
|
|
288
|
+
"""
|
|
289
|
+
return ""
|
|
290
|
+
|
|
291
|
+
def _get_known_fields(self):
|
|
292
|
+
"""Return all the `field` defined in the ressource schema."""
|
|
293
|
+
known_fields = [c.name for c in self.schema().children] + [
|
|
294
|
+
self.model.id_field,
|
|
295
|
+
self.model.modified_field,
|
|
296
|
+
self.model.deleted_field,
|
|
297
|
+
]
|
|
298
|
+
return known_fields
|
|
299
|
+
|
|
300
|
+
def is_known_field(self, field):
|
|
301
|
+
"""Return ``True`` if `field` is defined in the resource schema.
|
|
302
|
+
If the resource schema allows unknown fields, this will always return
|
|
303
|
+
``True``.
|
|
304
|
+
|
|
305
|
+
:param str field: Field name
|
|
306
|
+
:rtype: bool
|
|
307
|
+
|
|
308
|
+
"""
|
|
309
|
+
if self.schema.get_option("preserve_unknown"):
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
known_fields = self._get_known_fields()
|
|
313
|
+
# Test first level only: ``target.data.id`` -> ``target``
|
|
314
|
+
field = field.split(".", 1)[0]
|
|
315
|
+
return field in known_fields
|
|
316
|
+
|
|
317
|
+
#
|
|
318
|
+
# End-points
|
|
319
|
+
#
|
|
320
|
+
|
|
321
|
+
def plural_head(self):
|
|
322
|
+
"""Model ``HEAD`` endpoint: empty response with a ``Total-Objects`` header.
|
|
323
|
+
|
|
324
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotModified` if
|
|
325
|
+
``If-None-Match`` header is provided and collection not
|
|
326
|
+
modified in the interim.
|
|
327
|
+
|
|
328
|
+
:raises:
|
|
329
|
+
:exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if
|
|
330
|
+
``If-Match`` header is provided and collection modified
|
|
331
|
+
in the iterim.
|
|
332
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPBadRequest`
|
|
333
|
+
if filters or sorting are invalid.
|
|
334
|
+
"""
|
|
335
|
+
return self._plural_get(True)
|
|
336
|
+
|
|
337
|
+
def plural_get(self):
|
|
338
|
+
"""Model ``GET`` endpoint: retrieve multiple objects.
|
|
339
|
+
|
|
340
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotModified` if
|
|
341
|
+
``If-None-Match`` header is provided and the objects not
|
|
342
|
+
modified in the interim.
|
|
343
|
+
|
|
344
|
+
:raises:
|
|
345
|
+
:exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if
|
|
346
|
+
``If-Match`` header is provided and the objects modified
|
|
347
|
+
in the iterim.
|
|
348
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPBadRequest`
|
|
349
|
+
if filters or sorting are invalid.
|
|
350
|
+
"""
|
|
351
|
+
return self._plural_get(False)
|
|
352
|
+
|
|
353
|
+
def _plural_get(self, head_request):
|
|
354
|
+
self._add_timestamp_header(self.request.response)
|
|
355
|
+
self._add_cache_header(self.request.response)
|
|
356
|
+
self._raise_304_if_not_modified()
|
|
357
|
+
# Plural endpoints are considered resources that always exist
|
|
358
|
+
self._raise_412_if_modified(obj={})
|
|
359
|
+
|
|
360
|
+
headers = self.request.response.headers
|
|
361
|
+
|
|
362
|
+
filters = self._extract_filters()
|
|
363
|
+
limit = self._extract_limit()
|
|
364
|
+
sorting = self._extract_sorting(limit)
|
|
365
|
+
partial_fields = self._extract_partial_fields()
|
|
366
|
+
|
|
367
|
+
filter_fields = [f.field for f in filters]
|
|
368
|
+
include_deleted = self.model.modified_field in filter_fields
|
|
369
|
+
|
|
370
|
+
pagination_rules, offset = self._extract_pagination_rules_from_token(limit, sorting)
|
|
371
|
+
|
|
372
|
+
# The reason why we call self.model.get_objects() with `limit=limit + 1` is to avoid
|
|
373
|
+
# having to count the total number of objects in the database just to be able
|
|
374
|
+
# to *decide* whether or not to have a `Next-Page` header.
|
|
375
|
+
# This way, we can quickly depend on the number of objects returned and compare that
|
|
376
|
+
# with what the client requested.
|
|
377
|
+
# For example, if there are 100 objects in the database and the client used limit=100,
|
|
378
|
+
# it would, internally, ask for 101 objects. So if you retrieved 100 objects
|
|
379
|
+
# it means we got less than we asked for and thus there is not another page.
|
|
380
|
+
# Equally, if there are 200 objects in the database and the client used
|
|
381
|
+
# limit=100 it would, internally, ask for 101 objects and actually get that. Then,
|
|
382
|
+
# you know there is another page.
|
|
383
|
+
|
|
384
|
+
if head_request:
|
|
385
|
+
count = self.model.count_objects(filters=filters)
|
|
386
|
+
headers["Total-Objects"] = headers["Total-Records"] = str(count)
|
|
387
|
+
return self.postprocess([])
|
|
388
|
+
|
|
389
|
+
objects = self.model.get_objects(
|
|
390
|
+
filters=filters,
|
|
391
|
+
sorting=sorting,
|
|
392
|
+
limit=limit + 1, # See bigger explanation above.
|
|
393
|
+
pagination_rules=pagination_rules,
|
|
394
|
+
include_deleted=include_deleted,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
offset = offset + len(objects)
|
|
398
|
+
|
|
399
|
+
if limit and len(objects) == limit + 1:
|
|
400
|
+
lastobject = objects[-2]
|
|
401
|
+
next_page = self._next_page_url(sorting, limit, lastobject, offset)
|
|
402
|
+
headers["Next-Page"] = next_page
|
|
403
|
+
|
|
404
|
+
if partial_fields:
|
|
405
|
+
objects = [dict_subset(obj, partial_fields) for obj in objects]
|
|
406
|
+
|
|
407
|
+
# See bigger explanation above about the use of limits. The need for slicing
|
|
408
|
+
# here is because we might have asked for 1 more object just to see if there's
|
|
409
|
+
# a next page. But we have to honor the limit in our returned response.
|
|
410
|
+
return self.postprocess(objects[:limit])
|
|
411
|
+
|
|
412
|
+
def plural_post(self):
|
|
413
|
+
"""Model ``POST`` endpoint: create an object.
|
|
414
|
+
|
|
415
|
+
If the new object id conflicts against an existing one, the
|
|
416
|
+
posted object is ignored, and the existing object is returned, with
|
|
417
|
+
a ``200`` status.
|
|
418
|
+
|
|
419
|
+
:raises:
|
|
420
|
+
:exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if
|
|
421
|
+
``If-Match`` header is provided and the objects modified
|
|
422
|
+
in the iterim.
|
|
423
|
+
|
|
424
|
+
.. seealso::
|
|
425
|
+
|
|
426
|
+
Add custom behaviour by overriding
|
|
427
|
+
:meth:`kinto.core.resource.Resource.process_object`
|
|
428
|
+
"""
|
|
429
|
+
new_object = self.request.validated["body"].get("data", {})
|
|
430
|
+
|
|
431
|
+
existing = None
|
|
432
|
+
# If id was specified, then add it to posted body and look-up
|
|
433
|
+
# the existing object.
|
|
434
|
+
if self.object_id is not None:
|
|
435
|
+
new_object[self.model.id_field] = self.object_id
|
|
436
|
+
try:
|
|
437
|
+
existing = self._get_object_or_404(self.object_id)
|
|
438
|
+
except HTTPNotFound:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
self._raise_412_if_modified(obj=existing)
|
|
442
|
+
|
|
443
|
+
if existing:
|
|
444
|
+
obj = existing
|
|
445
|
+
action = ACTIONS.READ
|
|
446
|
+
else:
|
|
447
|
+
new_object = self.process_object(new_object)
|
|
448
|
+
obj = self.model.create_object(new_object)
|
|
449
|
+
self.request.response.status_code = 201
|
|
450
|
+
action = ACTIONS.CREATE
|
|
451
|
+
|
|
452
|
+
timestamp = obj[self.model.modified_field]
|
|
453
|
+
self._add_timestamp_header(self.request.response, timestamp=timestamp)
|
|
454
|
+
|
|
455
|
+
return self.postprocess(obj, action=action)
|
|
456
|
+
|
|
457
|
+
def plural_delete(self):
|
|
458
|
+
"""Model ``DELETE`` endpoint: delete multiple objects.
|
|
459
|
+
|
|
460
|
+
:raises:
|
|
461
|
+
:exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if
|
|
462
|
+
``If-Match`` header is provided and the objects modified
|
|
463
|
+
in the iterim.
|
|
464
|
+
|
|
465
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPBadRequest`
|
|
466
|
+
if filters are invalid.
|
|
467
|
+
"""
|
|
468
|
+
# Plural endpoint are considered resources that always exist
|
|
469
|
+
self._raise_412_if_modified(obj={})
|
|
470
|
+
|
|
471
|
+
filters = self._extract_filters()
|
|
472
|
+
limit = self._extract_limit()
|
|
473
|
+
sorting = self._extract_sorting(limit)
|
|
474
|
+
pagination_rules, offset = self._extract_pagination_rules_from_token(limit, sorting)
|
|
475
|
+
|
|
476
|
+
objects = self.model.get_objects(
|
|
477
|
+
filters=filters, sorting=sorting, limit=limit + 1, pagination_rules=pagination_rules
|
|
478
|
+
)
|
|
479
|
+
deleted = self.model.delete_objects(
|
|
480
|
+
filters=filters, sorting=sorting, limit=limit, pagination_rules=pagination_rules
|
|
481
|
+
)
|
|
482
|
+
if deleted:
|
|
483
|
+
lastobject = deleted[-1]
|
|
484
|
+
# Add pagination header, but only if there are more objects beyond the limit.
|
|
485
|
+
if limit and len(objects) == limit + 1:
|
|
486
|
+
next_page = self._next_page_url(sorting, limit, lastobject, offset)
|
|
487
|
+
self.request.response.headers["Next-Page"] = next_page
|
|
488
|
+
|
|
489
|
+
timestamp = max({d[self.model.modified_field] for d in deleted})
|
|
490
|
+
self._add_timestamp_header(self.request.response, timestamp=timestamp)
|
|
491
|
+
|
|
492
|
+
else:
|
|
493
|
+
self._add_timestamp_header(self.request.response)
|
|
494
|
+
|
|
495
|
+
action = len(deleted) > 0 and ACTIONS.DELETE or ACTIONS.READ
|
|
496
|
+
return self.postprocess(deleted, action=action, old=objects[:limit])
|
|
497
|
+
|
|
498
|
+
def get(self):
|
|
499
|
+
"""Object ``GET`` endpoint: retrieve an object.
|
|
500
|
+
|
|
501
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotFound` if
|
|
502
|
+
the object is not found.
|
|
503
|
+
|
|
504
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotModified` if
|
|
505
|
+
``If-None-Match`` header is provided and object not
|
|
506
|
+
modified in the interim.
|
|
507
|
+
|
|
508
|
+
:raises:
|
|
509
|
+
:exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if
|
|
510
|
+
``If-Match`` header is provided and object modified
|
|
511
|
+
in the iterim.
|
|
512
|
+
"""
|
|
513
|
+
self._raise_400_if_invalid_id(self.object_id)
|
|
514
|
+
obj = self._get_object_or_404(self.object_id)
|
|
515
|
+
timestamp = obj[self.model.modified_field]
|
|
516
|
+
self._add_timestamp_header(self.request.response, timestamp=timestamp)
|
|
517
|
+
self._add_cache_header(self.request.response)
|
|
518
|
+
self._raise_304_if_not_modified(obj)
|
|
519
|
+
self._raise_412_if_modified(obj)
|
|
520
|
+
|
|
521
|
+
partial_fields = self._extract_partial_fields()
|
|
522
|
+
if partial_fields:
|
|
523
|
+
obj = dict_subset(obj, partial_fields)
|
|
524
|
+
|
|
525
|
+
return self.postprocess(obj)
|
|
526
|
+
|
|
527
|
+
def put(self):
|
|
528
|
+
"""Object ``PUT`` endpoint: create or replace the provided object and
|
|
529
|
+
return it.
|
|
530
|
+
|
|
531
|
+
:raises:
|
|
532
|
+
:exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if
|
|
533
|
+
``If-Match`` header is provided and object modified
|
|
534
|
+
in the iterim.
|
|
535
|
+
|
|
536
|
+
.. note::
|
|
537
|
+
|
|
538
|
+
If ``If-None-Match: *`` request header is provided, the
|
|
539
|
+
``PUT`` will succeed only if no object exists with this id.
|
|
540
|
+
|
|
541
|
+
.. seealso::
|
|
542
|
+
|
|
543
|
+
Add custom behaviour by overriding
|
|
544
|
+
:meth:`kinto.core.resource.Resource.process_object`.
|
|
545
|
+
"""
|
|
546
|
+
self._raise_400_if_invalid_id(self.object_id)
|
|
547
|
+
try:
|
|
548
|
+
existing = self._get_object_or_404(self.object_id)
|
|
549
|
+
except HTTPNotFound:
|
|
550
|
+
existing = None
|
|
551
|
+
|
|
552
|
+
self._raise_412_if_modified(obj=existing)
|
|
553
|
+
|
|
554
|
+
# If `data` is not provided, use existing object (or empty if creation)
|
|
555
|
+
post_object = self.request.validated["body"].get("data", existing) or {}
|
|
556
|
+
|
|
557
|
+
object_id = post_object.setdefault(self.model.id_field, self.object_id)
|
|
558
|
+
self._raise_400_if_id_mismatch(object_id, self.object_id)
|
|
559
|
+
|
|
560
|
+
new_object = self.process_object(post_object, old=existing)
|
|
561
|
+
|
|
562
|
+
if existing:
|
|
563
|
+
obj = self.model.update_object(new_object)
|
|
564
|
+
else:
|
|
565
|
+
obj = self.model.create_object(new_object)
|
|
566
|
+
self.request.response.status_code = 201
|
|
567
|
+
|
|
568
|
+
timestamp = obj[self.model.modified_field]
|
|
569
|
+
self._add_timestamp_header(self.request.response, timestamp=timestamp)
|
|
570
|
+
|
|
571
|
+
action = existing and ACTIONS.UPDATE or ACTIONS.CREATE
|
|
572
|
+
return self.postprocess(obj, action=action, old=existing)
|
|
573
|
+
|
|
574
|
+
def patch(self):
|
|
575
|
+
"""Object ``PATCH`` endpoint: modify an object and return its
|
|
576
|
+
new version.
|
|
577
|
+
|
|
578
|
+
If a request header ``Response-Behavior`` is set to ``light``,
|
|
579
|
+
only the fields whose value was changed are returned.
|
|
580
|
+
If set to ``diff``, only the fields whose value became different than
|
|
581
|
+
the one provided are returned.
|
|
582
|
+
|
|
583
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotFound` if
|
|
584
|
+
the object is not found.
|
|
585
|
+
|
|
586
|
+
:raises:
|
|
587
|
+
:exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if
|
|
588
|
+
``If-Match`` header is provided and object modified
|
|
589
|
+
in the iterim.
|
|
590
|
+
|
|
591
|
+
.. seealso::
|
|
592
|
+
Add custom behaviour by overriding
|
|
593
|
+
:meth:`kinto.core.resource.Resource.apply_changes` or
|
|
594
|
+
:meth:`kinto.core.resource.Resource.process_object`.
|
|
595
|
+
"""
|
|
596
|
+
self._raise_400_if_invalid_id(self.object_id)
|
|
597
|
+
existing = self._get_object_or_404(self.object_id)
|
|
598
|
+
self._raise_412_if_modified(existing)
|
|
599
|
+
|
|
600
|
+
# patch is specified as a list of of operations (RFC 6902)
|
|
601
|
+
if self._is_json_patch:
|
|
602
|
+
requested_changes = self.request.validated["body"]
|
|
603
|
+
else:
|
|
604
|
+
# `data` attribute may not be present if only perms are patched.
|
|
605
|
+
body = self.request.validated["body"]
|
|
606
|
+
if not body:
|
|
607
|
+
# If no `data` nor `permissions` is provided in patch, reject!
|
|
608
|
+
# XXX: This should happen in schema instead (c.f. ViewSet)
|
|
609
|
+
error_details = {
|
|
610
|
+
"name": "data",
|
|
611
|
+
"description": "Provide at least one of data or permissions",
|
|
612
|
+
}
|
|
613
|
+
raise_invalid(self.request, **error_details)
|
|
614
|
+
requested_changes = body.get("data", {})
|
|
615
|
+
|
|
616
|
+
updated, applied_changes = self.apply_changes(
|
|
617
|
+
obj=existing, requested_changes=requested_changes
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
object_id = updated.setdefault(self.model.id_field, self.object_id)
|
|
621
|
+
self._raise_400_if_id_mismatch(object_id, self.object_id)
|
|
622
|
+
|
|
623
|
+
new_object = self.process_object(updated, old=existing)
|
|
624
|
+
|
|
625
|
+
changed_fields = [
|
|
626
|
+
k for k in applied_changes.keys() if existing.get(k) != new_object.get(k)
|
|
627
|
+
]
|
|
628
|
+
|
|
629
|
+
new_object = self.model.update_object(new_object)
|
|
630
|
+
|
|
631
|
+
# Adjust response according to ``Response-Behavior`` header
|
|
632
|
+
body_behavior = self.request.validated["header"].get("Response-Behavior", "full")
|
|
633
|
+
|
|
634
|
+
if body_behavior.lower() == "light":
|
|
635
|
+
# Only fields that were changed.
|
|
636
|
+
data = {k: new_object[k] for k in changed_fields}
|
|
637
|
+
|
|
638
|
+
elif body_behavior.lower() == "diff":
|
|
639
|
+
# Only fields that are different from those provided.
|
|
640
|
+
data = {
|
|
641
|
+
k: new_object[k]
|
|
642
|
+
for k in changed_fields
|
|
643
|
+
if applied_changes.get(k) != new_object.get(k)
|
|
644
|
+
}
|
|
645
|
+
else:
|
|
646
|
+
data = new_object
|
|
647
|
+
|
|
648
|
+
timestamp = new_object.get(self.model.modified_field, existing[self.model.modified_field])
|
|
649
|
+
self._add_timestamp_header(self.request.response, timestamp=timestamp)
|
|
650
|
+
|
|
651
|
+
return self.postprocess(data, action=ACTIONS.UPDATE, old=existing)
|
|
652
|
+
|
|
653
|
+
def delete(self):
|
|
654
|
+
"""Object ``DELETE`` endpoint: delete an object and return it.
|
|
655
|
+
|
|
656
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotFound` if
|
|
657
|
+
the object is not found.
|
|
658
|
+
|
|
659
|
+
:raises:
|
|
660
|
+
:exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if
|
|
661
|
+
``If-Match`` header is provided and object modified
|
|
662
|
+
in the iterim.
|
|
663
|
+
"""
|
|
664
|
+
self._raise_400_if_invalid_id(self.object_id)
|
|
665
|
+
obj = self._get_object_or_404(self.object_id)
|
|
666
|
+
self._raise_412_if_modified(obj)
|
|
667
|
+
|
|
668
|
+
# Retrieve the last_modified information from a querystring if present.
|
|
669
|
+
last_modified = self.request.validated["querystring"].get("last_modified")
|
|
670
|
+
|
|
671
|
+
# If less or equal than current object. Ignore it.
|
|
672
|
+
if last_modified and last_modified <= obj[self.model.modified_field]:
|
|
673
|
+
last_modified = None
|
|
674
|
+
|
|
675
|
+
try:
|
|
676
|
+
deleted = self.model.delete_object(obj, last_modified=last_modified)
|
|
677
|
+
except storage_exceptions.ObjectNotFoundError:
|
|
678
|
+
# Delete might fail if the object was deleted since we
|
|
679
|
+
# fetched it from the storage (ref Kinto/kinto#1407). This
|
|
680
|
+
# is one of a larger class of issues where another request
|
|
681
|
+
# could modify the object between our fetch and our
|
|
682
|
+
# delete, which could e.g. invalidate our precondition
|
|
683
|
+
# checking. Fixing this correctly is a larger
|
|
684
|
+
# problem. However, let's punt on fixing it correctly and
|
|
685
|
+
# just handle this one important case for now (see #1557).
|
|
686
|
+
#
|
|
687
|
+
# Raise a 404 vs. a 409 or 412 because that's what we
|
|
688
|
+
# would have done if the other thread's delete had
|
|
689
|
+
# happened a little earlier. (The client doesn't need to
|
|
690
|
+
# know that we did a bunch of work fetching the existing
|
|
691
|
+
# object for nothing.)
|
|
692
|
+
raise self._404_for_object(self.object_id)
|
|
693
|
+
|
|
694
|
+
timestamp = deleted[self.model.modified_field]
|
|
695
|
+
self._add_timestamp_header(self.request.response, timestamp=timestamp)
|
|
696
|
+
|
|
697
|
+
return self.postprocess(deleted, action=ACTIONS.DELETE, old=obj)
|
|
698
|
+
|
|
699
|
+
#
|
|
700
|
+
# Data processing
|
|
701
|
+
#
|
|
702
|
+
|
|
703
|
+
def process_object(self, new, old=None):
|
|
704
|
+
"""Hook for processing objects before they reach storage, to introduce
|
|
705
|
+
specific logics on fields for example.
|
|
706
|
+
|
|
707
|
+
.. code-block:: python
|
|
708
|
+
|
|
709
|
+
def process_object(self, new, old=None):
|
|
710
|
+
new = super().process_object(new, old)
|
|
711
|
+
version = old['version'] if old else 0
|
|
712
|
+
new['version'] = version + 1
|
|
713
|
+
return new
|
|
714
|
+
|
|
715
|
+
Or add extra validation based on request:
|
|
716
|
+
|
|
717
|
+
.. code-block:: python
|
|
718
|
+
|
|
719
|
+
from kinto.core.errors import raise_invalid
|
|
720
|
+
|
|
721
|
+
def process_object(self, new, old=None):
|
|
722
|
+
new = super().process_object(new, old)
|
|
723
|
+
if new['browser'] not in request.headers['User-Agent']:
|
|
724
|
+
raise_invalid(self.request, name='browser', error='Wrong')
|
|
725
|
+
return new
|
|
726
|
+
|
|
727
|
+
:param dict new: the validated object to be created or updated.
|
|
728
|
+
:param dict old: the old object to be updated,
|
|
729
|
+
``None`` for creation endpoints.
|
|
730
|
+
|
|
731
|
+
:returns: the processed object.
|
|
732
|
+
:rtype: dict
|
|
733
|
+
"""
|
|
734
|
+
modified_field = self.model.modified_field
|
|
735
|
+
new_last_modified = new.get(modified_field)
|
|
736
|
+
|
|
737
|
+
# Drop the new last_modified if it is not an integer.
|
|
738
|
+
is_integer = isinstance(new_last_modified, int)
|
|
739
|
+
if not is_integer:
|
|
740
|
+
new.pop(modified_field, None)
|
|
741
|
+
new_last_modified = None
|
|
742
|
+
|
|
743
|
+
# Drop the new last_modified if lesser or equal to the old one.
|
|
744
|
+
is_less_or_equal = (
|
|
745
|
+
new_last_modified and old is not None and new_last_modified <= old[modified_field]
|
|
746
|
+
)
|
|
747
|
+
if is_less_or_equal:
|
|
748
|
+
new.pop(modified_field, None)
|
|
749
|
+
|
|
750
|
+
# patch is specified as a list of of operations (RFC 6902)
|
|
751
|
+
|
|
752
|
+
payload = self.request.validated["body"]
|
|
753
|
+
|
|
754
|
+
if self._is_json_patch:
|
|
755
|
+
permissions = apply_json_patch(old, payload)["permissions"]
|
|
756
|
+
|
|
757
|
+
elif self._is_merge_patch:
|
|
758
|
+
existing = old or {}
|
|
759
|
+
permissions = existing.get("__permissions__", {})
|
|
760
|
+
recursive_update_dict(permissions, payload.get("permissions", {}), ignores=(None,))
|
|
761
|
+
|
|
762
|
+
else:
|
|
763
|
+
permissions = {
|
|
764
|
+
k: v for k, v in payload.get("permissions", {}).items() if v is not None
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
annotated = {**new}
|
|
768
|
+
|
|
769
|
+
if permissions:
|
|
770
|
+
is_put = self.request.method.lower() == "put"
|
|
771
|
+
if is_put or self._is_merge_patch:
|
|
772
|
+
# Remove every existing ACEs using empty lists.
|
|
773
|
+
for perm in self.permissions:
|
|
774
|
+
permissions.setdefault(perm, [])
|
|
775
|
+
annotated[self.model.permissions_field] = permissions
|
|
776
|
+
|
|
777
|
+
return annotated
|
|
778
|
+
|
|
779
|
+
def apply_changes(self, obj, requested_changes):
|
|
780
|
+
"""Merge `changes` into `object` fields.
|
|
781
|
+
|
|
782
|
+
.. note::
|
|
783
|
+
|
|
784
|
+
This is used in the context of PATCH only.
|
|
785
|
+
|
|
786
|
+
Override this to control field changes at object level, for example:
|
|
787
|
+
|
|
788
|
+
.. code-block:: python
|
|
789
|
+
|
|
790
|
+
def apply_changes(self, obj, requested_changes):
|
|
791
|
+
# Ignore value change if inferior
|
|
792
|
+
if object['position'] > changes.get('position', -1):
|
|
793
|
+
changes.pop('position', None)
|
|
794
|
+
return super().apply_changes(obj, requested_changes)
|
|
795
|
+
|
|
796
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPBadRequest`
|
|
797
|
+
if result does not comply with resource schema.
|
|
798
|
+
|
|
799
|
+
:returns: the new object with `changes` applied.
|
|
800
|
+
:rtype: tuple
|
|
801
|
+
"""
|
|
802
|
+
if self._is_json_patch:
|
|
803
|
+
try:
|
|
804
|
+
applied_changes = apply_json_patch(obj, requested_changes)["data"]
|
|
805
|
+
updated = {**applied_changes}
|
|
806
|
+
except ValueError as e:
|
|
807
|
+
error_details = {
|
|
808
|
+
"location": "body",
|
|
809
|
+
"description": f"JSON Patch operation failed: {e}",
|
|
810
|
+
}
|
|
811
|
+
raise_invalid(self.request, **error_details)
|
|
812
|
+
|
|
813
|
+
else:
|
|
814
|
+
applied_changes = {**requested_changes}
|
|
815
|
+
updated = {**obj}
|
|
816
|
+
|
|
817
|
+
# recursive patch and remove field if null attribute is passed (RFC 7396)
|
|
818
|
+
if self._is_merge_patch:
|
|
819
|
+
recursive_update_dict(updated, applied_changes, ignores=(None,))
|
|
820
|
+
else:
|
|
821
|
+
updated.update(**applied_changes)
|
|
822
|
+
|
|
823
|
+
for field, value in applied_changes.items():
|
|
824
|
+
has_changed = obj.get(field, value) != value
|
|
825
|
+
if self.schema.is_readonly(field) and has_changed:
|
|
826
|
+
error_details = {"name": field, "description": f"Cannot modify {field}"}
|
|
827
|
+
raise_invalid(self.request, **error_details)
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
validated = self.schema().deserialize(updated)
|
|
831
|
+
except colander.Invalid as e:
|
|
832
|
+
# Transform the errors we got from colander into Cornice errors.
|
|
833
|
+
# We could not rely on Service schema because the object should be
|
|
834
|
+
# validated only once the changes are applied
|
|
835
|
+
for field, error in e.asdict().items(): # pragma: no branch
|
|
836
|
+
raise_invalid(self.request, name=field, description=error)
|
|
837
|
+
|
|
838
|
+
return validated, applied_changes
|
|
839
|
+
|
|
840
|
+
def postprocess(self, result, action=ACTIONS.READ, old=None):
|
|
841
|
+
body = {}
|
|
842
|
+
|
|
843
|
+
if not isinstance(result, list):
|
|
844
|
+
perms = result.pop(self.model.permissions_field, None)
|
|
845
|
+
if perms is not None:
|
|
846
|
+
body["permissions"] = {k: list(p) for k, p in perms.items()}
|
|
847
|
+
if old:
|
|
848
|
+
# Remove permissions from event payload.
|
|
849
|
+
old.pop(self.model.permissions_field, None)
|
|
850
|
+
|
|
851
|
+
body["data"] = result
|
|
852
|
+
|
|
853
|
+
parent_id = self.get_parent_id(self.request)
|
|
854
|
+
# Use self.model.timestamp() instead of self.timestamp because
|
|
855
|
+
# self.timestamp is @reify'd relatively early in the request,
|
|
856
|
+
# so doesn't correspond to any time that is relevant to the
|
|
857
|
+
# event. See #1769.
|
|
858
|
+
timestamp = self.model.timestamp()
|
|
859
|
+
self.request.notify_resource_event(
|
|
860
|
+
parent_id=parent_id, timestamp=timestamp, data=result, action=action, old=old
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
return body
|
|
864
|
+
|
|
865
|
+
#
|
|
866
|
+
# Internals
|
|
867
|
+
#
|
|
868
|
+
|
|
869
|
+
def _404_for_object(self, object_id):
|
|
870
|
+
details = {"id": object_id, "resource_name": self.request.current_resource_name}
|
|
871
|
+
return http_error(HTTPNotFound(), errno=ERRORS.INVALID_RESOURCE_ID, details=details)
|
|
872
|
+
|
|
873
|
+
def _get_object_or_404(self, object_id):
|
|
874
|
+
"""Retrieve object from storage and raise ``404 Not found`` if missing.
|
|
875
|
+
|
|
876
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotFound` if
|
|
877
|
+
the object is not found.
|
|
878
|
+
"""
|
|
879
|
+
if self.context and self.context.current_object:
|
|
880
|
+
# Set during authorization. Save a storage hit.
|
|
881
|
+
return self.context.current_object
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
return self.model.get_object(object_id)
|
|
885
|
+
except storage_exceptions.ObjectNotFoundError:
|
|
886
|
+
raise self._404_for_object(object_id)
|
|
887
|
+
|
|
888
|
+
def _add_timestamp_header(self, response, timestamp=None):
|
|
889
|
+
"""Add current timestamp in response headers, when request comes in."""
|
|
890
|
+
if timestamp is None:
|
|
891
|
+
timestamp = self.timestamp
|
|
892
|
+
# Pyramid takes care of converting.
|
|
893
|
+
response.last_modified = timestamp / 1000.0
|
|
894
|
+
# Return timestamp as ETag.
|
|
895
|
+
response.headers["ETag"] = f'"{timestamp}"'
|
|
896
|
+
|
|
897
|
+
def _add_cache_header(self, response):
|
|
898
|
+
"""Add Cache-Control and Expire headers, based a on a setting for the
|
|
899
|
+
current resource.
|
|
900
|
+
|
|
901
|
+
Cache headers will be set with anonymous requests only.
|
|
902
|
+
|
|
903
|
+
.. note::
|
|
904
|
+
|
|
905
|
+
The ``Cache-Control: no-cache`` response header does not prevent
|
|
906
|
+
caching in client. It will indicate the client to revalidate
|
|
907
|
+
the response content on each access. The client will send a
|
|
908
|
+
conditional request to the server and check that a
|
|
909
|
+
``304 Not modified`` is returned before serving content from cache.
|
|
910
|
+
"""
|
|
911
|
+
resource_name = self.context.resource_name if self.context else ""
|
|
912
|
+
setting_key = f"{resource_name}_cache_expires_seconds"
|
|
913
|
+
cache_expires = self.request.registry.settings.get(setting_key)
|
|
914
|
+
is_anonymous = self.request.prefixed_userid is None
|
|
915
|
+
if cache_expires and is_anonymous:
|
|
916
|
+
response.cache_expires(seconds=int(cache_expires))
|
|
917
|
+
else:
|
|
918
|
+
# Since `Expires` response header provides an HTTP data with a
|
|
919
|
+
# resolution in seconds, do not use Pyramid `cache_expires()` in
|
|
920
|
+
# order to omit it.
|
|
921
|
+
response.cache_control.no_cache = True
|
|
922
|
+
response.cache_control.no_store = True
|
|
923
|
+
|
|
924
|
+
def _raise_400_if_invalid_id(self, object_id):
|
|
925
|
+
"""Raise 400 if specified object id does not match the format excepted
|
|
926
|
+
by storage backends.
|
|
927
|
+
|
|
928
|
+
:raises: :class:`pyramid.httpexceptions.HTTPBadRequest`
|
|
929
|
+
"""
|
|
930
|
+
is_string = isinstance(object_id, str)
|
|
931
|
+
if not is_string or not self.model.id_generator.match(object_id):
|
|
932
|
+
error_details = {"location": "path", "description": "Invalid object id"}
|
|
933
|
+
raise_invalid(self.request, **error_details)
|
|
934
|
+
|
|
935
|
+
def _raise_304_if_not_modified(self, obj=None):
|
|
936
|
+
"""Raise 304 if current timestamp is inferior to the one specified
|
|
937
|
+
in headers.
|
|
938
|
+
|
|
939
|
+
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotModified`
|
|
940
|
+
"""
|
|
941
|
+
if_none_match = self.request.validated["header"].get("If-None-Match")
|
|
942
|
+
|
|
943
|
+
if not if_none_match:
|
|
944
|
+
return
|
|
945
|
+
|
|
946
|
+
if if_none_match == "*":
|
|
947
|
+
return
|
|
948
|
+
|
|
949
|
+
if obj:
|
|
950
|
+
current_timestamp = obj[self.model.modified_field]
|
|
951
|
+
else:
|
|
952
|
+
current_timestamp = self.model.timestamp()
|
|
953
|
+
|
|
954
|
+
if current_timestamp == if_none_match:
|
|
955
|
+
response = HTTPNotModified()
|
|
956
|
+
self._add_timestamp_header(response, timestamp=current_timestamp)
|
|
957
|
+
raise response
|
|
958
|
+
|
|
959
|
+
def _raise_412_if_modified(self, obj=None):
|
|
960
|
+
"""Raise 412 if current timestamp is superior to the one
|
|
961
|
+
specified in headers.
|
|
962
|
+
|
|
963
|
+
:raises:
|
|
964
|
+
:exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed`
|
|
965
|
+
"""
|
|
966
|
+
if_match = self.request.validated["header"].get("If-Match")
|
|
967
|
+
if_none_match = self.request.validated["header"].get("If-None-Match")
|
|
968
|
+
|
|
969
|
+
# Check if object exists
|
|
970
|
+
object_exists = obj is not None
|
|
971
|
+
|
|
972
|
+
# If no precondition headers, just ignore
|
|
973
|
+
if not if_match and not if_none_match:
|
|
974
|
+
return
|
|
975
|
+
|
|
976
|
+
# If-None-Match: * should always raise if an object exists
|
|
977
|
+
if if_none_match == "*" and object_exists:
|
|
978
|
+
modified_since = -1 # Always raise.
|
|
979
|
+
|
|
980
|
+
# If-Match should always raise if an object doesn't exist
|
|
981
|
+
elif if_match and not object_exists:
|
|
982
|
+
modified_since = -1
|
|
983
|
+
|
|
984
|
+
# If-Match with ETag value on existing objects should compare ETag
|
|
985
|
+
elif if_match and if_match != "*":
|
|
986
|
+
modified_since = if_match
|
|
987
|
+
|
|
988
|
+
# If none of the above applies, don't raise
|
|
989
|
+
else:
|
|
990
|
+
return
|
|
991
|
+
|
|
992
|
+
if obj:
|
|
993
|
+
current_timestamp = obj[self.model.modified_field]
|
|
994
|
+
else:
|
|
995
|
+
current_timestamp = self.model.timestamp()
|
|
996
|
+
|
|
997
|
+
if current_timestamp != modified_since:
|
|
998
|
+
error_msg = "Resource was modified meanwhile"
|
|
999
|
+
# Do not provide the permissions among the object fields.
|
|
1000
|
+
# Ref: https://github.com/Kinto/kinto/issues/224
|
|
1001
|
+
existing = {**obj} if obj else {}
|
|
1002
|
+
existing.pop(self.model.permissions_field, None)
|
|
1003
|
+
|
|
1004
|
+
details = {"existing": existing} if obj else {}
|
|
1005
|
+
response = http_error(
|
|
1006
|
+
HTTPPreconditionFailed(),
|
|
1007
|
+
errno=ERRORS.MODIFIED_MEANWHILE,
|
|
1008
|
+
message=error_msg,
|
|
1009
|
+
details=details,
|
|
1010
|
+
)
|
|
1011
|
+
self._add_timestamp_header(response, timestamp=current_timestamp)
|
|
1012
|
+
raise response
|
|
1013
|
+
|
|
1014
|
+
def _raise_400_if_id_mismatch(self, new_id, object_id):
|
|
1015
|
+
"""Raise 400 if the `new_id`, within the request body, does not match
|
|
1016
|
+
the `object_id`, obtained from request path.
|
|
1017
|
+
|
|
1018
|
+
:raises: :class:`pyramid.httpexceptions.HTTPBadRequest`
|
|
1019
|
+
"""
|
|
1020
|
+
if new_id != object_id:
|
|
1021
|
+
error_msg = "Object id does not match existing object"
|
|
1022
|
+
error_details = {"name": self.model.id_field, "description": error_msg}
|
|
1023
|
+
raise_invalid(self.request, **error_details)
|
|
1024
|
+
|
|
1025
|
+
def _extract_partial_fields(self):
|
|
1026
|
+
"""Extract the fields to do the projection from QueryString parameters."""
|
|
1027
|
+
fields = self.request.validated["querystring"].get("_fields")
|
|
1028
|
+
if fields:
|
|
1029
|
+
root_fields = [f.split(".")[0] for f in fields]
|
|
1030
|
+
known_fields = self._get_known_fields()
|
|
1031
|
+
invalid_fields = set(root_fields) - set(known_fields)
|
|
1032
|
+
preserve_unknown = self.schema.get_option("preserve_unknown")
|
|
1033
|
+
if not preserve_unknown and invalid_fields:
|
|
1034
|
+
error_msg = f"Fields {','.join(invalid_fields)} do not exist"
|
|
1035
|
+
error_details = {"name": "Invalid _fields parameter", "description": error_msg}
|
|
1036
|
+
raise_invalid(self.request, **error_details)
|
|
1037
|
+
|
|
1038
|
+
# Since id and last_modified are part of the synchronisation
|
|
1039
|
+
# API, force their presence in payloads.
|
|
1040
|
+
fields = fields + [self.model.id_field, self.model.modified_field]
|
|
1041
|
+
|
|
1042
|
+
return fields
|
|
1043
|
+
|
|
1044
|
+
def _extract_limit(self):
|
|
1045
|
+
"""Extract limit value from QueryString parameters."""
|
|
1046
|
+
paginate_by = self.request.registry.settings["paginate_by"]
|
|
1047
|
+
max_fetch_size = self.request.registry.settings["storage_max_fetch_size"]
|
|
1048
|
+
limit = self.request.validated["querystring"].get("_limit", paginate_by)
|
|
1049
|
+
|
|
1050
|
+
# If limit is higher than paginate_by setting, ignore it.
|
|
1051
|
+
if limit and paginate_by:
|
|
1052
|
+
limit = min(limit, paginate_by)
|
|
1053
|
+
|
|
1054
|
+
# If limit is higher than what storage can retrieve, ignore it.
|
|
1055
|
+
limit = min(limit, max_fetch_size) if limit else max_fetch_size
|
|
1056
|
+
|
|
1057
|
+
return limit
|
|
1058
|
+
|
|
1059
|
+
def _extract_filters(self):
|
|
1060
|
+
"""Extracts filters from QueryString parameters."""
|
|
1061
|
+
|
|
1062
|
+
def is_valid_timestamp(value):
|
|
1063
|
+
# Is either integer, or integer as string, or integer between 2 quotes.
|
|
1064
|
+
return isinstance(value, int) or re.match(r'^(\d+)$|^("\d+")$', str(value))
|
|
1065
|
+
|
|
1066
|
+
queryparams = self.request.validated["querystring"]
|
|
1067
|
+
|
|
1068
|
+
filters = []
|
|
1069
|
+
|
|
1070
|
+
for param, value in queryparams.items():
|
|
1071
|
+
param = param.strip()
|
|
1072
|
+
|
|
1073
|
+
error_details = {
|
|
1074
|
+
"name": param,
|
|
1075
|
+
"location": "querystring",
|
|
1076
|
+
"description": f"Invalid value for {param}",
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
# Ignore specific fields
|
|
1080
|
+
if param.startswith("_") and param not in ("_since", "_to", "_before"):
|
|
1081
|
+
continue
|
|
1082
|
+
|
|
1083
|
+
# Handle the _since specific filter.
|
|
1084
|
+
if param in ("_since", "_to", "_before"):
|
|
1085
|
+
if param == "_since":
|
|
1086
|
+
operator = COMPARISON.GT
|
|
1087
|
+
else:
|
|
1088
|
+
if param == "_to":
|
|
1089
|
+
message = "_to is now deprecated, you should use _before instead"
|
|
1090
|
+
url = (
|
|
1091
|
+
"https://kinto.readthedocs.io/en/2.4.0/api/"
|
|
1092
|
+
"resource.html#list-of-available-url-"
|
|
1093
|
+
"parameters"
|
|
1094
|
+
)
|
|
1095
|
+
send_alert(self.request, message, url)
|
|
1096
|
+
operator = COMPARISON.LT
|
|
1097
|
+
|
|
1098
|
+
if value is not None and not is_valid_timestamp(value):
|
|
1099
|
+
raise_invalid(self.request, **error_details)
|
|
1100
|
+
|
|
1101
|
+
filters.append(Filter(self.model.modified_field, value, operator))
|
|
1102
|
+
continue
|
|
1103
|
+
|
|
1104
|
+
all_keywords = r"|".join([i.name.lower() for i in COMPARISON])
|
|
1105
|
+
m = re.match(r"^(" + all_keywords + r")_([\w\.]+)$", param)
|
|
1106
|
+
if m:
|
|
1107
|
+
keyword, field = m.groups()
|
|
1108
|
+
operator = getattr(COMPARISON, keyword.upper())
|
|
1109
|
+
else:
|
|
1110
|
+
operator, field = COMPARISON.EQ, param
|
|
1111
|
+
|
|
1112
|
+
if not self.is_known_field(field):
|
|
1113
|
+
error_msg = f"Unknown filter field '{param}'"
|
|
1114
|
+
error_details["description"] = error_msg
|
|
1115
|
+
raise_invalid(self.request, **error_details)
|
|
1116
|
+
|
|
1117
|
+
# Return 400 if _limit is not a string
|
|
1118
|
+
if operator == COMPARISON.LIKE:
|
|
1119
|
+
if not isinstance(value, str):
|
|
1120
|
+
raise_invalid(self.request, **error_details)
|
|
1121
|
+
|
|
1122
|
+
if operator in (COMPARISON.IN, COMPARISON.EXCLUDE):
|
|
1123
|
+
all_integers = all([isinstance(v, int) for v in value])
|
|
1124
|
+
all_strings = all([isinstance(v, str) for v in value])
|
|
1125
|
+
has_invalid_value = (field == self.model.id_field and not all_strings) or (
|
|
1126
|
+
field == self.model.modified_field and not all_integers
|
|
1127
|
+
)
|
|
1128
|
+
if has_invalid_value:
|
|
1129
|
+
raise_invalid(self.request, **error_details)
|
|
1130
|
+
|
|
1131
|
+
if "\x00" in field or "\x00" in str(value):
|
|
1132
|
+
error_details["description"] = "Invalid character 0x00"
|
|
1133
|
+
raise_invalid(self.request, **error_details)
|
|
1134
|
+
|
|
1135
|
+
if field == self.model.modified_field and not is_valid_timestamp(value):
|
|
1136
|
+
raise_invalid(self.request, **error_details)
|
|
1137
|
+
|
|
1138
|
+
if field in (self.model.modified_field, self.model.id_field) and operator in (
|
|
1139
|
+
COMPARISON.CONTAINS,
|
|
1140
|
+
COMPARISON.CONTAINS_ANY,
|
|
1141
|
+
):
|
|
1142
|
+
error_msg = f"Field '{field}' is not an array"
|
|
1143
|
+
error_details["description"] = error_msg
|
|
1144
|
+
raise_invalid(self.request, **error_details)
|
|
1145
|
+
|
|
1146
|
+
filters.append(Filter(field, value, operator))
|
|
1147
|
+
|
|
1148
|
+
# If a plural endpoint is reached, and if the user does not have the
|
|
1149
|
+
# permission to read/write the whole list, the set is filtered by ids,
|
|
1150
|
+
# based on the list of ids returned by the authorization policy.
|
|
1151
|
+
ids = self.context.shared_ids
|
|
1152
|
+
if ids is not None:
|
|
1153
|
+
filter_by_id = Filter(self.model.id_field, ids, COMPARISON.IN)
|
|
1154
|
+
filters.insert(0, filter_by_id)
|
|
1155
|
+
|
|
1156
|
+
return filters
|
|
1157
|
+
|
|
1158
|
+
def _extract_sorting(self, limit):
|
|
1159
|
+
"""Extracts filters from QueryString parameters."""
|
|
1160
|
+
specified = self.request.validated["querystring"].get("_sort", [])
|
|
1161
|
+
sorting = []
|
|
1162
|
+
modified_field_used = self.model.modified_field in specified
|
|
1163
|
+
for field in specified:
|
|
1164
|
+
field = field.strip()
|
|
1165
|
+
m = re.match(r"^([\-+]?)([\w\.]+)$", field)
|
|
1166
|
+
if m:
|
|
1167
|
+
order, field = m.groups()
|
|
1168
|
+
|
|
1169
|
+
if not self.is_known_field(field):
|
|
1170
|
+
error_details = {
|
|
1171
|
+
"location": "querystring",
|
|
1172
|
+
"description": f"Unknown sort field '{field}'",
|
|
1173
|
+
}
|
|
1174
|
+
raise_invalid(self.request, **error_details)
|
|
1175
|
+
|
|
1176
|
+
direction = -1 if order == "-" else 1
|
|
1177
|
+
sorting.append(Sort(field, direction))
|
|
1178
|
+
|
|
1179
|
+
if not modified_field_used:
|
|
1180
|
+
# Add a sort by the ``modified_field`` in descending order
|
|
1181
|
+
# useful for pagination
|
|
1182
|
+
sorting.append(Sort(self.model.modified_field, -1))
|
|
1183
|
+
return sorting
|
|
1184
|
+
|
|
1185
|
+
def _build_pagination_rules(self, sorting, last_object, rules=None):
|
|
1186
|
+
"""Return the list of rules for a given sorting attribute and
|
|
1187
|
+
last_object.
|
|
1188
|
+
|
|
1189
|
+
"""
|
|
1190
|
+
if rules is None:
|
|
1191
|
+
rules = []
|
|
1192
|
+
|
|
1193
|
+
rule = []
|
|
1194
|
+
next_sorting = sorting[:-1]
|
|
1195
|
+
|
|
1196
|
+
for field, _ in next_sorting:
|
|
1197
|
+
rule.append(Filter(field, last_object.get(field, MISSING), COMPARISON.EQ))
|
|
1198
|
+
|
|
1199
|
+
field, direction = sorting[-1]
|
|
1200
|
+
|
|
1201
|
+
if direction == -1:
|
|
1202
|
+
rule.append(Filter(field, last_object.get(field, MISSING), COMPARISON.LT))
|
|
1203
|
+
else:
|
|
1204
|
+
rule.append(Filter(field, last_object.get(field, MISSING), COMPARISON.GT))
|
|
1205
|
+
|
|
1206
|
+
rules.append(rule)
|
|
1207
|
+
|
|
1208
|
+
if len(next_sorting) == 0:
|
|
1209
|
+
return rules
|
|
1210
|
+
|
|
1211
|
+
return self._build_pagination_rules(next_sorting, last_object, rules)
|
|
1212
|
+
|
|
1213
|
+
def _extract_pagination_rules_from_token(self, limit, sorting):
|
|
1214
|
+
"""Get pagination params."""
|
|
1215
|
+
token = self.request.validated["querystring"].get("_token", None)
|
|
1216
|
+
filters = []
|
|
1217
|
+
offset = 0
|
|
1218
|
+
if token:
|
|
1219
|
+
error_msg = None
|
|
1220
|
+
try:
|
|
1221
|
+
tokeninfo = json.loads(decode64(token))
|
|
1222
|
+
if not isinstance(tokeninfo, dict):
|
|
1223
|
+
raise ValueError()
|
|
1224
|
+
last_object = tokeninfo["last_object"]
|
|
1225
|
+
offset = tokeninfo["offset"]
|
|
1226
|
+
nonce = tokeninfo["nonce"]
|
|
1227
|
+
except (ValueError, KeyError, TypeError):
|
|
1228
|
+
error_msg = "_token has invalid content"
|
|
1229
|
+
|
|
1230
|
+
# We don't want pagination tokens to be reused several times (#1171).
|
|
1231
|
+
# The cache backend is used to keep track of "nonces".
|
|
1232
|
+
if self.request.method.lower() == "delete" and error_msg is None:
|
|
1233
|
+
registry = self.request.registry
|
|
1234
|
+
deleted = registry.cache.delete(nonce)
|
|
1235
|
+
if deleted is None:
|
|
1236
|
+
error_msg = "_token was already used or has expired."
|
|
1237
|
+
|
|
1238
|
+
if error_msg:
|
|
1239
|
+
error_details = {"location": "querystring", "description": error_msg}
|
|
1240
|
+
raise_invalid(self.request, **error_details)
|
|
1241
|
+
|
|
1242
|
+
filters = self._build_pagination_rules(sorting, last_object)
|
|
1243
|
+
|
|
1244
|
+
return filters, offset
|
|
1245
|
+
|
|
1246
|
+
def _next_page_url(self, sorting, limit, last_object, offset):
|
|
1247
|
+
"""Build the Next-Page header from where we stopped."""
|
|
1248
|
+
token = self._build_pagination_token(sorting, last_object, offset)
|
|
1249
|
+
|
|
1250
|
+
params = {**request_GET(self.request), "_limit": limit, "_token": token}
|
|
1251
|
+
|
|
1252
|
+
service = self.request.current_service
|
|
1253
|
+
next_page_url = self.request.route_url(
|
|
1254
|
+
service.name, _query=params, **self.request.matchdict
|
|
1255
|
+
)
|
|
1256
|
+
return next_page_url
|
|
1257
|
+
|
|
1258
|
+
def _build_pagination_token(self, sorting, last_object, offset):
|
|
1259
|
+
"""Build a pagination token.
|
|
1260
|
+
|
|
1261
|
+
It is a base64 JSON object with the sorting fields values of
|
|
1262
|
+
the last_object.
|
|
1263
|
+
|
|
1264
|
+
"""
|
|
1265
|
+
nonce = f"pagination-token-{uuid4()}"
|
|
1266
|
+
if self.request.method.lower() == "delete":
|
|
1267
|
+
registry = self.request.registry
|
|
1268
|
+
validity = registry.settings["pagination_token_validity_seconds"]
|
|
1269
|
+
registry.cache.set(nonce, "", validity)
|
|
1270
|
+
|
|
1271
|
+
token = {"last_object": {}, "offset": offset, "nonce": nonce}
|
|
1272
|
+
|
|
1273
|
+
for field, _ in sorting:
|
|
1274
|
+
last_value = find_nested_value(last_object, field, MISSING)
|
|
1275
|
+
if last_value is not MISSING:
|
|
1276
|
+
token["last_object"][field] = last_value
|
|
1277
|
+
|
|
1278
|
+
return encode64(json.dumps(token))
|
|
1279
|
+
|
|
1280
|
+
@property
|
|
1281
|
+
def record_id(self):
|
|
1282
|
+
message = "`record_id` is deprecated, use `object_id` instead."
|
|
1283
|
+
warnings.warn(message, DeprecationWarning)
|
|
1284
|
+
return self.object_id
|
|
1285
|
+
|
|
1286
|
+
def process_record(self, *args, **kwargs):
|
|
1287
|
+
message = "`process_record()` is deprecated, use `process_object()` instead."
|
|
1288
|
+
warnings.warn(message, DeprecationWarning)
|
|
1289
|
+
return self.process_object(*args, **kwargs)
|
|
1290
|
+
|
|
1291
|
+
def collection_get(self, *args, **kwargs):
|
|
1292
|
+
message = "`collection_get()` is deprecated, use `plural_get()` instead."
|
|
1293
|
+
warnings.warn(message, DeprecationWarning)
|
|
1294
|
+
return self.plural_get(*args, **kwargs)
|
|
1295
|
+
|
|
1296
|
+
def collection_post(self, *args, **kwargs):
|
|
1297
|
+
message = "`collection_post()` is deprecated, use `plural_post()` instead."
|
|
1298
|
+
warnings.warn(message, DeprecationWarning)
|
|
1299
|
+
return self.plural_post(*args, **kwargs)
|
|
1300
|
+
|
|
1301
|
+
def collection_delete(self, *args, **kwargs):
|
|
1302
|
+
message = "`collection_delete()` is deprecated, use `plural_delete()` instead."
|
|
1303
|
+
warnings.warn(message, DeprecationWarning)
|
|
1304
|
+
return self.plural_delete(*args, **kwargs)
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
class ShareableResource(Resource):
|
|
1308
|
+
def __init__(self, *args, **kwargs):
|
|
1309
|
+
message = "`ShareableResource` is deprecated, use `Resource` instead."
|
|
1310
|
+
warnings.warn(message, DeprecationWarning)
|
|
1311
|
+
super().__init__(*args, **kwargs)
|