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.
Files changed (142) hide show
  1. kinto/__init__.py +92 -0
  2. kinto/__main__.py +249 -0
  3. kinto/authorization.py +134 -0
  4. kinto/config/__init__.py +94 -0
  5. kinto/config/kinto.tpl +270 -0
  6. kinto/contribute.json +27 -0
  7. kinto/core/__init__.py +246 -0
  8. kinto/core/authentication.py +48 -0
  9. kinto/core/authorization.py +311 -0
  10. kinto/core/cache/__init__.py +131 -0
  11. kinto/core/cache/memcached.py +112 -0
  12. kinto/core/cache/memory.py +104 -0
  13. kinto/core/cache/postgresql/__init__.py +178 -0
  14. kinto/core/cache/postgresql/schema.sql +23 -0
  15. kinto/core/cache/testing.py +208 -0
  16. kinto/core/cornice/__init__.py +93 -0
  17. kinto/core/cornice/cors.py +144 -0
  18. kinto/core/cornice/errors.py +40 -0
  19. kinto/core/cornice/pyramidhook.py +373 -0
  20. kinto/core/cornice/renderer.py +89 -0
  21. kinto/core/cornice/resource.py +205 -0
  22. kinto/core/cornice/service.py +641 -0
  23. kinto/core/cornice/util.py +138 -0
  24. kinto/core/cornice/validators/__init__.py +94 -0
  25. kinto/core/cornice/validators/_colander.py +142 -0
  26. kinto/core/cornice/validators/_marshmallow.py +182 -0
  27. kinto/core/cornice_swagger/__init__.py +92 -0
  28. kinto/core/cornice_swagger/converters/__init__.py +21 -0
  29. kinto/core/cornice_swagger/converters/exceptions.py +6 -0
  30. kinto/core/cornice_swagger/converters/parameters.py +90 -0
  31. kinto/core/cornice_swagger/converters/schema.py +249 -0
  32. kinto/core/cornice_swagger/swagger.py +725 -0
  33. kinto/core/cornice_swagger/templates/index.html +73 -0
  34. kinto/core/cornice_swagger/templates/index_script_template.html +21 -0
  35. kinto/core/cornice_swagger/util.py +42 -0
  36. kinto/core/cornice_swagger/views.py +78 -0
  37. kinto/core/decorators.py +74 -0
  38. kinto/core/errors.py +216 -0
  39. kinto/core/events.py +301 -0
  40. kinto/core/initialization.py +738 -0
  41. kinto/core/listeners/__init__.py +9 -0
  42. kinto/core/metrics.py +94 -0
  43. kinto/core/openapi.py +115 -0
  44. kinto/core/permission/__init__.py +202 -0
  45. kinto/core/permission/memory.py +167 -0
  46. kinto/core/permission/postgresql/__init__.py +489 -0
  47. kinto/core/permission/postgresql/migrations/migration_001_002.sql +18 -0
  48. kinto/core/permission/postgresql/schema.sql +41 -0
  49. kinto/core/permission/testing.py +487 -0
  50. kinto/core/resource/__init__.py +1311 -0
  51. kinto/core/resource/model.py +412 -0
  52. kinto/core/resource/schema.py +502 -0
  53. kinto/core/resource/viewset.py +230 -0
  54. kinto/core/schema.py +119 -0
  55. kinto/core/scripts.py +50 -0
  56. kinto/core/statsd.py +1 -0
  57. kinto/core/storage/__init__.py +436 -0
  58. kinto/core/storage/exceptions.py +53 -0
  59. kinto/core/storage/generators.py +58 -0
  60. kinto/core/storage/memory.py +651 -0
  61. kinto/core/storage/postgresql/__init__.py +1131 -0
  62. kinto/core/storage/postgresql/client.py +120 -0
  63. kinto/core/storage/postgresql/migrations/migration_001_002.sql +10 -0
  64. kinto/core/storage/postgresql/migrations/migration_002_003.sql +33 -0
  65. kinto/core/storage/postgresql/migrations/migration_003_004.sql +18 -0
  66. kinto/core/storage/postgresql/migrations/migration_004_005.sql +20 -0
  67. kinto/core/storage/postgresql/migrations/migration_005_006.sql +11 -0
  68. kinto/core/storage/postgresql/migrations/migration_006_007.sql +74 -0
  69. kinto/core/storage/postgresql/migrations/migration_007_008.sql +66 -0
  70. kinto/core/storage/postgresql/migrations/migration_008_009.sql +41 -0
  71. kinto/core/storage/postgresql/migrations/migration_009_010.sql +98 -0
  72. kinto/core/storage/postgresql/migrations/migration_010_011.sql +14 -0
  73. kinto/core/storage/postgresql/migrations/migration_011_012.sql +9 -0
  74. kinto/core/storage/postgresql/migrations/migration_012_013.sql +71 -0
  75. kinto/core/storage/postgresql/migrations/migration_013_014.sql +14 -0
  76. kinto/core/storage/postgresql/migrations/migration_014_015.sql +95 -0
  77. kinto/core/storage/postgresql/migrations/migration_015_016.sql +4 -0
  78. kinto/core/storage/postgresql/migrations/migration_016_017.sql +81 -0
  79. kinto/core/storage/postgresql/migrations/migration_017_018.sql +25 -0
  80. kinto/core/storage/postgresql/migrations/migration_018_019.sql +8 -0
  81. kinto/core/storage/postgresql/migrations/migration_019_020.sql +7 -0
  82. kinto/core/storage/postgresql/migrations/migration_020_021.sql +68 -0
  83. kinto/core/storage/postgresql/migrations/migration_021_022.sql +62 -0
  84. kinto/core/storage/postgresql/migrations/migration_022_023.sql +5 -0
  85. kinto/core/storage/postgresql/migrations/migration_023_024.sql +6 -0
  86. kinto/core/storage/postgresql/migrations/migration_024_025.sql +6 -0
  87. kinto/core/storage/postgresql/migrator.py +98 -0
  88. kinto/core/storage/postgresql/pool.py +55 -0
  89. kinto/core/storage/postgresql/schema.sql +143 -0
  90. kinto/core/storage/testing.py +1857 -0
  91. kinto/core/storage/utils.py +37 -0
  92. kinto/core/testing.py +182 -0
  93. kinto/core/utils.py +553 -0
  94. kinto/core/views/__init__.py +0 -0
  95. kinto/core/views/batch.py +163 -0
  96. kinto/core/views/errors.py +145 -0
  97. kinto/core/views/heartbeat.py +106 -0
  98. kinto/core/views/hello.py +69 -0
  99. kinto/core/views/openapi.py +35 -0
  100. kinto/core/views/version.py +50 -0
  101. kinto/events.py +3 -0
  102. kinto/plugins/__init__.py +0 -0
  103. kinto/plugins/accounts/__init__.py +94 -0
  104. kinto/plugins/accounts/authentication.py +63 -0
  105. kinto/plugins/accounts/scripts.py +61 -0
  106. kinto/plugins/accounts/utils.py +13 -0
  107. kinto/plugins/accounts/views.py +136 -0
  108. kinto/plugins/admin/README.md +3 -0
  109. kinto/plugins/admin/VERSION +1 -0
  110. kinto/plugins/admin/__init__.py +40 -0
  111. kinto/plugins/admin/build/VERSION +1 -0
  112. kinto/plugins/admin/build/assets/index-CYFwtKtL.css +6 -0
  113. kinto/plugins/admin/build/assets/index-DJ0m93zA.js +149 -0
  114. kinto/plugins/admin/build/assets/logo-VBRiKSPX.png +0 -0
  115. kinto/plugins/admin/build/index.html +18 -0
  116. kinto/plugins/admin/public/help.html +25 -0
  117. kinto/plugins/admin/views.py +42 -0
  118. kinto/plugins/default_bucket/__init__.py +191 -0
  119. kinto/plugins/flush.py +28 -0
  120. kinto/plugins/history/__init__.py +65 -0
  121. kinto/plugins/history/listener.py +181 -0
  122. kinto/plugins/history/views.py +66 -0
  123. kinto/plugins/openid/__init__.py +131 -0
  124. kinto/plugins/openid/utils.py +14 -0
  125. kinto/plugins/openid/views.py +193 -0
  126. kinto/plugins/prometheus.py +300 -0
  127. kinto/plugins/statsd.py +85 -0
  128. kinto/schema_validation.py +135 -0
  129. kinto/views/__init__.py +34 -0
  130. kinto/views/admin.py +195 -0
  131. kinto/views/buckets.py +45 -0
  132. kinto/views/collections.py +58 -0
  133. kinto/views/contribute.py +39 -0
  134. kinto/views/groups.py +90 -0
  135. kinto/views/permissions.py +235 -0
  136. kinto/views/records.py +133 -0
  137. kinto-23.2.1.dist-info/METADATA +232 -0
  138. kinto-23.2.1.dist-info/RECORD +142 -0
  139. kinto-23.2.1.dist-info/WHEEL +5 -0
  140. kinto-23.2.1.dist-info/entry_points.txt +5 -0
  141. kinto-23.2.1.dist-info/licenses/LICENSE +13 -0
  142. 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)