Skip to content

Commit b38cea1

Browse files
Philipp Kainzauvipy
authored andcommitted
added precondition required to the APIETagProcessor class, added tests, a new exception type
1 parent a65c3cf commit b38cea1

File tree

8 files changed

+615
-84
lines changed

8 files changed

+615
-84
lines changed

docs/index.html

2.35 KB
Binary file not shown.

docs/index.md

Lines changed: 102 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,9 +1417,9 @@ settings:
14171417

14181418
*New in DRF-extensions 0.3.2*
14191419

1420-
In addition, `APIETAGProcessor` exists explicitly requires a function that creates an ETag value from model instances.
1420+
In addition, `APIETAGProcessor` explicitly requires a function that (ideally) creates an ETag value from model instances.
14211421
If the `@api_etag` decorator is used without `etag_func` the framework will raise an `AssertionError`.
1422-
The following snipped would not work:
1422+
Hence, the following snipped would not work:
14231423

14241424
# BEGIN BAD CODE:
14251425
class View(views.APIView):
@@ -1434,7 +1434,7 @@ response on conditional requests, even if the resource was modified meanwhile.
14341434
Therefore the `APIETAGProcessor` cannot be used without specifying an `etag_func` as keyword argument and there exists convenient
14351435
[mixin classes](#apietagmixin).
14361436

1437-
You can use the decorator in regular `APIView` and subclasses from the `rest_framework.generics` module,
1437+
You can use the decorator in regular `APIView`, and subclasses from the `rest_framework.generics` module,
14381438
but ensure to include a `queryset` attribute or override `get_queryset()`:
14391439

14401440
from rest_framework import generics
@@ -1454,7 +1454,38 @@ but ensure to include a `queryset` attribute or override `get_queryset()`:
14541454
return Response(status=status.HTTP_204_NO_CONTENT)
14551455

14561456

1457-
#### Usage with caching
1457+
The next difference to the `@etag` decorator is that it defines an explicit map of
1458+
required headers for each HTTP request verb, using the following default values for unsafe methods:
1459+
1460+
precondition_map = {'PUT': ['If-Match'],
1461+
'PATCH': ['If-Match'],
1462+
'DELETE': ['If-Match']}
1463+
1464+
You can specify a custom set of headers in the decorator by passing the `precondition_map` keyword argument.
1465+
For instance, this statement
1466+
1467+
@api_etag(etag_func=default_api_object_etag_func, precondition_map={'PUT': ['X-mycorp-custom']})
1468+
def put(self, request, *args, **kwargs):
1469+
obj = Book.objects.get(id=kwargs['pk'])
1470+
# ... perform some custom operations here ...
1471+
obj.save()
1472+
return Response(status=status.HTTP_200_OK)
1473+
1474+
checks for the presence of a custom header `X-mycorp-custom` in the request and permits the request, if it is present,
1475+
or returns a `428 PRECONDITION REQUIRED` response.
1476+
1477+
Similarly, to disable all checks for a particular method simply pass an empty dict:
1478+
1479+
@api_etag(etag_func=default_api_object_etag_func, precondition_map={})
1480+
def put(self, request, *args, **kwargs):
1481+
obj = Book.objects.get(id=kwargs['pk'])
1482+
# ... perform some custom operations here ...
1483+
obj.save()
1484+
return Response(status=status.HTTP_200_OK)
1485+
1486+
Please note that passing `None` in the `precondition_map` argument falls back to using the default map.
1487+
1488+
#### Usage ETag with caching
14581489

14591490
As you can see `@etag` and `@cache_response` decorators has similar key calculation approaches.
14601491
They both can take key from simple callable function. And more than this - in many cases they share the same calculation logic.
@@ -1560,63 +1591,72 @@ Instead of obtaining a lock, the client attempts a write operation with the toke
15601591
The operation succeeds if the token is still valid and fails otherwise.
15611592

15621593
HTTP, being a stateless application control, is designed for optimistic concurrency control.
1563-
**NB: The current implementation DOES NOT enforce the If-Match header. It returns 403 if the ETag is not supplied, and permits the unsafe method!**
1564-
1565-
PUT/PATCH
1566-
|
1567-
+-------------+
1568-
| Etag |
1569-
| supplied? |
1570-
+-------------+
1571-
| |
1572-
Yes No
1573-
| |
1574-
+--------------------+ +-----------------------+
1575-
| Do preconditions | | Does the |
1576-
| match? | | resource exist? |
1577-
+--------------------+ +-----------------------+
1578-
| | | |
1579-
Yes No Yes No
1580-
| | | |
1581-
+--------------+ +--------------------+ +-------------+ |
1582-
| Update the | | 412 Precondition | | 403 | |
1583-
| resource | | failed | | Forbidden | |
1584-
+--------------+ +--------------------+ +-------------+ |
1585-
|
1586-
+-----------------------+
1587-
| Can clients |
1588-
| create resources? |
1589-
+-----------------------+
1590-
| |
1591-
Yes No
1592-
| |
1593-
+-----------+ +-------------+
1594-
| 201 | | 404 |
1595-
| Created | | Not Found |
1596-
+-----------+ +-------------+
1594+
According to [RFC 6585](https://tools.ietf.org/html/rfc6585), the server can optionally require
1595+
a condition for a request. This library returns a `428` status, if no ETag is supplied, but would be mandatory
1596+
for a request to succeed.
1597+
1598+
Update:
1599+
1600+
PUT/PATCH
1601+
+
1602+
+-----------+------------+
1603+
| ETag |
1604+
| supplied? |
1605+
++-----------------+-----+
1606+
| |
1607+
Yes No
1608+
| |
1609+
+---------------------++ ++-------------+
1610+
| Do preconditions | | Precondition |
1611+
| match? | | required? |
1612+
+---+-----------------++ ++------------++
1613+
| | | |
1614+
Yes No No Yes
1615+
| | | |
1616+
+----------+------+ +-------+----------+ +---+-----+ |
1617+
| Does resource | | 412 Precondition | | 200 OK | |
1618+
| exist? | | failed | | Update | |
1619+
++---------------++ +------------------+ +---------+ |
1620+
| | +-----------+------+
1621+
Yes No | 428 Precondition |
1622+
| | | required |
1623+
+----+----+ +----+----+ +------------------+
1624+
| 200 OK | | 404 Not |
1625+
| Update | | found |
1626+
+---------+ +---------+
1627+
15971628

15981629
Delete:
15991630

1600-
DELETE
1601-
|
1602-
+-------------+
1603-
| Etag |
1604-
| supplied? |
1605-
+-------------+
1606-
| |
1607-
Yes No
1608-
| |
1609-
+--------------------+ +-------------+
1610-
| Do preconditions | | 403 |
1611-
| match? | | Forbidden |
1612-
+--------------------+ +-------------+
1613-
| |
1614-
Yes No
1615-
| |
1616-
+--------------+ +--------------------+
1617-
| Delete the | | 412 Precondition |
1618-
| resource | | failed |
1619-
+--------------+ +--------------------+
1631+
DELETE
1632+
+
1633+
+-----------+------------+
1634+
| ETag |
1635+
| supplied? |
1636+
++-----------------+-----+
1637+
| |
1638+
Yes No
1639+
| |
1640+
+---------------------++ ++-------------+
1641+
| Do preconditions | | Precondition |
1642+
| match? | | required? |
1643+
+---+-----------------++ ++------------++
1644+
| | | |
1645+
Yes No No Yes
1646+
| | | |
1647+
+----------+------+ +-------+----------+ +---+-----+ |
1648+
| Does resource | | 412 Precondition | | 204 No | |
1649+
| exist? | | failed | | content | |
1650+
++---------------++ +------------------+ +---------+ |
1651+
| | +-----------+------+
1652+
Yes No | 428 Precondition |
1653+
| | | required |
1654+
+----+----+ +----+----+ +------------------+
1655+
| 204 No | | 404 Not |
1656+
| content | | found |
1657+
+---------+ +---------+
1658+
1659+
16201660

16211661
**Example: transient key construction**
16221662

@@ -1913,8 +1953,9 @@ There are other mixins for more granular ETag calculation in `rest_framework_ext
19131953
* **APIDestroyETAGMixin** - only for `destroy` method
19141954
* **APIUpdateETAGMixin** - only for `update` method
19151955

1956+
By default, all mixins require the conditional requests, i.e. they use the default `precondition_map` from the
1957+
`APIETAGProcessor` class.
19161958

1917-
#### Example: persistent key construction
19181959

19191960
#### Gzipped ETags
19201961

@@ -2151,8 +2192,9 @@ You can read about versioning, deprecation policy and upgrading from
21512192

21522193
#### 0.3.2
21532194

2154-
*Jan 2, 2017*
2195+
*Jan 4, 2017*
21552196

2197+
* Added `rest_framework_extensions.exceptions.PreconditionRequiredException` as subclass of `rest_framework.exceptions.APIException`
21562198
* Added `@api_etag` decorator function and `APIETAGProcessor` that uses *semantic* ETags per API resource, decoupled from views, such that it can be used in optimistic concurrency control
21572199
* Added new default key bits `RetrieveModelKeyBit` and `ListModelKeyBit` for computing the semantic fingerprint of a django model instance
21582200
* Added `APIETAGMixin` to be used in DRF viewsets and views

rest_framework_extensions/etag/decorators.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,18 @@
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
1415
from django.utils import six
1516

16-
1717
logger = logging.getLogger('django.request')
1818

1919

2020
class ETAGProcessor(object):
2121
"""Based on https://github.com/django/django/blob/master/django/views/decorators/http.py"""
22+
2223
def __init__(self, etag_func=None, rebuild_after_method_evaluation=False):
2324
if not etag_func:
2425
etag_func = extensions_api_settings.DEFAULT_ETAG_FUNC
@@ -27,6 +28,7 @@ def __init__(self, etag_func=None, rebuild_after_method_evaluation=False):
2728

2829
def __call__(self, func):
2930
this = self
31+
3032
@wraps(func, assigned=available_attrs(func))
3133
def inner(self, request, *args, **kwargs):
3234
return this.process_conditional_request(
@@ -36,6 +38,7 @@ def inner(self, request, *args, **kwargs):
3638
args=args,
3739
kwargs=kwargs,
3840
)
41+
3942
return inner
4043

4144
def process_conditional_request(self,
@@ -126,11 +129,11 @@ def is_if_match_failed(self, res_etag, etags, if_match):
126129

127130
def _get_and_log_precondition_failed_response(self, request):
128131
logger.warning('Precondition Failed: %s', request.path,
129-
extra={
130-
'status_code': status.HTTP_200_OK,
131-
'request': request
132-
}
133-
)
132+
extra={
133+
'status_code': status.HTTP_412_PRECONDITION_FAILED,
134+
'request': request
135+
}
136+
)
134137
return Response(status=status.HTTP_412_PRECONDITION_FAILED)
135138

136139

@@ -141,14 +144,60 @@ class APIETAGProcessor(ETAGProcessor):
141144
It does not make sense to compute a default ETag here, because the processor would always issue a 304 response,
142145
even if the response was modified meanwhile.
143146
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.
144151
"""
145-
def __init__(self, etag_func=None, rebuild_after_method_evaluation=False):
146-
assert etag_func is not None, ('None-Type functions are not allowed for APIETag processing.'
147-
'You must specify a function to calculate the API ETags '
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 '
148162
'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+
149170
super(APIETAGProcessor, self).__init__(etag_func=etag_func,
150171
rebuild_after_method_evaluation=rebuild_after_method_evaluation)
151172

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+
152201

153202
etag = ETAGProcessor
154203
api_etag = APIETAGProcessor
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.utils.translation import ugettext_lazy as _
2+
from rest_framework import status
3+
from rest_framework.exceptions import APIException
4+
5+
6+
class PreconditionRequiredException(APIException):
7+
status_code = status.HTTP_428_PRECONDITION_REQUIRED
8+
default_detail = _('This "{method}" request is required to be conditional.')
9+
default_code = 'precondition_required'

0 commit comments

Comments
 (0)