Skip to content

Commit 28c9e5e

Browse files
Philipp Kainzauvipy
authored andcommitted
added precondition_required decorator, split tests into unit/functional, added more tests
1 parent b38cea1 commit 28c9e5e

File tree

15 files changed

+860
-657
lines changed

15 files changed

+860
-657
lines changed

docs/index.html

1.41 KB
Binary file not shown.

docs/index.md

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

rest_framework_extensions/concurrency/__init__.py

Whitespace-only changes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from rest_framework_extensions.decorators import precondition_required
2+
from rest_framework_extensions.etag.decorators import etag
3+
from rest_framework_extensions.etag.mixins import BaseETAGMixin, APIListETAGMixin, APIRetrieveETAGMixin
4+
5+
6+
class OCCAPIUpdateETAGMixin(BaseETAGMixin):
7+
"""
8+
A mixin that enforces a conditional request for an update (PUT/PATCH) operation.
9+
The resource version must be provided as `ETag` using the `If-Match` HTTP header.
10+
"""
11+
@precondition_required(precondition_map={'PUT': ['If-Match'], 'PATCH': ['If-Match']})
12+
@etag(etag_func='api_object_etag_func', rebuild_after_method_evaluation=True)
13+
def update(self, request, *args, **kwargs):
14+
return super(OCCAPIUpdateETAGMixin, self).update(request, *args, **kwargs)
15+
16+
17+
class OCCAPIDestroyETAGMixin(BaseETAGMixin):
18+
"""
19+
A mixin that enforces a conditional request for a delete (DELETE) operation.
20+
The resource version must be provided as `ETag` using the `If-Match` HTTP header.
21+
"""
22+
@precondition_required(precondition_map={'DELETE': ['If-Match']})
23+
@etag(etag_func='api_object_etag_func')
24+
def destroy(self, request, *args, **kwargs):
25+
return super(OCCAPIDestroyETAGMixin, self).destroy(request, *args, **kwargs)
26+
27+
28+
class OCCAPIETAGMixin(APIListETAGMixin,
29+
APIRetrieveETAGMixin,
30+
OCCAPIUpdateETAGMixin,
31+
OCCAPIDestroyETAGMixin):
32+
33+
"""
34+
A mixin that *enforces* optimistic concurrency control (OCC) using `ETag`s for DRF API views and viewsets.
35+
36+
The API resource version identifiers are retrieved as HTTP `ETag` headers. PUT, PATCH, and DELETE HTTP
37+
request methods are required to be conditional, i.e. the client must provide a valid `ETag` in the HTTP
38+
`If-Match` header. Creating API resources using POST requests as well as listing and retrieval
39+
operations using GET requests do not require conditions. However, if provided on a GET method, the
40+
`If-None-Match` header will be used to create a 304 response in order to tell the client that the resource
41+
was not modified.
42+
"""
43+
pass
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import logging
2+
from functools import wraps
3+
4+
from django.utils.decorators import available_attrs
5+
from rest_framework import status
6+
7+
from rest_framework_extensions.exceptions import PreconditionRequiredException
8+
from rest_framework_extensions.utils import prepare_header_name
9+
10+
logger = logging.getLogger('django.request')
11+
12+
13+
class PreconditionRequiredProcessor(object):
14+
"""
15+
This class performs checks for required pre-conditional headers for view methods.
16+
17+
According to RFC 6585, conditional headers may be enforced for certain services that support conditional
18+
requests. For optimistic locking, the server should respond status code 428 including a description on how
19+
to resubmit the request successfully, see https://tools.ietf.org/html/rfc6585#section-3.
20+
"""
21+
22+
# require a pre-conditional header (e.g. 'If-Match') for unsafe HTTP methods (RFC 6585)
23+
# override this defaults, if required
24+
precondition_map = {'PUT': ['If-Match'],
25+
'PATCH': ['If-Match'],
26+
'DELETE': ['If-Match']}
27+
28+
def __init__(self, precondition_map=None):
29+
if precondition_map is not None:
30+
self.precondition_map = precondition_map
31+
assert isinstance(self.precondition_map, dict), ('`precondition_map` must be a dict, where '
32+
'the key is the HTTP verb, and the value is a list of '
33+
'HTTP headers that must all be present for that request.')
34+
35+
def __call__(self, func):
36+
this = self
37+
38+
@wraps(func, assigned=available_attrs(func))
39+
def inner(self, request, *args, **kwargs):
40+
# compute the preconditions
41+
errors = this.evaluate_preconditions(request=request,
42+
args=args,
43+
kwargs=kwargs)
44+
45+
if len(errors) != 0:
46+
# raises 428 exception that will be caught by DRF
47+
raise this.prepare_exception(request, errors)
48+
else:
49+
# call the wrapped function, may be a view,
50+
# or another view method decorator such as @etag
51+
return func(self, request, *args, **kwargs)
52+
53+
return inner
54+
55+
def evaluate_preconditions(self, request, *args, **kwargs):
56+
"""Evaluate whether the precondition for the request is met."""
57+
errors = {}
58+
59+
if request.method.upper() in self.precondition_map.keys():
60+
required_headers = self.precondition_map.get(request.method.upper(), [])
61+
# check the required headers
62+
for header in list(required_headers):
63+
if not request.META.get(prepare_header_name(header)):
64+
# collect errors
65+
errors[header] = {'detail': 'This header is required.'}
66+
return errors
67+
68+
def prepare_exception(self, request, errors):
69+
logger.warning('Precondition required: %s', request.path,
70+
extra={
71+
'status_code': status.HTTP_428_PRECONDITION_REQUIRED,
72+
'request': request
73+
}
74+
)
75+
76+
# raise an RFC 6585 compliant exception
77+
exception = PreconditionRequiredException(detail='Precondition required. This "%s" request '
78+
'is required to be conditional. '
79+
'Try again by providing all following HTTP headers: '
80+
'"%s".' % (request.method,
81+
", ".join(errors.keys()))
82+
)
83+
return exception
84+
85+
86+
# alias for decorator-style calls
87+
precondition_required = PreconditionRequiredProcessor

rest_framework_extensions/etag/decorators.py

Lines changed: 18 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
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
1211

1312
from rest_framework_extensions.utils import prepare_header_name
1413
from rest_framework_extensions.settings import extensions_api_settings
@@ -30,7 +29,7 @@ def __call__(self, func):
3029
this = self
3130

3231
@wraps(func, assigned=available_attrs(func))
33-
def inner(self, request, *args, **kwargs):
32+
def wrapper(self, request, *args, **kwargs):
3433
return this.process_conditional_request(
3534
view_instance=self,
3635
view_method=func,
@@ -39,7 +38,7 @@ def inner(self, request, *args, **kwargs):
3938
kwargs=kwargs,
4039
)
4140

42-
return inner
41+
return wrapper
4342

4443
def process_conditional_request(self,
4544
view_instance,
@@ -136,68 +135,21 @@ def _get_and_log_precondition_failed_response(self, request):
136135
)
137136
return Response(status=status.HTTP_412_PRECONDITION_FAILED)
138137

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-
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)
201154

202155
etag = ETAGProcessor
203-
api_etag = APIETAGProcessor

rest_framework_extensions/etag/mixins.py

Lines changed: 33 additions & 18 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, api_etag
2+
from rest_framework_extensions.etag.decorators import etag
33
from rest_framework_extensions.settings import extensions_api_settings
44

55

@@ -8,6 +8,11 @@ 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+
1116

1217
class ListETAGMixin(BaseETAGMixin):
1318
@etag(etag_func='list_etag_func')
@@ -45,32 +50,36 @@ class ETAGMixin(RetrieveETAGMixin,
4550
pass
4651

4752

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')
53+
class APIListETAGMixin(BaseETAGMixin):
54+
@etag(etag_func='api_list_etag_func')
5655
def list(self, request, *args, **kwargs):
5756
return super(APIListETAGMixin, self).list(request, *args, **kwargs)
5857

5958

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

6564

66-
class APIUpdateETAGMixin(APIBaseETAGMixin):
67-
@api_etag(etag_func='api_object_etag_func', rebuild_after_method_evaluation=True)
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)
6872
def update(self, request, *args, **kwargs):
6973
return super(APIUpdateETAGMixin, self).update(request, *args, **kwargs)
7074

7175

72-
class APIDestroyETAGMixin(APIBaseETAGMixin):
73-
@api_etag(etag_func='api_object_etag_func')
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')
7483
def destroy(self, request, *args, **kwargs):
7584
return super(APIDestroyETAGMixin, self).destroy(request, *args, **kwargs)
7685

@@ -80,8 +89,14 @@ class APIReadOnlyETAGMixin(APIRetrieveETAGMixin,
8089
pass
8190

8291

83-
class APIETAGMixin(APIRetrieveETAGMixin,
92+
class APIETAGMixin(APIListETAGMixin,
93+
APIRetrieveETAGMixin,
8494
APIUpdateETAGMixin,
85-
APIDestroyETAGMixin,
86-
APIListETAGMixin):
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+
"""
87102
pass

0 commit comments

Comments
 (0)