Skip to content

Commit 28c7539

Browse files
authored
Revert "Optimistic concurrency control using ETags"
1 parent 28c9e5e commit 28c7539

File tree

15 files changed

+657
-860
lines changed

15 files changed

+657
-860
lines changed

docs/index.html

-1.41 KB
Binary file not shown.

docs/index.md

Lines changed: 152 additions & 167 deletions
Large diffs are not rendered by default.

rest_framework_extensions/concurrency/__init__.py

Whitespace-only changes.

rest_framework_extensions/concurrency/mixins.py

Lines changed: 0 additions & 43 deletions
This file was deleted.

rest_framework_extensions/decorators.py

Lines changed: 0 additions & 87 deletions
This file was deleted.

rest_framework_extensions/etag/decorators.py

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from rest_framework import status
99
from rest_framework.permissions import SAFE_METHODS
1010
from rest_framework.response import Response
11+
from rest_framework_extensions.exceptions import PreconditionRequiredException
1112

1213
from rest_framework_extensions.utils import prepare_header_name
1314
from rest_framework_extensions.settings import extensions_api_settings
@@ -29,7 +30,7 @@ def __call__(self, func):
2930
this = self
3031

3132
@wraps(func, assigned=available_attrs(func))
32-
def wrapper(self, request, *args, **kwargs):
33+
def inner(self, request, *args, **kwargs):
3334
return this.process_conditional_request(
3435
view_instance=self,
3536
view_method=func,
@@ -38,7 +39,7 @@ def wrapper(self, request, *args, **kwargs):
3839
kwargs=kwargs,
3940
)
4041

41-
return wrapper
42+
return inner
4243

4344
def process_conditional_request(self,
4445
view_instance,
@@ -135,21 +136,68 @@ def _get_and_log_precondition_failed_response(self, request):
135136
)
136137
return Response(status=status.HTTP_412_PRECONDITION_FAILED)
137138

138-
#
139-
# class APIETAGProcessor(ETAGProcessor):
140-
# """
141-
# This class is responsible for calculating the ETag value given (a list of) model instance(s).
142-
#
143-
# It does not make sense to compute a default ETag here, because the processor would always issue a 304 response,
144-
# even if the response was modified meanwhile.
145-
# Therefore the `APIETAGProcessor` cannot be used without specifying an `etag_func` as keyword argument.
146-
# """
147-
# def __init__(self, etag_func=None, rebuild_after_method_evaluation=False):
148-
# assert etag_func is not None, ('None-type functions are not allowed for processing API ETags.'
149-
# 'You must specify a proper function to calculate the API ETags '
150-
# 'using the "etag_func" keyword argument.')
151-
#
152-
# super(APIETAGProcessor, self).__init__(etag_func=etag_func,
153-
# rebuild_after_method_evaluation=rebuild_after_method_evaluation)
139+
140+
class APIETAGProcessor(ETAGProcessor):
141+
"""
142+
This class is responsible for calculating the ETag value given (a list of) model instance(s).
143+
144+
It does not make sense to compute a default ETag here, because the processor would always issue a 304 response,
145+
even if the response was modified meanwhile.
146+
Therefore the `APIETAGProcessor` cannot be used without specifying an `etag_func` as keyword argument.
147+
148+
According to RFC 6585, conditional headers may be enforced for certain services that support conditional
149+
requests. For optimistic locking, the server should respond status code 428 including a description on how
150+
to resubmit the request successfully, see https://tools.ietf.org/html/rfc6585#section-3.
151+
"""
152+
153+
# require a pre-conditional header (e.g. If-Match) for unsafe HTTP methods (RFC 6585)
154+
# override this defaults, if required
155+
precondition_map = {'PUT': ['If-Match'],
156+
'PATCH': ['If-Match'],
157+
'DELETE': ['If-Match']}
158+
159+
def __init__(self, etag_func=None, rebuild_after_method_evaluation=False, precondition_map=None):
160+
assert etag_func is not None, ('None-type functions are not allowed for processing API ETags.'
161+
'You must specify a proper function to calculate the API ETags '
162+
'using the "etag_func" keyword argument.')
163+
164+
if precondition_map is not None:
165+
self.precondition_map = precondition_map
166+
assert isinstance(self.precondition_map, dict), ('`precondition_map` must be a dict, where '
167+
'the key is the HTTP verb, and the value is a list of '
168+
'HTTP headers that must all be present for that request.')
169+
170+
super(APIETAGProcessor, self).__init__(etag_func=etag_func,
171+
rebuild_after_method_evaluation=rebuild_after_method_evaluation)
172+
173+
def get_etags_and_matchers(self, request):
174+
"""Get the etags from the header and perform a validation against the required preconditions."""
175+
# evaluate the preconditions, raises 428 if condition is not met
176+
self.evaluate_preconditions(request)
177+
# alright, headers are present, extract the values and match the conditions
178+
return super(APIETAGProcessor, self).get_etags_and_matchers(request)
179+
180+
def evaluate_preconditions(self, request):
181+
"""Evaluate whether the precondition for the request is met."""
182+
if request.method.upper() in self.precondition_map.keys():
183+
required_headers = self.precondition_map.get(request.method.upper(), [])
184+
# check the required headers
185+
for header in required_headers:
186+
if not request.META.get(prepare_header_name(header)):
187+
# raise an error for each header that does not match
188+
logger.warning('Precondition required: %s', request.path,
189+
extra={
190+
'status_code': status.HTTP_428_PRECONDITION_REQUIRED,
191+
'request': request
192+
}
193+
)
194+
# raise an RFC 6585 compliant exception
195+
raise PreconditionRequiredException(detail='Precondition required. This "%s" request '
196+
'is required to be conditional. '
197+
'Try again using "%s".' % (request.method, header)
198+
)
199+
return True
200+
154201

155202
etag = ETAGProcessor
203+
api_etag = APIETAGProcessor
Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# -*- coding: utf-8 -*-
2-
from rest_framework_extensions.etag.decorators import etag
2+
from rest_framework_extensions.etag.decorators import etag, api_etag
33
from rest_framework_extensions.settings import extensions_api_settings
44

55

@@ -8,11 +8,6 @@ class BaseETAGMixin(object):
88
object_etag_func = extensions_api_settings.DEFAULT_OBJECT_ETAG_FUNC
99
list_etag_func = extensions_api_settings.DEFAULT_LIST_ETAG_FUNC
1010

11-
# Default functions to compute the ETags from (a list of) individual API resources.
12-
# The default functions *DO NOT* consider the request method or view instance, but only the queried resources.
13-
api_object_etag_func = extensions_api_settings.DEFAULT_API_OBJECT_ETAG_FUNC
14-
api_list_etag_func = extensions_api_settings.DEFAULT_API_LIST_ETAG_FUNC
15-
1611

1712
class ListETAGMixin(BaseETAGMixin):
1813
@etag(etag_func='list_etag_func')
@@ -50,36 +45,32 @@ class ETAGMixin(RetrieveETAGMixin,
5045
pass
5146

5247

53-
class APIListETAGMixin(BaseETAGMixin):
54-
@etag(etag_func='api_list_etag_func')
48+
class APIBaseETAGMixin(object):
49+
# todo: test me. Create generic test like test_etag(view_instance, method, should_rebuild_after_method_evaluation)
50+
api_object_etag_func = extensions_api_settings.DEFAULT_API_OBJECT_ETAG_FUNC
51+
api_list_etag_func = extensions_api_settings.DEFAULT_API_LIST_ETAG_FUNC
52+
53+
54+
class APIListETAGMixin(APIBaseETAGMixin):
55+
@api_etag(etag_func='api_list_etag_func')
5556
def list(self, request, *args, **kwargs):
5657
return super(APIListETAGMixin, self).list(request, *args, **kwargs)
5758

5859

59-
class APIRetrieveETAGMixin(BaseETAGMixin):
60-
@etag(etag_func='api_object_etag_func')
60+
class APIRetrieveETAGMixin(APIBaseETAGMixin):
61+
@api_etag(etag_func='api_object_etag_func')
6162
def retrieve(self, request, *args, **kwargs):
6263
return super(APIRetrieveETAGMixin, self).retrieve(request, *args, **kwargs)
6364

6465

65-
class APIUpdateETAGMixin(BaseETAGMixin):
66-
"""
67-
A mixin that principally *allows* a conditional request for an update (PUT/PATCH) operation.
68-
The resource version is optionally provided as `ETag` using the `If-Match` HTTP header.
69-
If the header is not present, the operation may succeed anyway.
70-
"""
71-
@etag(etag_func='api_object_etag_func', rebuild_after_method_evaluation=True)
66+
class APIUpdateETAGMixin(APIBaseETAGMixin):
67+
@api_etag(etag_func='api_object_etag_func', rebuild_after_method_evaluation=True)
7268
def update(self, request, *args, **kwargs):
7369
return super(APIUpdateETAGMixin, self).update(request, *args, **kwargs)
7470

7571

76-
class APIDestroyETAGMixin(BaseETAGMixin):
77-
"""
78-
A mixin that principally *allows* a conditional request for a delete (DELETE) operation.
79-
The resource version is optionally provided as `ETag` using the `If-Match` HTTP header.
80-
If the header is not present, the operation may succeed anyway.
81-
"""
82-
@etag(etag_func='api_object_etag_func')
72+
class APIDestroyETAGMixin(APIBaseETAGMixin):
73+
@api_etag(etag_func='api_object_etag_func')
8374
def destroy(self, request, *args, **kwargs):
8475
return super(APIDestroyETAGMixin, self).destroy(request, *args, **kwargs)
8576

@@ -89,14 +80,8 @@ class APIReadOnlyETAGMixin(APIRetrieveETAGMixin,
8980
pass
9081

9182

92-
class APIETAGMixin(APIListETAGMixin,
93-
APIRetrieveETAGMixin,
83+
class APIETAGMixin(APIRetrieveETAGMixin,
9484
APIUpdateETAGMixin,
95-
APIDestroyETAGMixin):
96-
"""
97-
A mixin that principally *allows* optimistic concurrency control (OCC) using `ETag`s for DRF API views and viewsets.
98-
99-
NB: Update and delete operations are *NOT* required to be conditional! Use `OCCAPIETAGMixin` to enforce
100-
the default pre-conditional header checks for update and delete.
101-
"""
85+
APIDestroyETAGMixin,
86+
APIListETAGMixin):
10287
pass

0 commit comments

Comments
 (0)