New upstream version 0.6.0
Michael Fladischer
4 years ago
0 | 0 | language: python |
1 | 1 | cache: pip |
2 | dist: xenial | |
2 | dist: bionic | |
3 | 3 | sudo: false |
4 | 4 | |
5 | 5 | python: |
6 | - 3.5 | |
7 | 6 | - 3.6 |
8 | 7 | - 3.7 |
8 | - 3.8 | |
9 | 9 | |
10 | 10 | install: |
11 | 11 | - pip install tox tox-travis |
0 | 0 | build_docs: |
1 | 1 | PYTHONIOENCODING=utf-8 python docs/backdoc.py --title "Django Rest Framework extensions documentation" < docs/index.md > docs/index.html |
2 | python docs/post_process_docs.py | |
3 | 2 | |
4 | 3 | watch_docs: |
5 | 4 | make build_docs |
13 | 13 | |
14 | 14 | ## Requirements |
15 | 15 | |
16 | * Tested for python 3.4, 3.5, 3.6 and 3.7 versions | |
17 | * Tested for releases of Django Rest Framework 3.9 | |
18 | * Tested for Django from 1.11 to 2.2 versions | |
16 | * Tested for Python 3.6, 3.7 and 3.8 | |
17 | * Tested for Django Rest Framework 3.9, 3.10 and 3.11 | |
18 | * Tested for Django 1.11, 2.1, 2.2 and 3.0 | |
19 | 19 | * Tested for django-filter 2.1.0 |
20 | 20 | |
21 | 21 | ## Installation: |
46 | 46 | |
47 | 47 | Running test for exact environment: |
48 | 48 | |
49 | $ tox -e py35 -- tests_app | |
49 | $ tox -e py38 -- tests_app | |
50 | 50 | |
51 | 51 | Recreate envs before running tests: |
52 | 52 |
0 | # -*- coding: utf-8 -*- | |
1 | from django.utils.encoding import force_text | |
2 | ||
3 | from rest_framework import status | |
4 | from rest_framework.response import Response | |
5 | from rest_framework_extensions.settings import extensions_api_settings | |
6 | from rest_framework_extensions import utils | |
7 | ||
8 | ||
9 | class BulkOperationBaseMixin(object): | |
10 | def is_object_operation(self): | |
11 | return bool(self.get_object_lookup_value()) | |
12 | ||
13 | def get_object_lookup_value(self): | |
14 | return self.kwargs.get(getattr(self, 'lookup_url_kwarg', None) or self.lookup_field, None) | |
15 | ||
16 | def is_valid_bulk_operation(self): | |
17 | if extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME: | |
18 | header_name = utils.prepare_header_name(extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME) | |
19 | return bool(self.request.META.get(header_name, None)), { | |
20 | 'detail': 'Header \'{0}\' should be provided for bulk operation.'.format( | |
21 | extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME | |
22 | ) | |
23 | } | |
24 | else: | |
25 | return True, {} | |
26 | ||
27 | ||
28 | class ListDestroyModelMixin(BulkOperationBaseMixin): | |
29 | def delete(self, request, *args, **kwargs): | |
30 | if self.is_object_operation(): | |
31 | return super(ListDestroyModelMixin, self).destroy(request, *args, **kwargs) | |
32 | else: | |
33 | return self.destroy_bulk(request, *args, **kwargs) | |
34 | ||
35 | def destroy_bulk(self, request, *args, **kwargs): | |
36 | is_valid, errors = self.is_valid_bulk_operation() | |
37 | if is_valid: | |
38 | queryset = self.filter_queryset(self.get_queryset()) | |
39 | self.pre_delete_bulk(queryset) # todo: test and document me | |
40 | queryset.delete() | |
41 | self.post_delete_bulk(queryset) # todo: test and document me | |
42 | return Response(status=status.HTTP_204_NO_CONTENT) | |
43 | else: | |
44 | return Response(errors, status=status.HTTP_400_BAD_REQUEST) | |
45 | ||
46 | def pre_delete_bulk(self, queryset): | |
47 | """ | |
48 | Placeholder method for calling before deleting an queryset. | |
49 | """ | |
50 | pass | |
51 | ||
52 | def post_delete_bulk(self, queryset): | |
53 | """ | |
54 | Placeholder method for calling after deleting an queryset. | |
55 | """ | |
56 | pass | |
57 | ||
58 | ||
59 | class ListUpdateModelMixin(BulkOperationBaseMixin): | |
60 | def patch(self, request, *args, **kwargs): | |
61 | if self.is_object_operation(): | |
62 | return super(ListUpdateModelMixin, self).partial_update(request, *args, **kwargs) | |
63 | else: | |
64 | return self.partial_update_bulk(request, *args, **kwargs) | |
65 | ||
66 | def partial_update_bulk(self, request, *args, **kwargs): | |
67 | is_valid, errors = self.is_valid_bulk_operation() | |
68 | if is_valid: | |
69 | queryset = self.filter_queryset(self.get_queryset()) | |
70 | update_bulk_dict = self.get_update_bulk_dict(serializer=self.get_serializer_class()(), data=request.data) | |
71 | self.pre_save_bulk(queryset, update_bulk_dict) # todo: test and document me | |
72 | try: | |
73 | queryset.update(**update_bulk_dict) | |
74 | except ValueError as e: | |
75 | errors = { | |
76 | 'detail': force_text(e) | |
77 | } | |
78 | return Response(errors, status=status.HTTP_400_BAD_REQUEST) | |
79 | self.post_save_bulk(queryset, update_bulk_dict) # todo: test and document me | |
80 | return Response(status=status.HTTP_204_NO_CONTENT) | |
81 | else: | |
82 | return Response(errors, status=status.HTTP_400_BAD_REQUEST) | |
83 | ||
84 | def get_update_bulk_dict(self, serializer, data): | |
85 | update_bulk_dict = {} | |
86 | for field_name, field in serializer.fields.items(): | |
87 | if field_name in data and not field.read_only: | |
88 | update_bulk_dict[field.source or field_name] = data[field_name] | |
89 | return update_bulk_dict | |
90 | ||
91 | def pre_save_bulk(self, queryset, update_bulk_dict): | |
92 | """ | |
93 | Placeholder method for calling before deleting an queryset. | |
94 | """ | |
95 | pass | |
96 | ||
97 | def post_save_bulk(self, queryset, update_bulk_dict): | |
98 | """ | |
99 | Placeholder method for calling after deleting an queryset. | |
100 | """ | |
101 | pass |
0 | # -*- coding: utf-8 -*- | |
1 | from functools import wraps | |
2 | ||
3 | from django.http.response import HttpResponse | |
4 | from django.utils.decorators import available_attrs | |
5 | ||
6 | ||
7 | from rest_framework_extensions.settings import extensions_api_settings | |
8 | from django.utils import six | |
9 | ||
10 | ||
11 | def get_cache(alias): | |
12 | from django.core.cache import caches | |
13 | return caches[alias] | |
14 | ||
15 | ||
16 | class CacheResponse(object): | |
17 | def __init__(self, | |
18 | timeout=None, | |
19 | key_func=None, | |
20 | cache=None, | |
21 | cache_errors=None): | |
22 | if timeout is None: | |
23 | self.timeout = extensions_api_settings.DEFAULT_CACHE_RESPONSE_TIMEOUT | |
24 | else: | |
25 | self.timeout = timeout | |
26 | ||
27 | if key_func is None: | |
28 | self.key_func = extensions_api_settings.DEFAULT_CACHE_KEY_FUNC | |
29 | else: | |
30 | self.key_func = key_func | |
31 | ||
32 | if cache_errors is None: | |
33 | self.cache_errors = extensions_api_settings.DEFAULT_CACHE_ERRORS | |
34 | else: | |
35 | self.cache_errors = cache_errors | |
36 | ||
37 | self.cache = get_cache(cache or extensions_api_settings.DEFAULT_USE_CACHE) | |
38 | ||
39 | def __call__(self, func): | |
40 | this = self | |
41 | @wraps(func, assigned=available_attrs(func)) | |
42 | def inner(self, request, *args, **kwargs): | |
43 | return this.process_cache_response( | |
44 | view_instance=self, | |
45 | view_method=func, | |
46 | request=request, | |
47 | args=args, | |
48 | kwargs=kwargs, | |
49 | ) | |
50 | return inner | |
51 | ||
52 | def process_cache_response(self, | |
53 | view_instance, | |
54 | view_method, | |
55 | request, | |
56 | args, | |
57 | kwargs): | |
58 | key = self.calculate_key( | |
59 | view_instance=view_instance, | |
60 | view_method=view_method, | |
61 | request=request, | |
62 | args=args, | |
63 | kwargs=kwargs | |
64 | ) | |
65 | response = self.cache.get(key) | |
66 | if not response: | |
67 | response = view_method(view_instance, request, *args, **kwargs) | |
68 | response = view_instance.finalize_response(request, response, *args, **kwargs) | |
69 | response.render() # should be rendered, before picklining while storing to cache | |
70 | ||
71 | if not response.status_code >= 400 or self.cache_errors: | |
72 | response_dict = ( | |
73 | response.rendered_content, | |
74 | response.status_code, | |
75 | response._headers | |
76 | ) | |
77 | self.cache.set(key, response_dict, self.timeout) | |
78 | else: | |
79 | content, status, headers = response | |
80 | response = HttpResponse(content=content, status=status) | |
81 | response._headers = headers | |
82 | ||
83 | if not hasattr(response, '_closable_objects'): | |
84 | response._closable_objects = [] | |
85 | ||
86 | return response | |
87 | ||
88 | def calculate_key(self, | |
89 | view_instance, | |
90 | view_method, | |
91 | request, | |
92 | args, | |
93 | kwargs): | |
94 | if isinstance(self.key_func, six.string_types): | |
95 | key_func = getattr(view_instance, self.key_func) | |
96 | else: | |
97 | key_func = self.key_func | |
98 | return key_func( | |
99 | view_instance=view_instance, | |
100 | view_method=view_method, | |
101 | request=request, | |
102 | args=args, | |
103 | kwargs=kwargs, | |
104 | ) | |
105 | ||
106 | ||
107 | cache_response = CacheResponse |
0 | # -*- coding: utf-8 -*- | |
1 | from rest_framework_extensions.cache.decorators import cache_response | |
2 | from rest_framework_extensions.settings import extensions_api_settings | |
3 | ||
4 | ||
5 | class BaseCacheResponseMixin(object): | |
6 | # todo: test me. Create generic test like | |
7 | # test_cache_reponse(view_instance, method, should_rebuild_after_method_evaluation) | |
8 | object_cache_key_func = extensions_api_settings.DEFAULT_OBJECT_CACHE_KEY_FUNC | |
9 | list_cache_key_func = extensions_api_settings.DEFAULT_LIST_CACHE_KEY_FUNC | |
10 | ||
11 | ||
12 | class ListCacheResponseMixin(BaseCacheResponseMixin): | |
13 | @cache_response(key_func='list_cache_key_func') | |
14 | def list(self, request, *args, **kwargs): | |
15 | return super(ListCacheResponseMixin, self).list(request, *args, **kwargs) | |
16 | ||
17 | ||
18 | class RetrieveCacheResponseMixin(BaseCacheResponseMixin): | |
19 | @cache_response(key_func='object_cache_key_func') | |
20 | def retrieve(self, request, *args, **kwargs): | |
21 | return super(RetrieveCacheResponseMixin, self).retrieve(request, *args, **kwargs) | |
22 | ||
23 | ||
24 | class CacheResponseMixin(RetrieveCacheResponseMixin, | |
25 | ListCacheResponseMixin): | |
26 | pass⏎ |
0 | """ | |
1 | The `compat` module provides support for backwards compatibility with older | |
2 | versions of django/python, and compatibility wrappers around optional packages. | |
3 | """ | |
4 | from __future__ import unicode_literals | |
5 | ||
6 | from django.utils import six | |
7 | ||
8 | ||
9 | # handle different QuerySet representations | |
10 | def queryset_to_value_list(queryset): | |
11 | assert isinstance(queryset, six.string_types) | |
12 | ||
13 | # django 1.10 introduces syntax "<QuerySet [(#1), (#2), ...]>" | |
14 | # we extract only the list of tuples from the string | |
15 | idx_bracket_open = queryset.find(u'[') | |
16 | idx_bracket_close = queryset.rfind(u']') | |
17 | ||
18 | return queryset[idx_bracket_open:idx_bracket_close + 1] |
0 | # -*- coding: utf-8 -*- | |
1 | import logging | |
2 | from functools import wraps | |
3 | ||
4 | from django.utils.decorators import available_attrs | |
5 | from django.utils.http import parse_etags, quote_etag | |
6 | ||
7 | from rest_framework import status | |
8 | from rest_framework.permissions import SAFE_METHODS | |
9 | from rest_framework.response import Response | |
10 | from rest_framework_extensions.exceptions import PreconditionRequiredException | |
11 | ||
12 | from rest_framework_extensions.utils import prepare_header_name | |
13 | from rest_framework_extensions.settings import extensions_api_settings | |
14 | from django.utils import six | |
15 | ||
16 | logger = logging.getLogger('django.request') | |
17 | ||
18 | ||
19 | class ETAGProcessor(object): | |
20 | """Based on https://github.com/django/django/blob/master/django/views/decorators/http.py""" | |
21 | ||
22 | def __init__(self, etag_func=None, rebuild_after_method_evaluation=False): | |
23 | if not etag_func: | |
24 | etag_func = extensions_api_settings.DEFAULT_ETAG_FUNC | |
25 | self.etag_func = etag_func | |
26 | self.rebuild_after_method_evaluation = rebuild_after_method_evaluation | |
27 | ||
28 | def __call__(self, func): | |
29 | this = self | |
30 | ||
31 | @wraps(func, assigned=available_attrs(func)) | |
32 | def inner(self, request, *args, **kwargs): | |
33 | return this.process_conditional_request( | |
34 | view_instance=self, | |
35 | view_method=func, | |
36 | request=request, | |
37 | args=args, | |
38 | kwargs=kwargs, | |
39 | ) | |
40 | ||
41 | return inner | |
42 | ||
43 | def process_conditional_request(self, | |
44 | view_instance, | |
45 | view_method, | |
46 | request, | |
47 | args, | |
48 | kwargs): | |
49 | etags, if_none_match, if_match = self.get_etags_and_matchers(request) | |
50 | res_etag = self.calculate_etag( | |
51 | view_instance=view_instance, | |
52 | view_method=view_method, | |
53 | request=request, | |
54 | args=args, | |
55 | kwargs=kwargs, | |
56 | ) | |
57 | ||
58 | if self.is_if_none_match_failed(res_etag, etags, if_none_match): | |
59 | if request.method in SAFE_METHODS: | |
60 | response = Response(status=status.HTTP_304_NOT_MODIFIED) | |
61 | else: | |
62 | response = self._get_and_log_precondition_failed_response(request=request) | |
63 | elif self.is_if_match_failed(res_etag, etags, if_match): | |
64 | response = self._get_and_log_precondition_failed_response(request=request) | |
65 | else: | |
66 | response = view_method(view_instance, request, *args, **kwargs) | |
67 | if self.rebuild_after_method_evaluation: | |
68 | res_etag = self.calculate_etag( | |
69 | view_instance=view_instance, | |
70 | view_method=view_method, | |
71 | request=request, | |
72 | args=args, | |
73 | kwargs=kwargs, | |
74 | ) | |
75 | ||
76 | if res_etag and not response.has_header('ETag'): | |
77 | response['ETag'] = quote_etag(res_etag) | |
78 | ||
79 | return response | |
80 | ||
81 | def get_etags_and_matchers(self, request): | |
82 | etags = None | |
83 | if_none_match = request.META.get(prepare_header_name("if-none-match")) | |
84 | if_match = request.META.get(prepare_header_name("if-match")) | |
85 | if if_none_match or if_match: | |
86 | # There can be more than one ETag in the request, so we | |
87 | # consider the list of values. | |
88 | try: | |
89 | etags = parse_etags(if_none_match or if_match) | |
90 | except ValueError: | |
91 | # In case of invalid etag ignore all ETag headers. | |
92 | # Apparently Opera sends invalidly quoted headers at times | |
93 | # (we should be returning a 400 response, but that's a | |
94 | # little extreme) -- this is Django bug #10681. | |
95 | if_none_match = None | |
96 | if_match = None | |
97 | return etags, if_none_match, if_match | |
98 | ||
99 | def calculate_etag(self, | |
100 | view_instance, | |
101 | view_method, | |
102 | request, | |
103 | args, | |
104 | kwargs): | |
105 | if isinstance(self.etag_func, six.string_types): | |
106 | etag_func = getattr(view_instance, self.etag_func) | |
107 | else: | |
108 | etag_func = self.etag_func | |
109 | return etag_func( | |
110 | view_instance=view_instance, | |
111 | view_method=view_method, | |
112 | request=request, | |
113 | args=args, | |
114 | kwargs=kwargs, | |
115 | ) | |
116 | ||
117 | def is_if_none_match_failed(self, res_etag, etags, if_none_match): | |
118 | if res_etag and if_none_match: | |
119 | etags = [etag.strip('"') for etag in etags] | |
120 | return res_etag in etags or '*' in etags | |
121 | else: | |
122 | return False | |
123 | ||
124 | def is_if_match_failed(self, res_etag, etags, if_match): | |
125 | if res_etag and if_match: | |
126 | return res_etag not in etags and '*' not in etags | |
127 | else: | |
128 | return False | |
129 | ||
130 | def _get_and_log_precondition_failed_response(self, request): | |
131 | logger.warning('Precondition Failed: %s', request.path, | |
132 | extra={ | |
133 | 'status_code': status.HTTP_412_PRECONDITION_FAILED, | |
134 | 'request': request | |
135 | } | |
136 | ) | |
137 | return Response(status=status.HTTP_412_PRECONDITION_FAILED) | |
138 | ||
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 | ||
201 | ||
202 | etag = ETAGProcessor | |
203 | api_etag = APIETAGProcessor |
0 | # -*- coding: utf-8 -*- | |
1 | from rest_framework_extensions.etag.decorators import etag, api_etag | |
2 | from rest_framework_extensions.settings import extensions_api_settings | |
3 | ||
4 | ||
5 | class BaseETAGMixin(object): | |
6 | # todo: test me. Create generic test like test_etag(view_instance, method, should_rebuild_after_method_evaluation) | |
7 | object_etag_func = extensions_api_settings.DEFAULT_OBJECT_ETAG_FUNC | |
8 | list_etag_func = extensions_api_settings.DEFAULT_LIST_ETAG_FUNC | |
9 | ||
10 | ||
11 | class ListETAGMixin(BaseETAGMixin): | |
12 | @etag(etag_func='list_etag_func') | |
13 | def list(self, request, *args, **kwargs): | |
14 | return super(ListETAGMixin, self).list(request, *args, **kwargs) | |
15 | ||
16 | ||
17 | class RetrieveETAGMixin(BaseETAGMixin): | |
18 | @etag(etag_func='object_etag_func') | |
19 | def retrieve(self, request, *args, **kwargs): | |
20 | return super(RetrieveETAGMixin, self).retrieve(request, *args, **kwargs) | |
21 | ||
22 | ||
23 | class UpdateETAGMixin(BaseETAGMixin): | |
24 | @etag(etag_func='object_etag_func', rebuild_after_method_evaluation=True) | |
25 | def update(self, request, *args, **kwargs): | |
26 | return super(UpdateETAGMixin, self).update(request, *args, **kwargs) | |
27 | ||
28 | ||
29 | class DestroyETAGMixin(BaseETAGMixin): | |
30 | @etag(etag_func='object_etag_func') | |
31 | def destroy(self, request, *args, **kwargs): | |
32 | return super(DestroyETAGMixin, self).destroy(request, *args, **kwargs) | |
33 | ||
34 | ||
35 | class ReadOnlyETAGMixin(RetrieveETAGMixin, | |
36 | ListETAGMixin): | |
37 | pass | |
38 | ||
39 | ||
40 | class ETAGMixin(RetrieveETAGMixin, | |
41 | UpdateETAGMixin, | |
42 | DestroyETAGMixin, | |
43 | ListETAGMixin): | |
44 | pass | |
45 | ||
46 | ||
47 | class APIBaseETAGMixin(object): | |
48 | # todo: test me. Create generic test like test_etag(view_instance, method, should_rebuild_after_method_evaluation) | |
49 | api_object_etag_func = extensions_api_settings.DEFAULT_API_OBJECT_ETAG_FUNC | |
50 | api_list_etag_func = extensions_api_settings.DEFAULT_API_LIST_ETAG_FUNC | |
51 | ||
52 | ||
53 | class APIListETAGMixin(APIBaseETAGMixin): | |
54 | @api_etag(etag_func='api_list_etag_func') | |
55 | def list(self, request, *args, **kwargs): | |
56 | return super(APIListETAGMixin, self).list(request, *args, **kwargs) | |
57 | ||
58 | ||
59 | class APIRetrieveETAGMixin(APIBaseETAGMixin): | |
60 | @api_etag(etag_func='api_object_etag_func') | |
61 | def retrieve(self, request, *args, **kwargs): | |
62 | return super(APIRetrieveETAGMixin, self).retrieve(request, *args, **kwargs) | |
63 | ||
64 | ||
65 | class APIUpdateETAGMixin(APIBaseETAGMixin): | |
66 | @api_etag(etag_func='api_object_etag_func', rebuild_after_method_evaluation=True) | |
67 | def update(self, request, *args, **kwargs): | |
68 | return super(APIUpdateETAGMixin, self).update(request, *args, **kwargs) | |
69 | ||
70 | ||
71 | class APIDestroyETAGMixin(APIBaseETAGMixin): | |
72 | @api_etag(etag_func='api_object_etag_func') | |
73 | def destroy(self, request, *args, **kwargs): | |
74 | return super(APIDestroyETAGMixin, self).destroy(request, *args, **kwargs) | |
75 | ||
76 | ||
77 | class APIReadOnlyETAGMixin(APIRetrieveETAGMixin, | |
78 | APIListETAGMixin): | |
79 | pass | |
80 | ||
81 | ||
82 | class APIETAGMixin(APIRetrieveETAGMixin, | |
83 | APIUpdateETAGMixin, | |
84 | APIDestroyETAGMixin, | |
85 | APIListETAGMixin): | |
86 | pass |
0 | from django.utils.translation import ugettext_lazy as _ | |
1 | from rest_framework import status | |
2 | from rest_framework.exceptions import APIException | |
3 | ||
4 | ||
5 | class PreconditionRequiredException(APIException): | |
6 | status_code = status.HTTP_428_PRECONDITION_REQUIRED | |
7 | default_detail = _('This "{method}" request is required to be conditional.') | |
8 | default_code = 'precondition_required' |
0 | # -*- coding: utf-8 -*- | |
1 | from rest_framework.relations import HyperlinkedRelatedField | |
2 | ||
3 | ||
4 | class ResourceUriField(HyperlinkedRelatedField): | |
5 | """ | |
6 | Represents a hyperlinking uri that points to the detail view for that object. | |
7 | ||
8 | Example: | |
9 | class SurveySerializer(serializers.ModelSerializer): | |
10 | resource_uri = ResourceUriField(view_name='survey-detail') | |
11 | ||
12 | class Meta: | |
13 | model = Survey | |
14 | fields = ('id', 'resource_uri') | |
15 | ||
16 | ... | |
17 | { | |
18 | "id": 1, | |
19 | "resource_uri": "http://localhost/v1/surveys/1/", | |
20 | } | |
21 | """ | |
22 | # todo: test me | |
23 | read_only = True | |
24 | ||
25 | def __init__(self, *args, **kwargs): | |
26 | kwargs.setdefault('source', '*') | |
27 | super(ResourceUriField, self).__init__(*args, **kwargs)⏎ |
0 | # -*- coding: utf-8 -*- | |
1 | from django.utils.translation import get_language | |
2 | from django.db.models.query import EmptyQuerySet | |
3 | from django.db.models.sql.datastructures import EmptyResultSet | |
4 | ||
5 | from django.utils.encoding import force_text | |
6 | ||
7 | from rest_framework_extensions import compat | |
8 | ||
9 | ||
10 | class AllArgsMixin(object): | |
11 | ||
12 | def __init__(self, params='*'): | |
13 | super(AllArgsMixin, self).__init__(params) | |
14 | ||
15 | ||
16 | class KeyBitBase(object): | |
17 | def __init__(self, params=None): | |
18 | self.params = params | |
19 | ||
20 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
21 | """ | |
22 | @rtype: dict | |
23 | """ | |
24 | raise NotImplementedError() | |
25 | ||
26 | ||
27 | class KeyBitDictBase(KeyBitBase): | |
28 | """Base class for dict-like source data processing. | |
29 | ||
30 | Look at HeadersKeyBit and QueryParamsKeyBit | |
31 | ||
32 | """ | |
33 | ||
34 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
35 | data = {} | |
36 | ||
37 | if params is not None: | |
38 | source_dict = self.get_source_dict( | |
39 | params=params, | |
40 | view_instance=view_instance, | |
41 | view_method=view_method, | |
42 | request=request, | |
43 | args=args, | |
44 | kwargs=kwargs | |
45 | ) | |
46 | ||
47 | if params == '*': | |
48 | params = source_dict.keys() | |
49 | ||
50 | for key in params: | |
51 | value = source_dict.get(self.prepare_key_for_value_retrieving(key)) | |
52 | if value is not None: | |
53 | data[self.prepare_key_for_value_assignment(key)] = force_text(value) | |
54 | ||
55 | return data | |
56 | ||
57 | def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): | |
58 | raise NotImplementedError() | |
59 | ||
60 | def prepare_key_for_value_retrieving(self, key): | |
61 | return key | |
62 | ||
63 | def prepare_key_for_value_assignment(self, key): | |
64 | return key | |
65 | ||
66 | ||
67 | class UniqueViewIdKeyBit(KeyBitBase): | |
68 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
69 | return u'.'.join([ | |
70 | view_instance.__module__, | |
71 | view_instance.__class__.__name__ | |
72 | ]) | |
73 | ||
74 | ||
75 | class UniqueMethodIdKeyBit(KeyBitBase): | |
76 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
77 | return u'.'.join([ | |
78 | view_instance.__module__, | |
79 | view_instance.__class__.__name__, | |
80 | view_method.__name__ | |
81 | ]) | |
82 | ||
83 | ||
84 | class LanguageKeyBit(KeyBitBase): | |
85 | """ | |
86 | Return example: | |
87 | u'en' | |
88 | ||
89 | """ | |
90 | ||
91 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
92 | return force_text(get_language()) | |
93 | ||
94 | ||
95 | class FormatKeyBit(KeyBitBase): | |
96 | """ | |
97 | Return example for json: | |
98 | u'json' | |
99 | ||
100 | Return example for html: | |
101 | u'html' | |
102 | """ | |
103 | ||
104 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
105 | return force_text(request.accepted_renderer.format) | |
106 | ||
107 | ||
108 | class UserKeyBit(KeyBitBase): | |
109 | """ | |
110 | Return example for anonymous: | |
111 | u'anonymous' | |
112 | ||
113 | Return example for authenticated (value is user id): | |
114 | u'10' | |
115 | """ | |
116 | ||
117 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
118 | if hasattr(request, 'user') and request.user and request.user.is_authenticated: | |
119 | return force_text(self._get_id_from_user(request.user)) | |
120 | else: | |
121 | return u'anonymous' | |
122 | ||
123 | def _get_id_from_user(self, user): | |
124 | return user.id | |
125 | ||
126 | ||
127 | class HeadersKeyBit(KeyBitDictBase): | |
128 | """ | |
129 | Return example: | |
130 | {'accept-language': u'ru', 'x-geobase-id': '123'} | |
131 | ||
132 | """ | |
133 | def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): | |
134 | return request.META | |
135 | ||
136 | def prepare_key_for_value_retrieving(self, key): | |
137 | from rest_framework_extensions.utils import prepare_header_name | |
138 | ||
139 | return prepare_header_name(key.lower()) # Accept-Language => http_accept_language | |
140 | ||
141 | def prepare_key_for_value_assignment(self, key): | |
142 | return key.lower() # Accept-Language => accept-language | |
143 | ||
144 | ||
145 | class RequestMetaKeyBit(KeyBitDictBase): | |
146 | """ | |
147 | Return example: | |
148 | {'REMOTE_ADDR': u'127.0.0.2', 'REMOTE_HOST': u'yandex.ru'} | |
149 | ||
150 | """ | |
151 | ||
152 | def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): | |
153 | return request.META | |
154 | ||
155 | ||
156 | class QueryParamsKeyBit(AllArgsMixin, KeyBitDictBase): | |
157 | """ | |
158 | Return example: | |
159 | {'part': 'Londo', 'callback': 'jquery_callback'} | |
160 | ||
161 | """ | |
162 | ||
163 | def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): | |
164 | return request.GET | |
165 | ||
166 | ||
167 | class PaginationKeyBit(QueryParamsKeyBit): | |
168 | """ | |
169 | Return example: | |
170 | {'page_size': 100, 'page': '1'} | |
171 | ||
172 | """ | |
173 | def get_data(self, **kwargs): | |
174 | kwargs['params'] = [] | |
175 | if hasattr(kwargs['view_instance'], 'paginator'): | |
176 | if hasattr(kwargs['view_instance'].paginator, 'page_query_param'): | |
177 | kwargs['params'].append( | |
178 | kwargs['view_instance'].paginator.page_query_param) | |
179 | if hasattr(kwargs['view_instance'].paginator, | |
180 | 'page_size_query_param'): | |
181 | kwargs['params'].append( | |
182 | kwargs['view_instance'].paginator.page_size_query_param) | |
183 | return super(PaginationKeyBit, self).get_data(**kwargs) | |
184 | ||
185 | ||
186 | class SqlQueryKeyBitBase(KeyBitBase): | |
187 | def _get_queryset_query_string(self, queryset): | |
188 | if isinstance(queryset, EmptyQuerySet): | |
189 | return None | |
190 | else: | |
191 | try: | |
192 | return force_text(queryset.query.__str__()) | |
193 | except EmptyResultSet: | |
194 | return None | |
195 | ||
196 | ||
197 | class ModelInstanceKeyBitBase(KeyBitBase): | |
198 | """ | |
199 | Return the actual contents of the query set. | |
200 | This class is similar to the `SqlQueryKeyBitBase`. | |
201 | """ | |
202 | def _get_queryset_query_values(self, queryset): | |
203 | if isinstance(queryset, EmptyQuerySet) or queryset.count() == 0: | |
204 | return None | |
205 | else: | |
206 | try: | |
207 | # run through the instances and collect all values in ordered fashion | |
208 | return compat.queryset_to_value_list(force_text(queryset.values_list())) | |
209 | except EmptyResultSet: | |
210 | return None | |
211 | ||
212 | ||
213 | class ListSqlQueryKeyBit(SqlQueryKeyBitBase): | |
214 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
215 | queryset = view_instance.filter_queryset(view_instance.get_queryset()) | |
216 | return self._get_queryset_query_string(queryset) | |
217 | ||
218 | ||
219 | class RetrieveSqlQueryKeyBit(SqlQueryKeyBitBase): | |
220 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
221 | lookup_value = view_instance.kwargs[view_instance.lookup_field] | |
222 | try: | |
223 | queryset = view_instance.filter_queryset(view_instance.get_queryset()).filter( | |
224 | **{view_instance.lookup_field: lookup_value} | |
225 | ) | |
226 | except ValueError: | |
227 | return None | |
228 | else: | |
229 | return self._get_queryset_query_string(queryset) | |
230 | ||
231 | ||
232 | class RetrieveModelKeyBit(ModelInstanceKeyBitBase): | |
233 | """ | |
234 | A key bit reflecting the contents of the model instance. | |
235 | Return example: | |
236 | u"[(3, False)]" | |
237 | """ | |
238 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
239 | lookup_value = view_instance.kwargs[view_instance.lookup_field] | |
240 | try: | |
241 | queryset = view_instance.filter_queryset(view_instance.get_queryset()).filter( | |
242 | **{view_instance.lookup_field: lookup_value} | |
243 | ) | |
244 | except ValueError: | |
245 | return None | |
246 | else: | |
247 | return self._get_queryset_query_values(queryset) | |
248 | ||
249 | ||
250 | class ListModelKeyBit(ModelInstanceKeyBitBase): | |
251 | """ | |
252 | A key bit reflecting the contents of a list of model instances. | |
253 | Return example: | |
254 | u"[(1, True), (2, True), (3, False)]" | |
255 | """ | |
256 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
257 | queryset = view_instance.filter_queryset(view_instance.get_queryset()) | |
258 | return self._get_queryset_query_values(queryset) | |
259 | ||
260 | ||
261 | class ArgsKeyBit(AllArgsMixin, KeyBitBase): | |
262 | ||
263 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
264 | if params == '*': | |
265 | return args | |
266 | elif params is not None: | |
267 | return [args[i] for i in params] | |
268 | else: | |
269 | return [] | |
270 | ||
271 | ||
272 | class KwargsKeyBit(AllArgsMixin, KeyBitDictBase): | |
273 | ||
274 | def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): | |
275 | return kwargs |
0 | # -*- coding: utf-8 -*- | |
1 | import hashlib | |
2 | import json | |
3 | ||
4 | from rest_framework_extensions.key_constructor import bits | |
5 | from rest_framework_extensions.settings import extensions_api_settings | |
6 | ||
7 | ||
8 | class KeyConstructor(object): | |
9 | def __init__(self, memoize_for_request=None, params=None): | |
10 | if memoize_for_request is None: | |
11 | self.memoize_for_request = extensions_api_settings.DEFAULT_KEY_CONSTRUCTOR_MEMOIZE_FOR_REQUEST | |
12 | else: | |
13 | self.memoize_for_request = memoize_for_request | |
14 | if params is None: | |
15 | self.params = {} | |
16 | else: | |
17 | self.params = params | |
18 | self.bits = self.get_bits() | |
19 | ||
20 | def get_bits(self): | |
21 | _bits = {} | |
22 | for attr in dir(self.__class__): | |
23 | attr_value = getattr(self.__class__, attr) | |
24 | if isinstance(attr_value, bits.KeyBitBase): | |
25 | _bits[attr] = attr_value | |
26 | return _bits | |
27 | ||
28 | def __call__(self, **kwargs): | |
29 | return self.get_key(**kwargs) | |
30 | ||
31 | def get_key(self, view_instance, view_method, request, args, kwargs): | |
32 | if self.memoize_for_request: | |
33 | memoization_key = self._get_memoization_key( | |
34 | view_instance=view_instance, | |
35 | view_method=view_method, | |
36 | args=args, | |
37 | kwargs=kwargs | |
38 | ) | |
39 | if not hasattr(request, '_key_constructor_cache'): | |
40 | request._key_constructor_cache = {} | |
41 | if self.memoize_for_request and memoization_key in request._key_constructor_cache: | |
42 | return request._key_constructor_cache.get(memoization_key) | |
43 | else: | |
44 | value = self._get_key( | |
45 | view_instance=view_instance, | |
46 | view_method=view_method, | |
47 | request=request, | |
48 | args=args, | |
49 | kwargs=kwargs | |
50 | ) | |
51 | if self.memoize_for_request: | |
52 | request._key_constructor_cache[memoization_key] = value | |
53 | return value | |
54 | ||
55 | def _get_memoization_key(self, view_instance, view_method, args, kwargs): | |
56 | from rest_framework_extensions.utils import get_unique_method_id | |
57 | return json.dumps({ | |
58 | 'unique_method_id': get_unique_method_id(view_instance=view_instance, view_method=view_method), | |
59 | 'args': args, | |
60 | 'kwargs': kwargs, | |
61 | 'instance_id': id(self) | |
62 | }) | |
63 | ||
64 | def _get_key(self, view_instance, view_method, request, args, kwargs): | |
65 | _kwargs = { | |
66 | 'view_instance': view_instance, | |
67 | 'view_method': view_method, | |
68 | 'request': request, | |
69 | 'args': args, | |
70 | 'kwargs': kwargs, | |
71 | } | |
72 | return self.prepare_key( | |
73 | self.get_data_from_bits(**_kwargs) | |
74 | ) | |
75 | ||
76 | def prepare_key(self, key_dict): | |
77 | return hashlib.md5(json.dumps(key_dict, sort_keys=True).encode('utf-8')).hexdigest() | |
78 | ||
79 | def get_data_from_bits(self, **kwargs): | |
80 | result_dict = {} | |
81 | for bit_name, bit_instance in self.bits.items(): | |
82 | if bit_name in self.params: | |
83 | params = self.params[bit_name] | |
84 | else: | |
85 | try: | |
86 | params = bit_instance.params | |
87 | except AttributeError: | |
88 | params = None | |
89 | result_dict[bit_name] = bit_instance.get_data(params=params, **kwargs) | |
90 | return result_dict | |
91 | ||
92 | ||
93 | class DefaultKeyConstructor(KeyConstructor): | |
94 | unique_method_id = bits.UniqueMethodIdKeyBit() | |
95 | format = bits.FormatKeyBit() | |
96 | language = bits.LanguageKeyBit() | |
97 | ||
98 | ||
99 | class DefaultObjectKeyConstructor(DefaultKeyConstructor): | |
100 | retrieve_sql_query = bits.RetrieveSqlQueryKeyBit() | |
101 | ||
102 | ||
103 | class DefaultListKeyConstructor(DefaultKeyConstructor): | |
104 | list_sql_query = bits.ListSqlQueryKeyBit() | |
105 | pagination = bits.PaginationKeyBit() | |
106 | ||
107 | ||
108 | class DefaultAPIModelInstanceKeyConstructor(KeyConstructor): | |
109 | """ | |
110 | Use this constructor when the values of the model instance are required | |
111 | to identify the resource. | |
112 | """ | |
113 | retrieve_model_values = bits.RetrieveModelKeyBit() | |
114 | ||
115 | ||
116 | class DefaultAPIModelListKeyConstructor(KeyConstructor): | |
117 | """ | |
118 | Use this constructor when the values of the model instance are required | |
119 | to identify many resources. | |
120 | """ | |
121 | list_model_values = bits.ListModelKeyBit() |
0 | # -*- coding: utf-8 -*- | |
1 | # Try to import six from Django, fallback to included `six`. | |
2 | ||
3 | from django.utils import six | |
4 | ||
5 | ||
6 | from rest_framework_extensions.cache.mixins import CacheResponseMixin | |
7 | # from rest_framework_extensions.etag.mixins import ReadOnlyETAGMixin, ETAGMixin | |
8 | from rest_framework_extensions.bulk_operations.mixins import ListUpdateModelMixin | |
9 | from rest_framework_extensions.settings import extensions_api_settings | |
10 | from django.http import Http404 | |
11 | ||
12 | ||
13 | class DetailSerializerMixin(object): | |
14 | """ | |
15 | Add custom serializer for detail view | |
16 | """ | |
17 | serializer_detail_class = None | |
18 | queryset_detail = None | |
19 | ||
20 | def get_serializer_class(self): | |
21 | error_message = "'{0}' should include a 'serializer_detail_class' attribute".format(self.__class__.__name__) | |
22 | assert self.serializer_detail_class is not None, error_message | |
23 | if self._is_request_to_detail_endpoint(): | |
24 | return self.serializer_detail_class | |
25 | else: | |
26 | return super(DetailSerializerMixin, self).get_serializer_class() | |
27 | ||
28 | def get_queryset(self, *args, **kwargs): | |
29 | if self._is_request_to_detail_endpoint() and self.queryset_detail is not None: | |
30 | return self.queryset_detail.all() # todo: test all() | |
31 | else: | |
32 | return super(DetailSerializerMixin, self).get_queryset(*args, **kwargs) | |
33 | ||
34 | def _is_request_to_detail_endpoint(self): | |
35 | if hasattr(self, 'lookup_url_kwarg'): | |
36 | lookup = self.lookup_url_kwarg or self.lookup_field | |
37 | return lookup and lookup in self.kwargs | |
38 | ||
39 | ||
40 | class PaginateByMaxMixin(object): | |
41 | ||
42 | def get_page_size(self, request): | |
43 | if self.page_size_query_param and self.max_page_size and request.query_params.get(self.page_size_query_param) == 'max': | |
44 | return self.max_page_size | |
45 | return super(PaginateByMaxMixin, self).get_page_size(request) | |
46 | ||
47 | ||
48 | # class ReadOnlyCacheResponseAndETAGMixin(ReadOnlyETAGMixin, CacheResponseMixin): | |
49 | # pass | |
50 | ||
51 | ||
52 | # class CacheResponseAndETAGMixin(ETAGMixin, CacheResponseMixin): | |
53 | # pass | |
54 | ||
55 | ||
56 | class NestedViewSetMixin(object): | |
57 | def get_queryset(self): | |
58 | return self.filter_queryset_by_parents_lookups( | |
59 | super(NestedViewSetMixin, self).get_queryset() | |
60 | ) | |
61 | ||
62 | def filter_queryset_by_parents_lookups(self, queryset): | |
63 | parents_query_dict = self.get_parents_query_dict() | |
64 | if parents_query_dict: | |
65 | try: | |
66 | return queryset.filter(**parents_query_dict) | |
67 | except ValueError: | |
68 | raise Http404 | |
69 | else: | |
70 | return queryset | |
71 | ||
72 | def get_parents_query_dict(self): | |
73 | result = {} | |
74 | for kwarg_name, kwarg_value in six.iteritems(self.kwargs): | |
75 | if kwarg_name.startswith(extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX): | |
76 | query_lookup = kwarg_name.replace( | |
77 | extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX, | |
78 | '', | |
79 | 1 | |
80 | ) | |
81 | query_value = kwarg_value | |
82 | result[query_lookup] = query_value | |
83 | return result |
0 | # -*- coding: utf-8 -*- | |
1 | from rest_framework.permissions import DjangoObjectPermissions | |
2 | ||
3 | ||
4 | class ExtendedDjangoObjectPermissions(DjangoObjectPermissions): | |
5 | hide_forbidden_for_read_objects = True | |
6 | ||
7 | def has_object_permission(self, request, view, obj): | |
8 | if self.hide_forbidden_for_read_objects: | |
9 | return super(ExtendedDjangoObjectPermissions, self).has_object_permission(request, view, obj) | |
10 | else: | |
11 | model_cls = getattr(view, 'model', None) | |
12 | queryset = getattr(view, 'queryset', None) | |
13 | ||
14 | if model_cls is None and queryset is not None: | |
15 | model_cls = queryset.model | |
16 | ||
17 | perms = self.get_required_object_permissions(request.method, model_cls) | |
18 | user = request.user | |
19 | ||
20 | return user.has_perms(perms, obj) |
0 | # -*- coding: utf-8 -*- | |
1 | from rest_framework.routers import DefaultRouter, SimpleRouter | |
2 | from rest_framework_extensions.utils import compose_parent_pk_kwarg_name | |
3 | ||
4 | ||
5 | class NestedRegistryItem(object): | |
6 | def __init__(self, router, parent_prefix, parent_item=None, parent_viewset=None): | |
7 | self.router = router | |
8 | self.parent_prefix = parent_prefix | |
9 | self.parent_item = parent_item | |
10 | self.parent_viewset = parent_viewset | |
11 | ||
12 | def register(self, prefix, viewset, basename, parents_query_lookups): | |
13 | self.router._register( | |
14 | prefix=self.get_prefix(current_prefix=prefix, parents_query_lookups=parents_query_lookups), | |
15 | viewset=viewset, | |
16 | basename=basename, | |
17 | ) | |
18 | return NestedRegistryItem( | |
19 | router=self.router, | |
20 | parent_prefix=prefix, | |
21 | parent_item=self, | |
22 | parent_viewset=viewset | |
23 | ) | |
24 | ||
25 | def get_prefix(self, current_prefix, parents_query_lookups): | |
26 | return '{0}/{1}'.format( | |
27 | self.get_parent_prefix(parents_query_lookups), | |
28 | current_prefix | |
29 | ) | |
30 | ||
31 | def get_parent_prefix(self, parents_query_lookups): | |
32 | prefix = '/' | |
33 | current_item = self | |
34 | i = len(parents_query_lookups) - 1 | |
35 | while current_item: | |
36 | parent_lookup_value_regex = getattr(current_item.parent_viewset, 'lookup_value_regex', '[^/.]+') | |
37 | prefix = '{parent_prefix}/(?P<{parent_pk_kwarg_name}>{parent_lookup_value_regex})/{prefix}'.format( | |
38 | parent_prefix=current_item.parent_prefix, | |
39 | parent_pk_kwarg_name=compose_parent_pk_kwarg_name(parents_query_lookups[i]), | |
40 | parent_lookup_value_regex=parent_lookup_value_regex, | |
41 | prefix=prefix | |
42 | ) | |
43 | i -= 1 | |
44 | current_item = current_item.parent_item | |
45 | return prefix.strip('/') | |
46 | ||
47 | ||
48 | class NestedRouterMixin(object): | |
49 | def _register(self, *args, **kwargs): | |
50 | return super(NestedRouterMixin, self).register(*args, **kwargs) | |
51 | ||
52 | def register(self, *args, **kwargs): | |
53 | self._register(*args, **kwargs) | |
54 | return NestedRegistryItem( | |
55 | router=self, | |
56 | parent_prefix=self.registry[-1][0], | |
57 | parent_viewset=self.registry[-1][1] | |
58 | ) | |
59 | ||
60 | ||
61 | class ExtendedRouterMixin(NestedRouterMixin): | |
62 | pass | |
63 | ||
64 | ||
65 | class ExtendedSimpleRouter(ExtendedRouterMixin, SimpleRouter): | |
66 | pass | |
67 | ||
68 | ||
69 | class ExtendedDefaultRouter(ExtendedRouterMixin, DefaultRouter): | |
70 | pass |
0 | # -*- coding: utf-8 -*- | |
1 | from rest_framework_extensions.utils import get_model_opts_concrete_fields | |
2 | ||
3 | ||
4 | def get_fields_for_partial_update(opts, init_data, fields, init_files=None): | |
5 | opts = opts.model._meta.concrete_model._meta | |
6 | partial_fields = list((init_data or {}).keys()) + list((init_files or {}).keys()) | |
7 | concrete_field_names = [] | |
8 | for field in get_model_opts_concrete_fields(opts): | |
9 | if not field.primary_key: | |
10 | concrete_field_names.append(field.name) | |
11 | if field.name != field.attname: | |
12 | concrete_field_names.append(field.attname) | |
13 | update_fields = [] | |
14 | for field_name in partial_fields: | |
15 | if field_name in fields: | |
16 | model_field_name = getattr(fields[field_name], 'source') or field_name | |
17 | if model_field_name in concrete_field_names: | |
18 | update_fields.append(model_field_name) | |
19 | return update_fields | |
20 | ||
21 | ||
22 | class PartialUpdateSerializerMixin(object): | |
23 | def save(self, **kwargs): | |
24 | self._update_fields = kwargs.get('update_fields', None) | |
25 | return super(PartialUpdateSerializerMixin, self).save(**kwargs) | |
26 | ||
27 | def update(self, instance, validated_attrs): | |
28 | for attr, value in validated_attrs.items(): | |
29 | if hasattr(getattr(instance, attr, None), 'set'): | |
30 | getattr(instance, attr).set(value) | |
31 | else: | |
32 | setattr(instance, attr, value) | |
33 | if self.partial and isinstance(instance, self.Meta.model): | |
34 | instance.save( | |
35 | update_fields=getattr(self, '_update_fields') or get_fields_for_partial_update( | |
36 | opts=self.Meta, | |
37 | init_data=self.get_initial(), | |
38 | fields=self.fields.fields | |
39 | ) | |
40 | ) | |
41 | else: | |
42 | instance.save() | |
43 | return instance |
0 | # -*- coding: utf-8 -*- | |
1 | from django.conf import settings | |
2 | ||
3 | from rest_framework.settings import APISettings | |
4 | ||
5 | ||
6 | USER_SETTINGS = getattr(settings, 'REST_FRAMEWORK_EXTENSIONS', None) | |
7 | ||
8 | DEFAULTS = { | |
9 | # caching | |
10 | 'DEFAULT_USE_CACHE': 'default', | |
11 | 'DEFAULT_CACHE_RESPONSE_TIMEOUT': None, | |
12 | 'DEFAULT_CACHE_ERRORS': True, | |
13 | 'DEFAULT_CACHE_KEY_FUNC': 'rest_framework_extensions.utils.default_cache_key_func', | |
14 | 'DEFAULT_OBJECT_CACHE_KEY_FUNC': 'rest_framework_extensions.utils.default_object_cache_key_func', | |
15 | 'DEFAULT_LIST_CACHE_KEY_FUNC': 'rest_framework_extensions.utils.default_list_cache_key_func', | |
16 | ||
17 | # ETAG | |
18 | 'DEFAULT_ETAG_FUNC': 'rest_framework_extensions.utils.default_etag_func', | |
19 | 'DEFAULT_OBJECT_ETAG_FUNC': 'rest_framework_extensions.utils.default_object_etag_func', | |
20 | 'DEFAULT_LIST_ETAG_FUNC': 'rest_framework_extensions.utils.default_list_etag_func', | |
21 | ||
22 | # API - ETAG | |
23 | 'DEFAULT_API_OBJECT_ETAG_FUNC': 'rest_framework_extensions.utils.default_api_object_etag_func', | |
24 | 'DEFAULT_API_LIST_ETAG_FUNC': 'rest_framework_extensions.utils.default_api_list_etag_func', | |
25 | ||
26 | # other | |
27 | 'DEFAULT_KEY_CONSTRUCTOR_MEMOIZE_FOR_REQUEST': False, | |
28 | 'DEFAULT_BULK_OPERATION_HEADER_NAME': 'X-BULK-OPERATION', | |
29 | 'DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX': 'parent_lookup_' | |
30 | } | |
31 | ||
32 | IMPORT_STRINGS = [ | |
33 | 'DEFAULT_CACHE_KEY_FUNC', | |
34 | 'DEFAULT_OBJECT_CACHE_KEY_FUNC', | |
35 | 'DEFAULT_LIST_CACHE_KEY_FUNC', | |
36 | 'DEFAULT_ETAG_FUNC', | |
37 | 'DEFAULT_OBJECT_ETAG_FUNC', | |
38 | 'DEFAULT_LIST_ETAG_FUNC', | |
39 | # API - ETAG | |
40 | 'DEFAULT_API_OBJECT_ETAG_FUNC', | |
41 | 'DEFAULT_API_LIST_ETAG_FUNC', | |
42 | ] | |
43 | ||
44 | ||
45 | extensions_api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) |
0 | # -- coding: utf-8 -- | |
1 | ||
2 | # Note that we import as `DjangoRequestFactory` and `DjangoClient` in order | |
3 | # to make it harder for the user to import the wrong thing without realizing. | |
4 | from __future__ import unicode_literals | |
5 | import django | |
6 | from django.conf import settings | |
7 | from django.test.client import Client as DjangoClient | |
8 | from django.test.client import ClientHandler | |
9 | from django.test import testcases | |
10 | from django.utils.http import urlencode | |
11 | from rest_framework.settings import api_settings | |
12 | from django.test.client import RequestFactory # changed here | |
13 | from django.utils.encoding import force_bytes, six # changed here | |
14 | ||
15 | ||
16 | def force_authenticate(request, user=None, token=None): | |
17 | request._force_auth_user = user | |
18 | request._force_auth_token = token | |
19 | ||
20 | ||
21 | class APIRequestFactory(RequestFactory): | |
22 | renderer_classes_list = api_settings.TEST_REQUEST_RENDERER_CLASSES | |
23 | default_format = api_settings.TEST_REQUEST_DEFAULT_FORMAT | |
24 | ||
25 | def __init__(self, enforce_csrf_checks=False, **defaults): | |
26 | self.enforce_csrf_checks = enforce_csrf_checks | |
27 | self.renderer_classes = {} | |
28 | for cls in self.renderer_classes_list: | |
29 | self.renderer_classes[cls.format] = cls | |
30 | super(APIRequestFactory, self).__init__(**defaults) | |
31 | ||
32 | def _encode_data(self, data, format=None, content_type=None): | |
33 | """ | |
34 | Encode the data returning a two tuple of (bytes, content_type) | |
35 | """ | |
36 | ||
37 | if not data: | |
38 | return ('', None) | |
39 | ||
40 | assert format is None or content_type is None, ( | |
41 | 'You may not set both `format` and `content_type`.' | |
42 | ) | |
43 | ||
44 | if content_type: | |
45 | # Content type specified explicitly, treat data as a raw bytestring | |
46 | ret = force_bytes(data, settings.DEFAULT_CHARSET) | |
47 | ||
48 | else: | |
49 | format = format or self.default_format | |
50 | ||
51 | assert format in self.renderer_classes, ( | |
52 | "Invalid format '{0}'." | |
53 | "Available formats are {1}. Set TEST_REQUEST_RENDERER_CLASSES " | |
54 | "to enable extra request formats.".format( | |
55 | format, | |
56 | ', '.join( | |
57 | ["'" + fmt + "'" for fmt in self.renderer_classes.keys()]) | |
58 | ) | |
59 | ) | |
60 | ||
61 | # Use format and render the data into a bytestring | |
62 | renderer = self.renderer_classes[format]() | |
63 | ret = renderer.render(data) | |
64 | ||
65 | # Determine the content-type header from the renderer | |
66 | content_type = "{0}; charset={1}".format( | |
67 | renderer.media_type, renderer.charset | |
68 | ) | |
69 | ||
70 | # Coerce text to bytes if required. | |
71 | if isinstance(ret, six.text_type): | |
72 | ret = bytes(ret.encode(renderer.charset)) | |
73 | ||
74 | return ret, content_type | |
75 | ||
76 | def get(self, path, data=None, **extra): | |
77 | r = { | |
78 | 'QUERY_STRING': urlencode(data or {}, doseq=True), | |
79 | } | |
80 | # Fix to support old behavior where you have the arguments in the url | |
81 | # See #1461 | |
82 | if not data and '?' in path: | |
83 | r['QUERY_STRING'] = path.split('?')[1] | |
84 | r.update(extra) | |
85 | return self.generic('GET', path, **r) | |
86 | ||
87 | def post(self, path, data=None, format=None, content_type=None, **extra): | |
88 | data, content_type = self._encode_data(data, format, content_type) | |
89 | return self.generic('POST', path, data, content_type, **extra) | |
90 | ||
91 | def put(self, path, data=None, format=None, content_type=None, **extra): | |
92 | data, content_type = self._encode_data(data, format, content_type) | |
93 | return self.generic('PUT', path, data, content_type, **extra) | |
94 | ||
95 | def patch(self, path, data=None, format=None, content_type=None, **extra): | |
96 | data, content_type = self._encode_data(data, format, content_type) | |
97 | return self.generic('PATCH', path, data, content_type, **extra) | |
98 | ||
99 | def delete(self, path, data=None, format=None, content_type=None, **extra): | |
100 | data, content_type = self._encode_data(data, format, content_type) | |
101 | return self.generic('DELETE', path, data, content_type, **extra) | |
102 | ||
103 | def options(self, path, data=None, format=None, content_type=None, **extra): | |
104 | data, content_type = self._encode_data(data, format, content_type) | |
105 | return self.generic('OPTIONS', path, data, content_type, **extra) | |
106 | ||
107 | def request(self, **kwargs): | |
108 | request = super(APIRequestFactory, self).request(**kwargs) | |
109 | request._dont_enforce_csrf_checks = not self.enforce_csrf_checks | |
110 | return request | |
111 | ||
112 | ||
113 | class ForceAuthClientHandler(ClientHandler): | |
114 | """ | |
115 | A patched version of ClientHandler that can enforce authentication | |
116 | on the outgoing requests. | |
117 | """ | |
118 | ||
119 | def __init__(self, *args, **kwargs): | |
120 | self._force_user = None | |
121 | self._force_token = None | |
122 | super(ForceAuthClientHandler, self).__init__(*args, **kwargs) | |
123 | ||
124 | def get_response(self, request): | |
125 | # This is the simplest place we can hook into to patch the | |
126 | # request object. | |
127 | force_authenticate(request, self._force_user, self._force_token) | |
128 | return super(ForceAuthClientHandler, self).get_response(request) | |
129 | ||
130 | ||
131 | class APIClient(APIRequestFactory, DjangoClient): | |
132 | def __init__(self, enforce_csrf_checks=False, **defaults): | |
133 | super(APIClient, self).__init__(**defaults) | |
134 | self.handler = ForceAuthClientHandler(enforce_csrf_checks) | |
135 | self._credentials = {} | |
136 | ||
137 | def credentials(self, **kwargs): | |
138 | """ | |
139 | Sets headers that will be used on every outgoing request. | |
140 | """ | |
141 | self._credentials = kwargs | |
142 | ||
143 | def force_authenticate(self, user=None, token=None): | |
144 | """ | |
145 | Forcibly authenticates outgoing requests with the given | |
146 | user and/or token. | |
147 | """ | |
148 | self.handler._force_user = user | |
149 | self.handler._force_token = token | |
150 | if user is None: | |
151 | self.logout() # Also clear any possible session info if required | |
152 | ||
153 | def request(self, **kwargs): | |
154 | # Ensure that any credentials set get added to every request. | |
155 | kwargs.update(self._credentials) | |
156 | return super(APIClient, self).request(**kwargs) | |
157 | ||
158 | ||
159 | class APITransactionTestCase(testcases.TransactionTestCase): | |
160 | client_class = APIClient | |
161 | ||
162 | ||
163 | class APITestCase(testcases.TestCase): | |
164 | client_class = APIClient | |
165 | ||
166 | ||
167 | if django.VERSION >= (1, 4): | |
168 | class APISimpleTestCase(testcases.SimpleTestCase): | |
169 | client_class = APIClient | |
170 | ||
171 | class APILiveServerTestCase(testcases.LiveServerTestCase): | |
172 | client_class = APIClient⏎ |
0 | # -*- coding: utf-8 -*- | |
1 | import itertools | |
2 | from distutils.version import LooseVersion | |
3 | ||
4 | import rest_framework | |
5 | ||
6 | from rest_framework_extensions.key_constructor.constructors import ( | |
7 | DefaultKeyConstructor, | |
8 | DefaultObjectKeyConstructor, | |
9 | DefaultListKeyConstructor, | |
10 | DefaultAPIModelInstanceKeyConstructor, | |
11 | DefaultAPIModelListKeyConstructor | |
12 | ) | |
13 | from rest_framework_extensions.settings import extensions_api_settings | |
14 | ||
15 | ||
16 | def get_rest_framework_version(): | |
17 | return tuple(LooseVersion(rest_framework.VERSION).version) | |
18 | ||
19 | ||
20 | def flatten(list_of_lists): | |
21 | """ | |
22 | Takes an iterable of iterables, | |
23 | returns a single iterable containing all items | |
24 | """ | |
25 | # todo: test me | |
26 | return itertools.chain(*list_of_lists) | |
27 | ||
28 | ||
29 | def prepare_header_name(name): | |
30 | """ | |
31 | >> prepare_header_name('Accept-Language') | |
32 | http_accept_language | |
33 | """ | |
34 | return 'http_{0}'.format(name.strip().replace('-', '_')).upper() | |
35 | ||
36 | ||
37 | def get_unique_method_id(view_instance, view_method): | |
38 | # todo: test me as UniqueMethodIdKeyBit | |
39 | return u'.'.join([ | |
40 | view_instance.__module__, | |
41 | view_instance.__class__.__name__, | |
42 | view_method.__name__ | |
43 | ]) | |
44 | ||
45 | ||
46 | def get_model_opts_concrete_fields(opts): | |
47 | # todo: test me | |
48 | if not hasattr(opts, 'concrete_fields'): | |
49 | opts.concrete_fields = [f for f in opts.fields if f.column is not None] | |
50 | return opts.concrete_fields | |
51 | ||
52 | ||
53 | def compose_parent_pk_kwarg_name(value): | |
54 | return '{0}{1}'.format( | |
55 | extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX, | |
56 | value | |
57 | ) | |
58 | ||
59 | ||
60 | default_cache_key_func = DefaultKeyConstructor() | |
61 | default_object_cache_key_func = DefaultObjectKeyConstructor() | |
62 | default_list_cache_key_func = DefaultListKeyConstructor() | |
63 | ||
64 | default_etag_func = default_cache_key_func | |
65 | default_object_etag_func = default_object_cache_key_func | |
66 | default_list_etag_func = default_list_cache_key_func | |
67 | ||
68 | # API (object-centered) functions | |
69 | default_api_object_etag_func = DefaultAPIModelInstanceKeyConstructor() | |
70 | default_api_list_etag_func = DefaultAPIModelListKeyConstructor() |
0 | # -*- coding: utf-8 -*- | |
1 | 0 | #!/usr/bin/env python |
2 | 1 | """ |
3 | 2 | Backdoc is a tool for backbone-like documentation generation. |
8 | 7 | import sys |
9 | 8 | import argparse |
10 | 9 | |
11 | # -*- coding: utf-8 -*- | |
12 | #!/usr/bin/env python | |
13 | 10 | # Copyright (c) 2012 Trent Mick. |
14 | 11 | # Copyright (c) 2007-2008 ActiveState Corp. |
15 | 12 | # License: MIT (http://www.opensource.org/licenses/mit-license.php) |
184 | 181 | link_patterns=link_patterns, |
185 | 182 | use_file_vars=use_file_vars).convert(text) |
186 | 183 | |
187 | class Markdown(object): | |
184 | class Markdown: | |
188 | 185 | # The dict of "extras" to enable in processing -- a mapping of |
189 | 186 | # extra name to argument for the extra. Most extras do not have an |
190 | 187 | # argument, in which case the value is None. |
2094 | 2091 | return ''.join(lines) |
2095 | 2092 | |
2096 | 2093 | |
2097 | class _memoized(object): | |
2094 | class _memoized: | |
2098 | 2095 | """Decorator that caches a function's return value each time it is called. |
2099 | 2096 | If called later with the same arguments, the cached value is returned, and |
2100 | 2097 | not re-evaluated. |
2659 | 2656 | return text.decode('utf-8') |
2660 | 2657 | |
2661 | 2658 | |
2662 | class BackDoc(object): | |
2659 | class BackDoc: | |
2663 | 2660 | def __init__(self, markdown_converter, template_html, stdin, stdout): |
2664 | 2661 | self.markdown_converter = markdown_converter |
2665 | 2662 | self.template_html = force_text(template_html) |
Binary diff not shown
78 | 78 | |
79 | 79 | #### Cache/ETAG mixins |
80 | 80 | |
81 | **ReadOnlyCacheResponseAndETAGMixin** | |
82 | ||
83 | This mixin combines `ReadOnlyETAGMixin` and `CacheResponseMixin`. It could be used with | |
84 | [ReadOnlyModelViewSet](http://www.django-rest-framework.org/api-guide/viewsets.html#readonlymodelviewset) and helps | |
85 | to process caching + etag calculation for `retrieve` and `list` methods: | |
86 | ||
87 | from myapps.serializers import UserSerializer | |
88 | from rest_framework_extensions.mixins import ( | |
89 | ReadOnlyCacheResponseAndETAGMixin | |
90 | ) | |
91 | ||
92 | class UserViewSet(ReadOnlyCacheResponseAndETAGMixin, | |
93 | viewsets.ReadOnlyModelViewSet): | |
94 | serializer_class = UserSerializer | |
95 | ||
96 | **CacheResponseAndETAGMixin** | |
97 | ||
98 | This mixin combines `ETAGMixin` and `CacheResponseMixin`. It could be used with | |
99 | [ModelViewSet](http://www.django-rest-framework.org/api-guide/viewsets.html#modelviewset) and helps | |
100 | to process: | |
101 | ||
102 | * Caching for `retrieve` and `list` methods | |
103 | * Etag for `retrieve`, `list`, `update` and `destroy` methods | |
104 | ||
105 | Usage: | |
106 | ||
107 | from myapps.serializers import UserSerializer | |
108 | from rest_framework_extensions.mixins import CacheResponseAndETAGMixin | |
109 | ||
110 | class UserViewSet(CacheResponseAndETAGMixin, | |
111 | viewsets.ModelViewSet): | |
112 | serializer_class = UserSerializer | |
113 | ||
114 | Please, read more about [caching](#caching), [key construction](#key-constructor) and [conditional requests](#conditional-requests). | |
115 | ||
81 | <!--**ReadOnlyCacheResponseAndETAGMixin**--> | |
82 | ||
83 | <!--This mixin combines `ReadOnlyETAGMixin` and `CacheResponseMixin`. It could be used with--> | |
84 | <!--[ReadOnlyModelViewSet](http://www.django-rest-framework.org/api-guide/viewsets.html#readonlymodelviewset) and helps--> | |
85 | <!--to process caching + etag calculation for `retrieve` and `list` methods:--> | |
86 | ||
87 | <!-- from myapps.serializers import UserSerializer--> | |
88 | <!-- from rest_framework_extensions.mixins import (--> | |
89 | <!-- ReadOnlyCacheResponseAndETAGMixin--> | |
90 | <!-- )--> | |
91 | ||
92 | <!-- class UserViewSet(ReadOnlyCacheResponseAndETAGMixin,--> | |
93 | <!-- viewsets.ReadOnlyModelViewSet):--> | |
94 | <!-- serializer_class = UserSerializer--> | |
95 | ||
96 | <!--**CacheResponseAndETAGMixin**--> | |
97 | ||
98 | <!--This mixin combines `ETAGMixin` and `CacheResponseMixin`. It could be used with--> | |
99 | <!--[ModelViewSet](http://www.django-rest-framework.org/api-guide/viewsets.html#modelviewset) and helps--> | |
100 | <!--to process:--> | |
101 | ||
102 | <!--* Caching for `retrieve` and `list` methods--> | |
103 | <!--* Etag for `retrieve`, `list`, `update` and `destroy` methods--> | |
104 | ||
105 | <!--Usage:--> | |
106 | ||
107 | <!-- from myapps.serializers import UserSerializer--> | |
108 | <!-- from rest_framework_extensions.mixins import CacheResponseAndETAGMixin--> | |
109 | ||
110 | <!-- class UserViewSet(CacheResponseAndETAGMixin,--> | |
111 | <!-- viewsets.ModelViewSet):--> | |
112 | <!-- serializer_class = UserSerializer--> | |
113 | ||
114 | <!--Please, read more about [caching](#caching), [key construction](#key-constructor) and [conditional requests](#conditional-requests).--> | |
115 | The etag functionality is pending an overhaul has been temporarily removed since 0.4.0. | |
116 | ||
117 | ReadOnlyCacheResponseAndETAGMixin and CacheResponseAndETAGMixin are no longer available to use. | |
118 | ||
119 | See discussion in [Issue #177](https://github.com/chibisov/drf-extensions/issues/177) | |
116 | 120 | |
117 | 121 | ### Routers |
118 | 122 | |
1020 | 1024 | # views.py |
1021 | 1025 | import datetime |
1022 | 1026 | from django.core.cache import cache |
1023 | from django.utils.encoding import force_text | |
1027 | from django.utils.encoding import force_str | |
1024 | 1028 | from yourapp.serializers import GroupSerializer, ProfileSerializer |
1025 | 1029 | from rest_framework_extensions.cache.decorators import cache_response |
1026 | 1030 | from rest_framework_extensions.key_constructor.constructors import ( |
1040 | 1044 | if not value: |
1041 | 1045 | value = datetime.datetime.utcnow() |
1042 | 1046 | cache.set(key, value=value) |
1043 | return force_text(value) | |
1047 | return force_str(value) | |
1044 | 1048 | |
1045 | 1049 | class CustomObjectKeyConstructor(DefaultKeyConstructor): |
1046 | 1050 | retrieve_sql = RetrieveSqlQueryKeyBit() |
1322 | 1326 | |
1323 | 1327 | ### Conditional requests |
1324 | 1328 | |
1325 | *This documentation section uses information from [RESTful Web Services Cookbook](http://shop.oreilly.com/product/9780596801694.do) 10-th chapter.* | |
1326 | ||
1327 | Conditional HTTP requests allow API clients to accomplish 2 goals: | |
1328 | ||
1329 | 1. Conditional HTTP GET saves client and server [time and bandwidth](#saving-time-and-bandwidth). | |
1330 | * For unsafe requests such as PUT, POST, and DELETE, conditional requests provide [concurrency control](#concurrency-control). | |
1331 | ||
1332 | The second goal addresses the lost update problem, where a resource is altered and saved by user B while user A is still editing. | |
1333 | In both cases, the 'condition' included in the request needs to be a unique identifier (e.g. unique semantic fingerprint) of the requested resource in order to detect changes. | |
1334 | This fingerprint can be transient (i.e. using the cache as with `UpdatedAtKeyBit`), or persistent, i.e. computed from the model instance attribute values from the database. | |
1335 | While the `UpdatedAtKeyBit` approach requires to add triggers to your models, the semantic fingerprint option is designed to be pluggable and does not require to alter your model code. | |
1336 | ||
1337 | ||
1338 | #### HTTP ETag | |
1339 | ||
1340 | *An ETag or entity tag, is part of HTTP, the protocol for the World Wide Web. | |
1341 | It is one of several mechanisms that HTTP provides for web cache validation, and which allows a client to make conditional requests.* - [Wikipedia](http://en.wikipedia.org/wiki/HTTP_ETag) | |
1342 | ||
1343 | For ETag calculation and conditional request processing you should use the decorators from `rest_framework_extensions.etag.decorators`. | |
1344 | The `@etag` decorator works similar to the native [django decorator](https://docs.djangoproject.com/en/dev/topics/conditional-view-processing/). | |
1345 | ||
1346 | <!-- THIS REFERS TO THE DEFAULT_OBJ_ETAG_FUNC --> | |
1347 | The [default ETag function](#default-etag-function) used by the `@etag` decorator computes the value with respect to the particular view and HTTP method in the request and therefore *cannot detect changes in individual model instances*. | |
1348 | If you need to compute the *semantic* fingerprint of a model independent of a particular view and method, implement your custom `etag_func`. | |
1349 | Alternatively you could use the `@api_etag` decorator and specify the `viewset` in the view. | |
1350 | ||
1351 | ||
1352 | from rest_framework_extensions.etag.decorators import etag | |
1353 | ||
1354 | class CityView(views.APIView): | |
1355 | @etag() | |
1356 | def get(self, request, *args, **kwargs): | |
1357 | cities = City.objects.all().values_list('name', flat=True) | |
1358 | return Response(cities) | |
1359 | ||
1360 | By default `@etag` would calculate the ETag header value with the same algorithm as [cache key](#cache-key) default calculation performs. | |
1361 | ||
1362 | # Request | |
1363 | GET /cities/ HTTP/1.1 | |
1364 | Accept: application/json | |
1365 | ||
1366 | # Response | |
1367 | HTTP/1.1 200 OK | |
1368 | Content-Type: application/json; charset=UTF-8 | |
1369 | ETag: "e7b50490dc546d116635a14cfa58110306dd6c5434146b6740ec08bf0a78f9a2" | |
1370 | ||
1371 | ['Moscow', 'London', 'Paris'] | |
1372 | ||
1373 | You can define a custom function for Etag value calculation with `etag_func` argument: | |
1374 | ||
1375 | from rest_framework_extensions.etag.decorators import etag | |
1376 | ||
1377 | def calculate_etag(view_instance, view_method, | |
1378 | request, args, kwargs): | |
1379 | return '.'.join([ | |
1380 | len(args), | |
1381 | len(kwargs) | |
1382 | ]) | |
1383 | ||
1384 | class CityView(views.APIView): | |
1385 | @etag(etag_func=calculate_etag) | |
1386 | def get(self, request, *args, **kwargs): | |
1387 | cities = City.objects.all().values_list('name', flat=True) | |
1388 | return Response(cities) | |
1389 | ||
1390 | ||
1391 | You can implement a view method and use it for Etag calculation by specifying `etag_func` argument as string: | |
1392 | ||
1393 | from rest_framework_extensions.etag.decorators import etag | |
1394 | ||
1395 | class CityView(views.APIView): | |
1396 | @etag(etag_func='calculate_etag_from_method') | |
1397 | def get(self, request, *args, **kwargs): | |
1398 | cities = City.objects.all().values_list('name', flat=True) | |
1399 | return Response(cities) | |
1400 | ||
1401 | def calculate_etag_from_method(self, view_instance, view_method, | |
1402 | request, args, kwargs): | |
1403 | return '.'.join([ | |
1404 | len(args), | |
1405 | len(kwargs) | |
1406 | ]) | |
1407 | ||
1408 | ETag calculation function will be called with following parameters: | |
1409 | ||
1410 | * **view_instance** - view instance of decorated method | |
1411 | * **view_method** - decorated method | |
1412 | * **request** - decorated method request | |
1413 | * **args** - decorated method positional arguments | |
1414 | * **kwargs** - decorated method keyword arguments | |
1415 | ||
1416 | ||
1417 | #### Default ETag function | |
1418 | ||
1419 | If `@etag` decorator used without `etag_func` argument then default etag function will be used. You can change this function in | |
1420 | settings: | |
1421 | ||
1422 | REST_FRAMEWORK_EXTENSIONS = { | |
1423 | 'DEFAULT_ETAG_FUNC': | |
1424 | 'rest_framework_extensions.utils.default_etag_func' | |
1425 | } | |
1426 | ||
1427 | `default_etag_func` uses [DefaultKeyConstructor](#default-key-constructor) as a base for etag calculation. | |
1428 | ||
1429 | ||
1430 | #### API ETag function | |
1431 | <!-- This refers to the APIETagProcessor and @api_etag decorator --> | |
1432 | ||
1433 | *New in DRF-extensions 0.3.2* | |
1434 | ||
1435 | In addition, `APIETAGProcessor` explicitly requires a function that (ideally) creates an ETag value from model instances. | |
1436 | If the `@api_etag` decorator is used without `etag_func` the framework will raise an `AssertionError`. | |
1437 | Hence, the following snipped would not work: | |
1438 | ||
1439 | # BEGIN BAD CODE: | |
1440 | class View(views.APIView): | |
1441 | @api_etag() | |
1442 | def get(self, request, *args, **kwargs): | |
1443 | return super(View, self).get(request, *args, **kwargs) | |
1444 | # END BAD CODE | |
1445 | ||
1446 | **Why's that?** | |
1447 | It does not make sense to compute a default ETag here, because the processor would lock us out from the API by always issuing a `304` | |
1448 | response on conditional requests, even if the resource was modified meanwhile. | |
1449 | Therefore the `APIETAGProcessor` cannot be used without specifying an `etag_func` as keyword argument and there exists convenient | |
1450 | [mixin classes](#apietagmixin). | |
1451 | ||
1452 | You can use the decorator in regular `APIView`, and subclasses from the `rest_framework.generics` module, | |
1453 | but ensure to include a `queryset` attribute or override `get_queryset()`: | |
1454 | ||
1455 | from rest_framework import generics | |
1456 | from rest_framework.response import Response | |
1457 | from rest_framework_extensions.utils import default_api_object_etag_func | |
1458 | from my_app.models import Book | |
1459 | ||
1460 | class BookCustomDestroyView(generics.DestroyAPIView): | |
1461 | # include the queryset here to enable the object lookup | |
1462 | queryset = Book.objects.all() | |
1463 | ||
1464 | @api_etag(etag_func=default_api_object_etag_func) | |
1465 | def delete(self, request, *args, **kwargs): | |
1466 | obj = Book.objects.get(id=kwargs['pk']) | |
1467 | # ... perform some custom operations here ... | |
1468 | obj.delete() | |
1469 | return Response(status=status.HTTP_204_NO_CONTENT) | |
1470 | ||
1471 | ||
1472 | The next difference to the `@etag` decorator is that it defines an explicit map of | |
1473 | required headers for each HTTP request verb, using the following default values for unsafe methods: | |
1474 | ||
1475 | precondition_map = {'PUT': ['If-Match'], | |
1476 | 'PATCH': ['If-Match'], | |
1477 | 'DELETE': ['If-Match']} | |
1478 | ||
1479 | You can specify a custom set of headers in the decorator by passing the `precondition_map` keyword argument. | |
1480 | For instance, this statement | |
1481 | ||
1482 | @api_etag(etag_func=default_api_object_etag_func, precondition_map={'PUT': ['X-mycorp-custom']}) | |
1483 | def put(self, request, *args, **kwargs): | |
1484 | obj = Book.objects.get(id=kwargs['pk']) | |
1485 | # ... perform some custom operations here ... | |
1486 | obj.save() | |
1487 | return Response(status=status.HTTP_200_OK) | |
1488 | ||
1489 | checks for the presence of a custom header `X-mycorp-custom` in the request and permits the request, if it is present, | |
1490 | or returns a `428 PRECONDITION REQUIRED` response. | |
1491 | ||
1492 | Similarly, to disable all checks for a particular method simply pass an empty dict: | |
1493 | ||
1494 | @api_etag(etag_func=default_api_object_etag_func, precondition_map={}) | |
1495 | def put(self, request, *args, **kwargs): | |
1496 | obj = Book.objects.get(id=kwargs['pk']) | |
1497 | # ... perform some custom operations here ... | |
1498 | obj.save() | |
1499 | return Response(status=status.HTTP_200_OK) | |
1500 | ||
1501 | Please note that passing `None` in the `precondition_map` argument falls back to using the default map. | |
1502 | ||
1503 | #### Usage ETag with caching | |
1504 | ||
1505 | As you can see `@etag` and `@cache_response` decorators has similar key calculation approaches. | |
1506 | They both can take key from simple callable function. And more than this - in many cases they share the same calculation logic. | |
1507 | In the next example we use both decorators, which share one calculation function: | |
1508 | ||
1509 | from rest_framework_extensions.etag.decorators import etag | |
1510 | from rest_framework_extensions.cache.decorators import cache_response | |
1511 | from rest_framework_extensions.key_constructor import bits | |
1512 | from rest_framework_extensions.key_constructor.constructors import ( | |
1513 | KeyConstructor | |
1514 | ) | |
1515 | ||
1516 | class CityGetKeyConstructor(KeyConstructor): | |
1517 | format = bits.FormatKeyBit() | |
1518 | language = bits.LanguageKeyBit() | |
1519 | ||
1520 | class CityView(views.APIView): | |
1521 | key_constructor_func = CityGetKeyConstructor() | |
1522 | ||
1523 | @etag(key_constructor_func) | |
1524 | @cache_response(key_func=key_constructor_func) | |
1525 | def get(self, request, *args, **kwargs): | |
1526 | cities = City.objects.all().values_list('name', flat=True) | |
1527 | return Response(cities) | |
1528 | ||
1529 | Note the decorators order. First goes `@etag` and then goes `@cache_response`. We want firstly perform conditional processing and after it response processing. | |
1530 | ||
1531 | There is one more point for it. If conditional processing didn't fail then `key_constructor_func` would be called again in `@cache_response`. | |
1532 | But in most cases first calculation is enough. To accomplish this goal you could use `KeyConstructor` initial argument `memoize_for_request`: | |
1533 | ||
1534 | >>> key_constructor_func = CityGetKeyConstructor(memoize_for_request=True) | |
1535 | >>> request1, request1 = 'request1', 'request2' | |
1536 | >>> print key_constructor_func(request=request1) # full calculation | |
1537 | request1-key | |
1538 | >>> print key_constructor_func(request=request1) # data from cache | |
1539 | request1-key | |
1540 | >>> print key_constructor_func(request=request2) # full calculation | |
1541 | request2-key | |
1542 | >>> print key_constructor_func(request=request2) # data from cache | |
1543 | request2-key | |
1544 | ||
1545 | By default `memoize_for_request` is `False`, but you can change it in settings: | |
1546 | ||
1547 | REST_FRAMEWORK_EXTENSIONS = { | |
1548 | 'DEFAULT_KEY_CONSTRUCTOR_MEMOIZE_FOR_REQUEST': True | |
1549 | } | |
1550 | ||
1551 | ||
1552 | It's important to note that this memoization is thread safe. | |
1553 | ||
1554 | #### Saving time and bandwidth | |
1555 | ||
1556 | When a server returns `ETag` header, you should store it along with the representation data on the client. | |
1557 | When making GET and HEAD requests for the same resource in the future, include the `If-None-Match` header | |
1558 | to make these requests "conditional". | |
1559 | ||
1560 | For example, retrieve all cities: | |
1561 | ||
1562 | # Request | |
1563 | GET /cities/ HTTP/1.1 | |
1564 | Accept: application/json | |
1565 | ||
1566 | # Response | |
1567 | HTTP/1.1 200 OK | |
1568 | Content-Type: application/json; charset=UTF-8 | |
1569 | ETag: "some_etag_value" | |
1570 | ||
1571 | ['Moscow', 'London', 'Paris'] | |
1572 | ||
1573 | If you make same request with `If-None-Match` and there exists a cached value for this request, | |
1574 | then server will respond with `304` status code without body data. | |
1575 | ||
1576 | # Request | |
1577 | GET /cities/ HTTP/1.1 | |
1578 | Accept: application/json | |
1579 | If-None-Match: some_etag_value | |
1580 | ||
1581 | # Response | |
1582 | HTTP/1.1 304 NOT MODIFIED | |
1583 | Content-Type: application/json; charset=UTF-8 | |
1584 | Etag: "some_etag_value" | |
1585 | ||
1586 | After this response you can use existing cities data on the client. | |
1587 | ||
1588 | ||
1589 | ||
1590 | ||
1591 | ||
1592 | ||
1593 | ||
1594 | ||
1595 | #### Concurrency control | |
1596 | ||
1597 | Concurrency control ensures the correct processing of data under concurrent operations by clients. | |
1598 | There are two ways to implement concurrency control: | |
1599 | ||
1600 | * **Pessimistic concurrency control**. In this model, the client gets a lock, obtains | |
1601 | the current state of the resource, makes modifications, and then releases the lock. | |
1602 | During this process, the server prevents other clients from acquiring a lock on the same resource. | |
1603 | Relational databases operate in this manner. | |
1604 | * **Optimistic concurrency control**. In this model, the client first gets a token. | |
1605 | Instead of obtaining a lock, the client attempts a write operation with the token included in the request. | |
1606 | The operation succeeds if the token is still valid and fails otherwise. | |
1607 | ||
1608 | HTTP, being a stateless application control, is designed for optimistic concurrency control. | |
1609 | According to [RFC 6585](https://tools.ietf.org/html/rfc6585), the server can optionally require | |
1610 | a condition for a request. This library returns a `428` status, if no ETag is supplied, but would be mandatory | |
1611 | for a request to succeed. | |
1612 | ||
1613 | Update: | |
1614 | ||
1615 | PUT/PATCH | |
1616 | + | |
1617 | +-----------+------------+ | |
1618 | | ETag | | |
1619 | | supplied? | | |
1620 | ++-----------------+-----+ | |
1621 | | | | |
1622 | Yes No | |
1623 | | | | |
1624 | +---------------------++ ++-------------+ | |
1625 | | Do preconditions | | Precondition | | |
1626 | | match? | | required? | | |
1627 | +---+-----------------++ ++------------++ | |
1628 | | | | | | |
1629 | Yes No No Yes | |
1630 | | | | | | |
1631 | +----------+------+ +-------+----------+ +---+-----+ | | |
1632 | | Does resource | | 412 Precondition | | 200 OK | | | |
1633 | | exist? | | failed | | Update | | | |
1634 | ++---------------++ +------------------+ +---------+ | | |
1635 | | | +-----------+------+ | |
1636 | Yes No | 428 Precondition | | |
1637 | | | | required | | |
1638 | +----+----+ +----+----+ +------------------+ | |
1639 | | 200 OK | | 404 Not | | |
1640 | | Update | | found | | |
1641 | +---------+ +---------+ | |
1642 | ||
1643 | ||
1644 | Delete: | |
1645 | ||
1646 | DELETE | |
1647 | + | |
1648 | +-----------+------------+ | |
1649 | | ETag | | |
1650 | | supplied? | | |
1651 | ++-----------------+-----+ | |
1652 | | | | |
1653 | Yes No | |
1654 | | | | |
1655 | +---------------------++ ++-------------+ | |
1656 | | Do preconditions | | Precondition | | |
1657 | | match? | | required? | | |
1658 | +---+-----------------++ ++------------++ | |
1659 | | | | | | |
1660 | Yes No No Yes | |
1661 | | | | | | |
1662 | +----------+------+ +-------+----------+ +---+-----+ | | |
1663 | | Does resource | | 412 Precondition | | 204 No | | | |
1664 | | exist? | | failed | | content | | | |
1665 | ++---------------++ +------------------+ +---------+ | | |
1666 | | | +-----------+------+ | |
1667 | Yes No | 428 Precondition | | |
1668 | | | | required | | |
1669 | +----+----+ +----+----+ +------------------+ | |
1670 | | 204 No | | 404 Not | | |
1671 | | content | | found | | |
1672 | +---------+ +---------+ | |
1673 | ||
1674 | ||
1675 | ||
1676 | **Example: transient key construction** | |
1677 | ||
1678 | Here is an example implementation for all (C)RUD methods (except create, because it doesn't need concurrency control) | |
1679 | wrapped with the default `etag` decorator. We use our [previous implementation](#custom-key-bit) of the `UpdatedAtKeyBit` that looks up the cache | |
1680 | for the last timestamp the particular object was updated on the server. This required us to add `post_save` and `post_delete` signals | |
1681 | to our models explicitly. See [below](#apietagmixin) for an example using `@api_etag` and mixins that computes the key from persistent data. | |
1682 | ||
1683 | from rest_framework.viewsets import ModelViewSet | |
1684 | from rest_framework_extensions.key_constructor import bits | |
1685 | from rest_framework_extensions.key_constructor.constructors import ( | |
1686 | KeyConstructor | |
1687 | ) | |
1688 | ||
1689 | from your_app.models import City | |
1690 | # use our own implementation that detects an update timestamp in the cache | |
1691 | from your_app.key_bits import UpdatedAtKeyBit | |
1692 | ||
1693 | class CityListKeyConstructor(KeyConstructor): | |
1694 | format = bits.FormatKeyBit() | |
1695 | language = bits.LanguageKeyBit() | |
1696 | pagination = bits.PaginationKeyBit() | |
1697 | list_sql_query = bits.ListSqlQueryKeyBit() | |
1698 | unique_view_id = bits.UniqueViewIdKeyBit() | |
1699 | ||
1700 | class CityDetailKeyConstructor(KeyConstructor): | |
1701 | format = bits.FormatKeyBit() | |
1702 | language = bits.LanguageKeyBit() | |
1703 | retrieve_sql_query = bits.RetrieveSqlQueryKeyBit() | |
1704 | unique_view_id = bits.UniqueViewIdKeyBit() | |
1705 | updated_at = UpdatedAtKeyBit() | |
1706 | ||
1707 | class CityViewSet(ModelViewSet): | |
1708 | list_key_func = CityListKeyConstructor( | |
1709 | memoize_for_request=True | |
1710 | ) | |
1711 | obj_key_func = CityDetailKeyConstructor( | |
1712 | memoize_for_request=True | |
1713 | ) | |
1714 | ||
1715 | @etag(list_key_func) | |
1716 | @cache_response(key_func=list_key_func) | |
1717 | def list(self, request, *args, **kwargs): | |
1718 | return super(CityViewSet, self).list(request, *args, **kwargs) | |
1719 | ||
1720 | @etag(obj_key_func) | |
1721 | @cache_response(key_func=obj_key_func) | |
1722 | def retrieve(self, request, *args, **kwargs): | |
1723 | return super(CityViewSet, self).retrieve(request, *args, **kwargs) | |
1724 | ||
1725 | @etag(obj_key_func) | |
1726 | def update(self, request, *args, **kwargs): | |
1727 | return super(CityViewSet, self).update(request, *args, **kwargs) | |
1728 | ||
1729 | @etag(obj_key_func) | |
1730 | def destroy(self, request, *args, **kwargs): | |
1731 | return super(CityViewSet, self).destroy(request, *args, **kwargs) | |
1732 | ||
1733 | ||
1734 | #### ETag for unsafe methods | |
1735 | ||
1736 | From previous section you could see that unsafe methods, such `update` (PUT, PATCH) or `destroy` (DELETE), have the same `@etag` | |
1737 | decorator wrapping manner as the safe methods. | |
1738 | ||
1739 | But every unsafe method has one distinction from safe method - it changes the data | |
1740 | which could be used for Etag calculation. In our case it is `UpdatedAtKeyBit`. It means that we should calculate Etag: | |
1741 | ||
1742 | * Before building response - for `If-Match` and `If-None-Match` conditions validation | |
1743 | * After building response (if necessary) - for clients | |
1744 | ||
1745 | `@etag` decorator has special attribute `rebuild_after_method_evaluation`, which by default is `False`. | |
1746 | ||
1747 | If you specify `rebuild_after_method_evaluation` as `True` then Etag will be rebuilt after method evaluation: | |
1748 | ||
1749 | class CityViewSet(ModelViewSet): | |
1750 | ... | |
1751 | @etag(obj_key_func, rebuild_after_method_evaluation=True) | |
1752 | def update(self, request, *args, **kwargs): | |
1753 | return super(CityViewSet, self).update(request, *args, **kwargs) | |
1754 | ||
1755 | @etag(obj_key_func) | |
1756 | def destroy(self, request, *args, **kwargs): | |
1757 | return super(CityViewSet, self).destroy(request, *args, **kwargs) | |
1758 | ||
1759 | # Request | |
1760 | PUT /cities/1/ HTTP/1.1 | |
1761 | Accept: application/json | |
1762 | ||
1763 | {"name": "London"} | |
1764 | ||
1765 | # Response | |
1766 | HTTP/1.1 200 OK | |
1767 | Content-Type: application/json; charset=UTF-8 | |
1768 | ETag: "4e63ef056f47270272b96523f51ad938b5ea141024b767880eac047d10a0b339" | |
1769 | ||
1770 | { | |
1771 | id: 1, | |
1772 | name: "London" | |
1773 | } | |
1774 | ||
1775 | As you can see we didn't specify `rebuild_after_method_evaluation` for `destroy` method. That is because there is no | |
1776 | sense to use returned ETag value on clients if object deletion already performed. | |
1777 | ||
1778 | With `rebuild_after_method_evaluation` parameter Etag calculation for `PUT`/`PATCH` method would look like: | |
1779 | ||
1780 | +--------------+ | |
1781 | | Request | | |
1782 | +--------------+ | |
1783 | | | |
1784 | +--------------------------+ | |
1785 | | Calculate Etag | | |
1786 | | for condition matching | | |
1787 | +--------------------------+ | |
1788 | | | |
1789 | +--------------------+ | |
1790 | | Do preconditions | | |
1791 | | match? | | |
1792 | +--------------------+ | |
1793 | | | | |
1794 | Yes No | |
1795 | | | | |
1796 | +--------------+ +--------------------+ | |
1797 | | Update the | | 412 Precondition | | |
1798 | | resource | | failed | | |
1799 | +--------------+ +--------------------+ | |
1800 | | | |
1801 | +--------------------+ | |
1802 | | Calculate Etag | | |
1803 | | again and add it | | |
1804 | | to response | | |
1805 | +--------------------+ | |
1806 | | | |
1807 | +------------+ | |
1808 | | Return | | |
1809 | | response | | |
1810 | +------------+ | |
1811 | ||
1812 | `If-None-Match` example for `DELETE` method: | |
1813 | ||
1814 | # Request | |
1815 | DELETE /cities/1/ HTTP/1.1 | |
1816 | Accept: application/json | |
1817 | If-None-Match: some_etag_value | |
1818 | ||
1819 | # Response | |
1820 | HTTP/1.1 304 NOT MODIFIED | |
1821 | Content-Type: application/json; charset=UTF-8 | |
1822 | Etag: "some_etag_value" | |
1823 | ||
1824 | ||
1825 | `If-Match` example for `DELETE` method: | |
1826 | ||
1827 | # Request | |
1828 | DELETE /cities/1/ HTTP/1.1 | |
1829 | Accept: application/json | |
1830 | If-Match: another_etag_value | |
1831 | ||
1832 | # Response | |
1833 | HTTP/1.1 412 PRECONDITION FAILED | |
1834 | Content-Type: application/json; charset=UTF-8 | |
1835 | Etag: "some_etag_value" | |
1836 | ||
1837 | ||
1838 | #### ETAGMixin | |
1839 | ||
1840 | It is common to process etags for standard [viewset](http://www.django-rest-framework.org/api-guide/viewsets) | |
1841 | `retrieve`, `list`, `update` and `destroy` methods. | |
1842 | That is why `ETAGMixin` exists. Just mix it into viewset | |
1843 | implementation and those methods will use functions, defined in `REST_FRAMEWORK_EXTENSIONS` [settings](#settings): | |
1844 | ||
1845 | * *"DEFAULT\_OBJECT\_ETAG\_FUNC"* for `retrieve`, `update` and `destroy` methods | |
1846 | * *"DEFAULT\_LIST\_ETAG\_FUNC"* for `list` method | |
1847 | ||
1848 | By default those functions are using [DefaultKeyConstructor](#default-key-constructor) and extends it: | |
1849 | ||
1850 | * With `RetrieveSqlQueryKeyBit` for *"DEFAULT\_OBJECT\_ETAG\_FUNC"* | |
1851 | * With `ListSqlQueryKeyBit` and `PaginationKeyBit` for *"DEFAULT\_LIST\_ETAG\_FUNC"* | |
1852 | ||
1853 | You can change those settings for custom ETag generation: | |
1854 | ||
1855 | REST_FRAMEWORK_EXTENSIONS = { | |
1856 | 'DEFAULT_OBJECT_ETAG_FUNC': | |
1857 | 'rest_framework_extensions.utils.default_object_etag_func', | |
1858 | 'DEFAULT_LIST_ETAG_FUNC': | |
1859 | 'rest_framework_extensions.utils.default_list_etag_func', | |
1860 | } | |
1861 | ||
1862 | Mixin example usage: | |
1863 | ||
1864 | from myapps.serializers import UserSerializer | |
1865 | from rest_framework_extensions.etag.mixins import ETAGMixin | |
1866 | ||
1867 | class UserViewSet(ETAGMixin, viewsets.ModelViewSet): | |
1868 | serializer_class = UserSerializer | |
1869 | ||
1870 | You can change etag function by providing `object_etag_func` or | |
1871 | `list_etag_func` methods in view class: | |
1872 | ||
1873 | class UserViewSet(ETAGMixin, viewsets.ModelViewSet): | |
1874 | serializer_class = UserSerializer | |
1875 | ||
1876 | def object_etag_func(self, **kwargs): | |
1877 | return 'some key for object' | |
1878 | ||
1879 | def list_etag_func(self, **kwargs): | |
1880 | return 'some key for list' | |
1881 | ||
1882 | Of course you can use custom [key constructor](#key-constructor): | |
1883 | ||
1884 | from yourapp.key_constructors import ( | |
1885 | CustomObjectKeyConstructor, | |
1886 | CustomListKeyConstructor, | |
1887 | ) | |
1888 | ||
1889 | class UserViewSet(ETAGMixin, viewsets.ModelViewSet): | |
1890 | serializer_class = UserSerializer | |
1891 | object_etag_func = CustomObjectKeyConstructor() | |
1892 | list_etag_func = CustomListKeyConstructor() | |
1893 | ||
1894 | It is important to note that ETags for unsafe method `update` is processed with parameter | |
1895 | `rebuild_after_method_evaluation` equals `True`. You can read why from [this](#etag-for-unsafe-methods) section. | |
1896 | ||
1897 | There are other mixins for more granular Etag calculation in `rest_framework_extensions.etag.mixins` module: | |
1898 | ||
1899 | * **ReadOnlyETAGMixin** - only for `retrieve` and `list` methods | |
1900 | * **RetrieveETAGMixin** - only for `retrieve` method | |
1901 | * **ListETAGMixin** - only for `list` method | |
1902 | * **DestroyETAGMixin** - only for `destroy` method | |
1903 | * **UpdateETAGMixin** - only for `update` method | |
1904 | ||
1905 | ||
1906 | #### APIETagMixin | |
1907 | ||
1908 | *New in DRF-extensions 0.3.2* | |
1909 | ||
1910 | In analogy to `ETAGMixin` the `APIETAGMixin` exists. Just mix it into DRF viewsets or `APIViews` | |
1911 | and those methods will use the ETag functions, defined in `REST_FRAMEWORK_EXTENSIONS` [settings](#settings): | |
1912 | ||
1913 | * *"DEFAULT\_API\_OBJECT\_ETAG\_FUNC"* for `retrieve`, `update` and `destroy` methods | |
1914 | * *"DEFAULT\_API\_LIST\_ETAG\_FUNC"* for `list` method | |
1915 | ||
1916 | By default those functions are using custom key constructors that create the key from **persisted model attributes**: | |
1917 | ||
1918 | * `RetrieveModelKeyBit` (see [definition](#retrievemodelkeybit)) for *"DEFAULT\_API\_OBJECT\_ETAG\_FUNC"* | |
1919 | * `ListModelKeyBit` (see [definition](#listmodelkeybit)) for *"DEFAULT\_API\_LIST\_ETAG\_FUNC"* | |
1920 | ||
1921 | You can change those settings globally for your custom ETag generation, or use the default values, which should cover | |
1922 | the most common use cases: | |
1923 | ||
1924 | REST_FRAMEWORK_EXTENSIONS = { | |
1925 | 'DEFAULT_API_OBJECT_ETAG_FUNC': | |
1926 | 'rest_framework_extensions.utils.default_api_object_etag_func', | |
1927 | 'DEFAULT_API_LIST_ETAG_FUNC': | |
1928 | 'rest_framework_extensions.utils.default_api_list_etag_func', | |
1929 | } | |
1930 | ||
1931 | Mixin example usage: | |
1932 | ||
1933 | from myapps.serializers import UserSerializer | |
1934 | from rest_framework_extensions.etag.mixins import APIETAGMixin | |
1935 | ||
1936 | class UserViewSet(APIETAGMixin, viewsets.ModelViewSet): | |
1937 | serializer_class = UserSerializer | |
1938 | ||
1939 | You can change etag function by providing `api_object_etag_func` or | |
1940 | `api_list_etag_func` methods in view class: | |
1941 | ||
1942 | class UserViewSet(APIETAGMixin, viewsets.ModelViewSet): | |
1943 | serializer_class = UserSerializer | |
1944 | ||
1945 | def api_object_etag_func(self, **kwargs): | |
1946 | return 'some key for object' | |
1947 | ||
1948 | def api_list_etag_func(self, **kwargs): | |
1949 | return 'some key for list' | |
1950 | ||
1951 | Of course you can use custom [key constructors](#key-constructor): | |
1952 | ||
1953 | from yourapp.key_constructors import ( | |
1954 | CustomObjectKeyConstructor, | |
1955 | CustomListKeyConstructor, | |
1956 | ) | |
1957 | ||
1958 | class UserViewSet(APIETAGMixin, viewsets.ModelViewSet): | |
1959 | serializer_class = UserSerializer | |
1960 | api_object_etag_func = CustomObjectKeyConstructor() | |
1961 | api_list_etag_func = CustomListKeyConstructor() | |
1962 | ||
1963 | There are other mixins for more granular ETag calculation in `rest_framework_extensions.etag.mixins` module: | |
1964 | ||
1965 | * **APIReadOnlyETAGMixin** - only for `retrieve` and `list` methods | |
1966 | * **APIRetrieveETAGMixin** - only for `retrieve` method | |
1967 | * **APIListETAGMixin** - only for `list` method | |
1968 | * **APIDestroyETAGMixin** - only for `destroy` method | |
1969 | * **APIUpdateETAGMixin** - only for `update` method | |
1970 | ||
1971 | By default, all mixins require the conditional requests, i.e. they use the default `precondition_map` from the | |
1972 | `APIETAGProcessor` class. | |
1973 | ||
1974 | ||
1975 | #### Gzipped ETags | |
1976 | ||
1977 | If you use [GZipMiddleware](https://docs.djangoproject.com/en/dev/ref/middleware/#module-django.middleware.gzip) | |
1978 | and your client accepts Gzipped response, then you should return different ETags for compressed and not compressed responses. | |
1979 | That's what `GZipMiddleware` does by default while processing response - | |
1980 | it adds `;gzip` postfix to ETag response header if client requests compressed response. | |
1981 | Lets see it in example. First request without compression: | |
1982 | ||
1983 | # Request | |
1984 | GET /cities/ HTTP/1.1 | |
1985 | Accept: application/json | |
1986 | ||
1987 | # Response | |
1988 | HTTP/1.1 200 OK | |
1989 | Content-Type: application/json; charset=UTF-8 | |
1990 | ETag: "e7b50490dc" | |
1991 | ||
1992 | ['Moscow', 'London', 'Paris'] | |
1993 | ||
1994 | Second request with compression: | |
1995 | ||
1996 | # Request | |
1997 | GET /cities/ HTTP/1.1 | |
1998 | Accept: application/json | |
1999 | Accept-Encoding: gzip | |
2000 | ||
2001 | # Response | |
2002 | HTTP/1.1 200 OK | |
2003 | Content-Type: application/json | |
2004 | Content-Length: 675 | |
2005 | Content-Encoding: gzip | |
2006 | ETag: "e7b50490dc;gzip" | |
2007 | ||
2008 | wS?n?0?_%o?cc?Ҫ?Eʒ?Cժʻ?a\1?a?^T*7q<>[Nvh?[?^9?x:/Ms?79?Fd/???ۦjES?ڽ?&??c%^?C[K۲%N?w{?졭2?m?}?Q&Egz?? | |
2009 | ||
2010 | As you can see there is `;gzip` postfix in ETag response header. | |
2011 | That's ok but there is one caveat - drf-extension doesn't know how you post-processed calculated ETag value. | |
2012 | And your clients could have next problem with conditional request: | |
2013 | ||
2014 | * Client sends request to retrieve compressed data about cities to `/cities/` | |
2015 | * DRF-extensions decorator calculates ETag header for response equals, for example, `123` | |
2016 | * `GZipMiddleware` adds `;gzip` postfix to ETag header response, and now it equals `123;gzip` | |
2017 | * Client retrieves response with ETag equals `123;gzip` | |
2018 | * Client again makes request to retrieve compressed data about cities, | |
2019 | but now it's conditional request with `If-None-Match` header equals `123;gzip` | |
2020 | * DRF-extensions decorator calculates ETag value for processing conditional request. | |
2021 | But it doesn't know, that `GZipMiddleware` added `;gzip` postfix for previous response. | |
2022 | DRF-extensions decorator calculates ETag equals `123`, compares it with `123;gzip` and returns | |
2023 | response with status code 200, because `123` != `123;gzip` | |
2024 | ||
2025 | You can solve this problem by stripping `;gzip` postfix on client side. | |
2026 | ||
2027 | But there are so many libraries that just magically uses ETag response header without allowing to | |
2028 | pre-process conditional requests (for example, browser). If that's you case then you could add custom middleware which removes `;gzip` | |
2029 | postfix from header: | |
2030 | ||
2031 | # yourapp/middleware.py | |
2032 | ||
2033 | class RemoveEtagGzipPostfix(object): | |
2034 | def process_response(self, request, response): | |
2035 | if response.has_header('ETag') and response['ETag'][-6:] == ';gzip"': | |
2036 | response['ETag'] = response['ETag'][:-6] + '"' | |
2037 | return response | |
2038 | ||
2039 | Don't forget to add this middleware in your settings before `GZipMiddleware`: | |
2040 | ||
2041 | # settings.py | |
2042 | MIDDLEWARE_CLASSES = ( | |
2043 | ... | |
2044 | 'yourapp.RemoveEtagGzipPostfix', | |
2045 | 'django.middleware.gzip.GZipMiddleware', | |
2046 | 'django.middleware.common.CommonMiddleware', | |
2047 | ... | |
2048 | ) | |
2049 | ||
1329 | The etag functionality is pending an overhaul has been temporarily removed since 0.4.0. | |
1330 | ||
1331 | See discussion in [Issue #177](https://github.com/chibisov/drf-extensions/issues/177) | |
1332 | ||
1333 | <!--*This documentation section uses information from [RESTful Web Services Cookbook](http://shop.oreilly.com/product/9780596801694.do) 10-th chapter.*--> | |
1334 | ||
1335 | <!--Conditional HTTP requests allow API clients to accomplish 2 goals:--> | |
1336 | ||
1337 | <!--1. Conditional HTTP GET saves client and server [time and bandwidth](#saving-time-and-bandwidth).--> | |
1338 | <!--* For unsafe requests such as PUT, POST, and DELETE, conditional requests provide [concurrency control](#concurrency-control).--> | |
1339 | ||
1340 | <!--The second goal addresses the lost update problem, where a resource is altered and saved by user B while user A is still editing.--> | |
1341 | <!--In both cases, the 'condition' included in the request needs to be a unique identifier (e.g. unique semantic fingerprint) of the requested resource in order to detect changes.--> | |
1342 | <!--This fingerprint can be transient (i.e. using the cache as with `UpdatedAtKeyBit`), or persistent, i.e. computed from the model instance attribute values from the database. --> | |
1343 | <!--While the `UpdatedAtKeyBit` approach requires to add triggers to your models, the semantic fingerprint option is designed to be pluggable and does not require to alter your model code. --> | |
1344 | ||
1345 | ||
1346 | <!--#### HTTP ETag--> | |
1347 | ||
1348 | <!--*An ETag or entity tag, is part of HTTP, the protocol for the World Wide Web. --> | |
1349 | <!--It is one of several mechanisms that HTTP provides for web cache validation, and which allows a client to make conditional requests.* - [Wikipedia](http://en.wikipedia.org/wiki/HTTP_ETag)--> | |
1350 | ||
1351 | <!--For ETag calculation and conditional request processing you should use the decorators from `rest_framework_extensions.etag.decorators`.--> | |
1352 | <!--The `@etag` decorator works similar to the native [django decorator](https://docs.djangoproject.com/en/dev/topics/conditional-view-processing/).--> | |
1353 | ||
1354 | <!--<!-- THIS REFERS TO THE DEFAULT_OBJ_ETAG_FUNC --> | |
1355 | <!--The [default ETag function](#default-etag-function) used by the `@etag` decorator computes the value with respect to the particular view and HTTP method in the request and therefore *cannot detect changes in individual model instances*.--> | |
1356 | <!--If you need to compute the *semantic* fingerprint of a model independent of a particular view and method, implement your custom `etag_func`.--> | |
1357 | <!--Alternatively you could use the `@api_etag` decorator and specify the `viewset` in the view.--> | |
1358 | ||
1359 | ||
1360 | <!-- from rest_framework_extensions.etag.decorators import etag--> | |
1361 | ||
1362 | <!-- class CityView(views.APIView):--> | |
1363 | <!-- @etag()--> | |
1364 | <!-- def get(self, request, *args, **kwargs):--> | |
1365 | <!-- cities = City.objects.all().values_list('name', flat=True)--> | |
1366 | <!-- return Response(cities)--> | |
1367 | ||
1368 | <!--By default `@etag` would calculate the ETag header value with the same algorithm as [cache key](#cache-key) default calculation performs.--> | |
1369 | ||
1370 | <!-- # Request--> | |
1371 | <!-- GET /cities/ HTTP/1.1--> | |
1372 | <!-- Accept: application/json--> | |
1373 | ||
1374 | <!-- # Response--> | |
1375 | <!-- HTTP/1.1 200 OK--> | |
1376 | <!-- Content-Type: application/json; charset=UTF-8--> | |
1377 | <!-- ETag: "e7b50490dc546d116635a14cfa58110306dd6c5434146b6740ec08bf0a78f9a2"--> | |
1378 | ||
1379 | <!-- ['Moscow', 'London', 'Paris']--> | |
1380 | ||
1381 | <!--You can define a custom function for Etag value calculation with `etag_func` argument:--> | |
1382 | ||
1383 | <!-- from rest_framework_extensions.etag.decorators import etag--> | |
1384 | ||
1385 | <!-- def calculate_etag(view_instance, view_method,--> | |
1386 | <!-- request, args, kwargs):--> | |
1387 | <!-- return '.'.join([--> | |
1388 | <!-- len(args),--> | |
1389 | <!-- len(kwargs)--> | |
1390 | <!-- ])--> | |
1391 | ||
1392 | <!-- class CityView(views.APIView):--> | |
1393 | <!-- @etag(etag_func=calculate_etag)--> | |
1394 | <!-- def get(self, request, *args, **kwargs):--> | |
1395 | <!-- cities = City.objects.all().values_list('name', flat=True)--> | |
1396 | <!-- return Response(cities)--> | |
1397 | ||
1398 | ||
1399 | <!--You can implement a view method and use it for Etag calculation by specifying `etag_func` argument as string:--> | |
1400 | ||
1401 | <!-- from rest_framework_extensions.etag.decorators import etag--> | |
1402 | ||
1403 | <!-- class CityView(views.APIView):--> | |
1404 | <!-- @etag(etag_func='calculate_etag_from_method')--> | |
1405 | <!-- def get(self, request, *args, **kwargs):--> | |
1406 | <!-- cities = City.objects.all().values_list('name', flat=True)--> | |
1407 | <!-- return Response(cities)--> | |
1408 | ||
1409 | <!-- def calculate_etag_from_method(self, view_instance, view_method,--> | |
1410 | <!-- request, args, kwargs):--> | |
1411 | <!-- return '.'.join([--> | |
1412 | <!-- len(args),--> | |
1413 | <!-- len(kwargs)--> | |
1414 | <!-- ])--> | |
1415 | ||
1416 | <!--ETag calculation function will be called with following parameters:--> | |
1417 | ||
1418 | <!--* **view_instance** - view instance of decorated method--> | |
1419 | <!--* **view_method** - decorated method--> | |
1420 | <!--* **request** - decorated method request--> | |
1421 | <!--* **args** - decorated method positional arguments--> | |
1422 | <!--* **kwargs** - decorated method keyword arguments--> | |
1423 | ||
1424 | ||
1425 | <!--#### Default ETag function--> | |
1426 | ||
1427 | <!--If `@etag` decorator used without `etag_func` argument then default etag function will be used. You can change this function in--> | |
1428 | <!--settings:--> | |
1429 | ||
1430 | <!-- REST_FRAMEWORK_EXTENSIONS = {--> | |
1431 | <!-- 'DEFAULT_ETAG_FUNC':--> | |
1432 | <!-- 'rest_framework_extensions.utils.default_etag_func'--> | |
1433 | <!-- }--> | |
1434 | ||
1435 | <!--`default_etag_func` uses [DefaultKeyConstructor](#default-key-constructor) as a base for etag calculation.--> | |
1436 | ||
1437 | ||
1438 | <!--#### API ETag function--> | |
1439 | <!--<!-- This refers to the APIETagProcessor and @api_etag decorator --> | |
1440 | ||
1441 | <!--*New in DRF-extensions 0.3.2*--> | |
1442 | ||
1443 | <!--In addition, `APIETAGProcessor` explicitly requires a function that (ideally) creates an ETag value from model instances.--> | |
1444 | <!--If the `@api_etag` decorator is used without `etag_func` the framework will raise an `AssertionError`. --> | |
1445 | <!--Hence, the following snipped would not work:--> | |
1446 | ||
1447 | <!-- # BEGIN BAD CODE:--> | |
1448 | <!-- class View(views.APIView):--> | |
1449 | <!-- @api_etag() --> | |
1450 | <!-- def get(self, request, *args, **kwargs):--> | |
1451 | <!-- return super(View, self).get(request, *args, **kwargs)--> | |
1452 | <!-- # END BAD CODE--> | |
1453 | ||
1454 | <!--**Why's that?**--> | |
1455 | <!--It does not make sense to compute a default ETag here, because the processor would lock us out from the API by always issuing a `304` --> | |
1456 | <!--response on conditional requests, even if the resource was modified meanwhile.--> | |
1457 | <!--Therefore the `APIETAGProcessor` cannot be used without specifying an `etag_func` as keyword argument and there exists convenient --> | |
1458 | <!--[mixin classes](#apietagmixin).--> | |
1459 | ||
1460 | <!--You can use the decorator in regular `APIView`, and subclasses from the `rest_framework.generics` module, --> | |
1461 | <!--but ensure to include a `queryset` attribute or override `get_queryset()`:--> | |
1462 | ||
1463 | <!-- from rest_framework import generics--> | |
1464 | <!-- from rest_framework.response import Response--> | |
1465 | <!-- from rest_framework_extensions.utils import default_api_object_etag_func--> | |
1466 | <!-- from my_app.models import Book--> | |
1467 | ||
1468 | <!-- class BookCustomDestroyView(generics.DestroyAPIView): --> | |
1469 | <!-- # include the queryset here to enable the object lookup --> | |
1470 | <!-- queryset = Book.objects.all()--> | |
1471 | ||
1472 | <!-- @api_etag(etag_func=default_api_object_etag_func)--> | |
1473 | <!-- def delete(self, request, *args, **kwargs):--> | |
1474 | <!-- obj = Book.objects.get(id=kwargs['pk'])--> | |
1475 | <!-- # ... perform some custom operations here ...--> | |
1476 | <!-- obj.delete()--> | |
1477 | <!-- return Response(status=status.HTTP_204_NO_CONTENT)--> | |
1478 | ||
1479 | ||
1480 | <!--The next difference to the `@etag` decorator is that it defines an explicit map of --> | |
1481 | <!--required headers for each HTTP request verb, using the following default values for unsafe methods:--> | |
1482 | ||
1483 | <!-- precondition_map = {'PUT': ['If-Match'],--> | |
1484 | <!-- 'PATCH': ['If-Match'],--> | |
1485 | <!-- 'DELETE': ['If-Match']}--> | |
1486 | ||
1487 | <!--You can specify a custom set of headers in the decorator by passing the `precondition_map` keyword argument.--> | |
1488 | <!--For instance, this statement--> | |
1489 | <!-- @api_etag(etag_func=default_api_object_etag_func, precondition_map={'PUT': ['X-mycorp-custom']})--> | |
1490 | <!-- def put(self, request, *args, **kwargs):--> | |
1491 | <!-- obj = Book.objects.get(id=kwargs['pk'])--> | |
1492 | <!-- # ... perform some custom operations here ...--> | |
1493 | <!-- obj.save()--> | |
1494 | <!-- return Response(status=status.HTTP_200_OK)--> | |
1495 | ||
1496 | <!--checks for the presence of a custom header `X-mycorp-custom` in the request and permits the request, if it is present, --> | |
1497 | <!--or returns a `428 PRECONDITION REQUIRED` response.--> | |
1498 | ||
1499 | <!--Similarly, to disable all checks for a particular method simply pass an empty dict:--> | |
1500 | ||
1501 | <!-- @api_etag(etag_func=default_api_object_etag_func, precondition_map={})--> | |
1502 | <!-- def put(self, request, *args, **kwargs):--> | |
1503 | <!-- obj = Book.objects.get(id=kwargs['pk'])--> | |
1504 | <!-- # ... perform some custom operations here ...--> | |
1505 | <!-- obj.save()--> | |
1506 | <!-- return Response(status=status.HTTP_200_OK)--> | |
1507 | ||
1508 | <!--Please note that passing `None` in the `precondition_map` argument falls back to using the default map.--> | |
1509 | ||
1510 | <!--#### Usage ETag with caching--> | |
1511 | ||
1512 | <!--As you can see `@etag` and `@cache_response` decorators has similar key calculation approaches. --> | |
1513 | <!--They both can take key from simple callable function. And more than this - in many cases they share the same calculation logic. --> | |
1514 | <!--In the next example we use both decorators, which share one calculation function:--> | |
1515 | ||
1516 | <!-- from rest_framework_extensions.etag.decorators import etag--> | |
1517 | <!-- from rest_framework_extensions.cache.decorators import cache_response--> | |
1518 | <!-- from rest_framework_extensions.key_constructor import bits--> | |
1519 | <!-- from rest_framework_extensions.key_constructor.constructors import (--> | |
1520 | <!-- KeyConstructor--> | |
1521 | <!-- )--> | |
1522 | ||
1523 | <!-- class CityGetKeyConstructor(KeyConstructor):--> | |
1524 | <!-- format = bits.FormatKeyBit()--> | |
1525 | <!-- language = bits.LanguageKeyBit()--> | |
1526 | ||
1527 | <!-- class CityView(views.APIView):--> | |
1528 | <!-- key_constructor_func = CityGetKeyConstructor()--> | |
1529 | ||
1530 | <!-- @etag(key_constructor_func)--> | |
1531 | <!-- @cache_response(key_func=key_constructor_func)--> | |
1532 | <!-- def get(self, request, *args, **kwargs):--> | |
1533 | <!-- cities = City.objects.all().values_list('name', flat=True)--> | |
1534 | <!-- return Response(cities)--> | |
1535 | ||
1536 | <!--Note the decorators order. First goes `@etag` and then goes `@cache_response`. We want firstly perform conditional processing and after it response processing.--> | |
1537 | ||
1538 | <!--There is one more point for it. If conditional processing didn't fail then `key_constructor_func` would be called again in `@cache_response`.--> | |
1539 | <!--But in most cases first calculation is enough. To accomplish this goal you could use `KeyConstructor` initial argument `memoize_for_request`:--> | |
1540 | ||
1541 | <!-- >>> key_constructor_func = CityGetKeyConstructor(memoize_for_request=True)--> | |
1542 | <!-- >>> request1, request1 = 'request1', 'request2'--> | |
1543 | <!-- >>> print key_constructor_func(request=request1) # full calculation--> | |
1544 | <!-- request1-key--> | |
1545 | <!-- >>> print key_constructor_func(request=request1) # data from cache--> | |
1546 | <!-- request1-key--> | |
1547 | <!-- >>> print key_constructor_func(request=request2) # full calculation--> | |
1548 | <!-- request2-key--> | |
1549 | <!-- >>> print key_constructor_func(request=request2) # data from cache--> | |
1550 | <!-- request2-key--> | |
1551 | ||
1552 | <!--By default `memoize_for_request` is `False`, but you can change it in settings:--> | |
1553 | ||
1554 | <!-- REST_FRAMEWORK_EXTENSIONS = {--> | |
1555 | <!-- 'DEFAULT_KEY_CONSTRUCTOR_MEMOIZE_FOR_REQUEST': True--> | |
1556 | <!-- }--> | |
1557 | ||
1558 | ||
1559 | <!--It's important to note that this memoization is thread safe.--> | |
1560 | ||
1561 | <!--#### Saving time and bandwidth--> | |
1562 | ||
1563 | <!--When a server returns `ETag` header, you should store it along with the representation data on the client.--> | |
1564 | <!--When making GET and HEAD requests for the same resource in the future, include the `If-None-Match` header--> | |
1565 | <!--to make these requests "conditional". --> | |
1566 | ||
1567 | <!--For example, retrieve all cities:--> | |
1568 | ||
1569 | <!-- # Request--> | |
1570 | <!-- GET /cities/ HTTP/1.1--> | |
1571 | <!-- Accept: application/json--> | |
1572 | ||
1573 | <!-- # Response--> | |
1574 | <!-- HTTP/1.1 200 OK--> | |
1575 | <!-- Content-Type: application/json; charset=UTF-8--> | |
1576 | <!-- ETag: "some_etag_value"--> | |
1577 | ||
1578 | <!-- ['Moscow', 'London', 'Paris']--> | |
1579 | ||
1580 | <!--If you make same request with `If-None-Match` and there exists a cached value for this request,--> | |
1581 | <!--then server will respond with `304` status code without body data.--> | |
1582 | ||
1583 | <!-- # Request--> | |
1584 | <!-- GET /cities/ HTTP/1.1--> | |
1585 | <!-- Accept: application/json--> | |
1586 | <!-- If-None-Match: some_etag_value--> | |
1587 | ||
1588 | <!-- # Response--> | |
1589 | <!-- HTTP/1.1 304 NOT MODIFIED--> | |
1590 | <!-- Content-Type: application/json; charset=UTF-8--> | |
1591 | <!-- Etag: "some_etag_value"--> | |
1592 | ||
1593 | <!--After this response you can use existing cities data on the client. --> | |
1594 | ||
1595 | ||
1596 | ||
1597 | ||
1598 | ||
1599 | ||
1600 | ||
1601 | ||
1602 | <!--#### Concurrency control--> | |
1603 | ||
1604 | <!--Concurrency control ensures the correct processing of data under concurrent operations by clients.--> | |
1605 | <!--There are two ways to implement concurrency control:--> | |
1606 | ||
1607 | <!--* **Pessimistic concurrency control**. In this model, the client gets a lock, obtains--> | |
1608 | <!--the current state of the resource, makes modifications, and then releases the lock.--> | |
1609 | <!--During this process, the server prevents other clients from acquiring a lock on the same resource.--> | |
1610 | <!--Relational databases operate in this manner.--> | |
1611 | <!--* **Optimistic concurrency control**. In this model, the client first gets a token.--> | |
1612 | <!--Instead of obtaining a lock, the client attempts a write operation with the token included in the request.--> | |
1613 | <!--The operation succeeds if the token is still valid and fails otherwise.--> | |
1614 | ||
1615 | <!--HTTP, being a stateless application control, is designed for optimistic concurrency control.--> | |
1616 | <!--According to [RFC 6585](https://tools.ietf.org/html/rfc6585), the server can optionally require --> | |
1617 | <!--a condition for a request. This library returns a `428` status, if no ETag is supplied, but would be mandatory --> | |
1618 | <!--for a request to succeed.--> | |
1619 | ||
1620 | <!--Update:--> | |
1621 | ||
1622 | <!-- PUT/PATCH--> | |
1623 | <!-- +--> | |
1624 | <!-- +-----------+------------+--> | |
1625 | <!-- | ETag |--> | |
1626 | <!-- | supplied? |--> | |
1627 | <!-- ++-----------------+-----+--> | |
1628 | <!-- | |--> | |
1629 | <!-- Yes No--> | |
1630 | <!-- | |--> | |
1631 | <!-- +---------------------++ ++-------------+--> | |
1632 | <!-- | Do preconditions | | Precondition |--> | |
1633 | <!-- | match? | | required? |--> | |
1634 | <!-- +---+-----------------++ ++------------++--> | |
1635 | <!-- | | | |--> | |
1636 | <!-- Yes No No Yes--> | |
1637 | <!-- | | | |--> | |
1638 | <!-- +----------+------+ +-------+----------+ +---+-----+ |--> | |
1639 | <!-- | Does resource | | 412 Precondition | | 200 OK | |--> | |
1640 | <!-- | exist? | | failed | | Update | |--> | |
1641 | <!-- ++---------------++ +------------------+ +---------+ |--> | |
1642 | <!-- | | +-----------+------+--> | |
1643 | <!-- Yes No | 428 Precondition |--> | |
1644 | <!-- | | | required |--> | |
1645 | <!-- +----+----+ +----+----+ +------------------+--> | |
1646 | <!-- | 200 OK | | 404 Not |--> | |
1647 | <!-- | Update | | found |--> | |
1648 | <!-- +---------+ +---------+--> | |
1649 | ||
1650 | ||
1651 | <!--Delete:--> | |
1652 | ||
1653 | <!-- DELETE--> | |
1654 | <!-- +--> | |
1655 | <!-- +-----------+------------+--> | |
1656 | <!-- | ETag |--> | |
1657 | <!-- | supplied? |--> | |
1658 | <!-- ++-----------------+-----+--> | |
1659 | <!-- | |--> | |
1660 | <!-- Yes No--> | |
1661 | <!-- | |--> | |
1662 | <!-- +---------------------++ ++-------------+--> | |
1663 | <!-- | Do preconditions | | Precondition |--> | |
1664 | <!-- | match? | | required? |--> | |
1665 | <!-- +---+-----------------++ ++------------++--> | |
1666 | <!-- | | | |--> | |
1667 | <!-- Yes No No Yes--> | |
1668 | <!-- | | | |--> | |
1669 | <!-- +----------+------+ +-------+----------+ +---+-----+ |--> | |
1670 | <!-- | Does resource | | 412 Precondition | | 204 No | |--> | |
1671 | <!-- | exist? | | failed | | content | |--> | |
1672 | <!-- ++---------------++ +------------------+ +---------+ |--> | |
1673 | <!-- | | +-----------+------+--> | |
1674 | <!-- Yes No | 428 Precondition |--> | |
1675 | <!-- | | | required |--> | |
1676 | <!-- +----+----+ +----+----+ +------------------+--> | |
1677 | <!-- | 204 No | | 404 Not |--> | |
1678 | <!-- | content | | found |--> | |
1679 | <!-- +---------+ +---------+--> | |
1680 | ||
1681 | ||
1682 | ||
1683 | <!--**Example: transient key construction**--> | |
1684 | ||
1685 | <!--Here is an example implementation for all (C)RUD methods (except create, because it doesn't need concurrency control)--> | |
1686 | <!--wrapped with the default `etag` decorator. We use our [previous implementation](#custom-key-bit) of the `UpdatedAtKeyBit` that looks up the cache --> | |
1687 | <!--for the last timestamp the particular object was updated on the server. This required us to add `post_save` and `post_delete` signals--> | |
1688 | <!--to our models explicitly. See [below](#apietagmixin) for an example using `@api_etag` and mixins that computes the key from persistent data.--> | |
1689 | ||
1690 | <!-- from rest_framework.viewsets import ModelViewSet--> | |
1691 | <!-- from rest_framework_extensions.key_constructor import bits--> | |
1692 | <!-- from rest_framework_extensions.key_constructor.constructors import (--> | |
1693 | <!-- KeyConstructor--> | |
1694 | <!-- )--> | |
1695 | ||
1696 | <!-- from your_app.models import City--> | |
1697 | <!-- # use our own implementation that detects an update timestamp in the cache --> | |
1698 | <!-- from your_app.key_bits import UpdatedAtKeyBit--> | |
1699 | ||
1700 | <!-- class CityListKeyConstructor(KeyConstructor):--> | |
1701 | <!-- format = bits.FormatKeyBit()--> | |
1702 | <!-- language = bits.LanguageKeyBit()--> | |
1703 | <!-- pagination = bits.PaginationKeyBit()--> | |
1704 | <!-- list_sql_query = bits.ListSqlQueryKeyBit()--> | |
1705 | <!-- unique_view_id = bits.UniqueViewIdKeyBit()--> | |
1706 | ||
1707 | <!-- class CityDetailKeyConstructor(KeyConstructor):--> | |
1708 | <!-- format = bits.FormatKeyBit()--> | |
1709 | <!-- language = bits.LanguageKeyBit()--> | |
1710 | <!-- retrieve_sql_query = bits.RetrieveSqlQueryKeyBit()--> | |
1711 | <!-- unique_view_id = bits.UniqueViewIdKeyBit()--> | |
1712 | <!-- updated_at = UpdatedAtKeyBit()--> | |
1713 | ||
1714 | <!-- class CityViewSet(ModelViewSet):--> | |
1715 | <!-- list_key_func = CityListKeyConstructor(--> | |
1716 | <!-- memoize_for_request=True--> | |
1717 | <!-- )--> | |
1718 | <!-- obj_key_func = CityDetailKeyConstructor(--> | |
1719 | <!-- memoize_for_request=True--> | |
1720 | <!-- )--> | |
1721 | ||
1722 | <!-- @etag(list_key_func)--> | |
1723 | <!-- @cache_response(key_func=list_key_func)--> | |
1724 | <!-- def list(self, request, *args, **kwargs):--> | |
1725 | <!-- return super(CityViewSet, self).list(request, *args, **kwargs)--> | |
1726 | ||
1727 | <!-- @etag(obj_key_func)--> | |
1728 | <!-- @cache_response(key_func=obj_key_func)--> | |
1729 | <!-- def retrieve(self, request, *args, **kwargs):--> | |
1730 | <!-- return super(CityViewSet, self).retrieve(request, *args, **kwargs)--> | |
1731 | ||
1732 | <!-- @etag(obj_key_func)--> | |
1733 | <!-- def update(self, request, *args, **kwargs):--> | |
1734 | <!-- return super(CityViewSet, self).update(request, *args, **kwargs)--> | |
1735 | ||
1736 | <!-- @etag(obj_key_func)--> | |
1737 | <!-- def destroy(self, request, *args, **kwargs):--> | |
1738 | <!-- return super(CityViewSet, self).destroy(request, *args, **kwargs)--> | |
1739 | ||
1740 | ||
1741 | <!--#### ETag for unsafe methods--> | |
1742 | ||
1743 | <!--From previous section you could see that unsafe methods, such `update` (PUT, PATCH) or `destroy` (DELETE), have the same `@etag`--> | |
1744 | <!--decorator wrapping manner as the safe methods.--> | |
1745 | ||
1746 | <!--But every unsafe method has one distinction from safe method - it changes the data--> | |
1747 | <!--which could be used for Etag calculation. In our case it is `UpdatedAtKeyBit`. It means that we should calculate Etag:--> | |
1748 | ||
1749 | <!--* Before building response - for `If-Match` and `If-None-Match` conditions validation--> | |
1750 | <!--* After building response (if necessary) - for clients--> | |
1751 | ||
1752 | <!--`@etag` decorator has special attribute `rebuild_after_method_evaluation`, which by default is `False`.--> | |
1753 | ||
1754 | <!--If you specify `rebuild_after_method_evaluation` as `True` then Etag will be rebuilt after method evaluation:--> | |
1755 | ||
1756 | <!-- class CityViewSet(ModelViewSet):--> | |
1757 | <!-- ...--> | |
1758 | <!-- @etag(obj_key_func, rebuild_after_method_evaluation=True)--> | |
1759 | <!-- def update(self, request, *args, **kwargs):--> | |
1760 | <!-- return super(CityViewSet, self).update(request, *args, **kwargs)--> | |
1761 | ||
1762 | <!-- @etag(obj_key_func)--> | |
1763 | <!-- def destroy(self, request, *args, **kwargs):--> | |
1764 | <!-- return super(CityViewSet, self).destroy(request, *args, **kwargs)--> | |
1765 | ||
1766 | <!-- # Request--> | |
1767 | <!-- PUT /cities/1/ HTTP/1.1--> | |
1768 | <!-- Accept: application/json--> | |
1769 | ||
1770 | <!-- {"name": "London"}--> | |
1771 | ||
1772 | <!-- # Response--> | |
1773 | <!-- HTTP/1.1 200 OK--> | |
1774 | <!-- Content-Type: application/json; charset=UTF-8--> | |
1775 | <!-- ETag: "4e63ef056f47270272b96523f51ad938b5ea141024b767880eac047d10a0b339"--> | |
1776 | ||
1777 | <!-- {--> | |
1778 | <!-- id: 1,--> | |
1779 | <!-- name: "London"--> | |
1780 | <!-- }--> | |
1781 | ||
1782 | <!--As you can see we didn't specify `rebuild_after_method_evaluation` for `destroy` method. That is because there is no--> | |
1783 | <!--sense to use returned ETag value on clients if object deletion already performed.--> | |
1784 | ||
1785 | <!--With `rebuild_after_method_evaluation` parameter Etag calculation for `PUT`/`PATCH` method would look like:--> | |
1786 | ||
1787 | <!-- +--------------+--> | |
1788 | <!-- | Request |--> | |
1789 | <!-- +--------------+--> | |
1790 | <!-- |--> | |
1791 | <!-- +--------------------------+--> | |
1792 | <!-- | Calculate Etag |--> | |
1793 | <!-- | for condition matching |--> | |
1794 | <!-- +--------------------------+--> | |
1795 | <!-- |--> | |
1796 | <!-- +--------------------+--> | |
1797 | <!-- | Do preconditions |--> | |
1798 | <!-- | match? |--> | |
1799 | <!-- +--------------------+--> | |
1800 | <!-- | |--> | |
1801 | <!-- Yes No--> | |
1802 | <!-- | |--> | |
1803 | <!-- +--------------+ +--------------------+--> | |
1804 | <!-- | Update the | | 412 Precondition |--> | |
1805 | <!-- | resource | | failed |--> | |
1806 | <!-- +--------------+ +--------------------+--> | |
1807 | <!-- |--> | |
1808 | <!-- +--------------------+--> | |
1809 | <!-- | Calculate Etag |--> | |
1810 | <!-- | again and add it |--> | |
1811 | <!-- | to response |--> | |
1812 | <!-- +--------------------+--> | |
1813 | <!-- |--> | |
1814 | <!-- +------------+--> | |
1815 | <!-- | Return |--> | |
1816 | <!-- | response |--> | |
1817 | <!-- +------------+--> | |
1818 | ||
1819 | <!--`If-None-Match` example for `DELETE` method:--> | |
1820 | ||
1821 | <!-- # Request--> | |
1822 | <!-- DELETE /cities/1/ HTTP/1.1--> | |
1823 | <!-- Accept: application/json--> | |
1824 | <!-- If-None-Match: some_etag_value--> | |
1825 | ||
1826 | <!-- # Response--> | |
1827 | <!-- HTTP/1.1 304 NOT MODIFIED--> | |
1828 | <!-- Content-Type: application/json; charset=UTF-8--> | |
1829 | <!-- Etag: "some_etag_value"--> | |
1830 | ||
1831 | ||
1832 | <!--`If-Match` example for `DELETE` method:--> | |
1833 | ||
1834 | <!-- # Request--> | |
1835 | <!-- DELETE /cities/1/ HTTP/1.1--> | |
1836 | <!-- Accept: application/json--> | |
1837 | <!-- If-Match: another_etag_value--> | |
1838 | ||
1839 | <!-- # Response--> | |
1840 | <!-- HTTP/1.1 412 PRECONDITION FAILED--> | |
1841 | <!-- Content-Type: application/json; charset=UTF-8--> | |
1842 | <!-- Etag: "some_etag_value"--> | |
1843 | ||
1844 | ||
1845 | <!--#### ETAGMixin--> | |
1846 | ||
1847 | <!--It is common to process etags for standard [viewset](http://www.django-rest-framework.org/api-guide/viewsets)--> | |
1848 | <!--`retrieve`, `list`, `update` and `destroy` methods.--> | |
1849 | <!--That is why `ETAGMixin` exists. Just mix it into viewset--> | |
1850 | <!--implementation and those methods will use functions, defined in `REST_FRAMEWORK_EXTENSIONS` [settings](#settings):--> | |
1851 | ||
1852 | <!--* *"DEFAULT\_OBJECT\_ETAG\_FUNC"* for `retrieve`, `update` and `destroy` methods--> | |
1853 | <!--* *"DEFAULT\_LIST\_ETAG\_FUNC"* for `list` method--> | |
1854 | ||
1855 | <!--By default those functions are using [DefaultKeyConstructor](#default-key-constructor) and extends it:--> | |
1856 | ||
1857 | <!--* With `RetrieveSqlQueryKeyBit` for *"DEFAULT\_OBJECT\_ETAG\_FUNC"*--> | |
1858 | <!--* With `ListSqlQueryKeyBit` and `PaginationKeyBit` for *"DEFAULT\_LIST\_ETAG\_FUNC"*--> | |
1859 | ||
1860 | <!--You can change those settings for custom ETag generation:--> | |
1861 | ||
1862 | <!-- REST_FRAMEWORK_EXTENSIONS = {--> | |
1863 | <!-- 'DEFAULT_OBJECT_ETAG_FUNC':--> | |
1864 | <!-- 'rest_framework_extensions.utils.default_object_etag_func',--> | |
1865 | <!-- 'DEFAULT_LIST_ETAG_FUNC':--> | |
1866 | <!-- 'rest_framework_extensions.utils.default_list_etag_func',--> | |
1867 | <!-- }--> | |
1868 | ||
1869 | <!--Mixin example usage:--> | |
1870 | ||
1871 | <!-- from myapps.serializers import UserSerializer--> | |
1872 | <!-- from rest_framework_extensions.etag.mixins import ETAGMixin--> | |
1873 | ||
1874 | <!-- class UserViewSet(ETAGMixin, viewsets.ModelViewSet):--> | |
1875 | <!-- serializer_class = UserSerializer--> | |
1876 | ||
1877 | <!--You can change etag function by providing `object_etag_func` or--> | |
1878 | <!--`list_etag_func` methods in view class:--> | |
1879 | ||
1880 | <!-- class UserViewSet(ETAGMixin, viewsets.ModelViewSet):--> | |
1881 | <!-- serializer_class = UserSerializer--> | |
1882 | ||
1883 | <!-- def object_etag_func(self, **kwargs):--> | |
1884 | <!-- return 'some key for object'--> | |
1885 | ||
1886 | <!-- def list_etag_func(self, **kwargs):--> | |
1887 | <!-- return 'some key for list'--> | |
1888 | ||
1889 | <!--Of course you can use custom [key constructor](#key-constructor):--> | |
1890 | ||
1891 | <!-- from yourapp.key_constructors import (--> | |
1892 | <!-- CustomObjectKeyConstructor,--> | |
1893 | <!-- CustomListKeyConstructor,--> | |
1894 | <!-- )--> | |
1895 | ||
1896 | <!-- class UserViewSet(ETAGMixin, viewsets.ModelViewSet):--> | |
1897 | <!-- serializer_class = UserSerializer--> | |
1898 | <!-- object_etag_func = CustomObjectKeyConstructor()--> | |
1899 | <!-- list_etag_func = CustomListKeyConstructor()--> | |
1900 | ||
1901 | <!--It is important to note that ETags for unsafe method `update` is processed with parameter--> | |
1902 | <!--`rebuild_after_method_evaluation` equals `True`. You can read why from [this](#etag-for-unsafe-methods) section.--> | |
1903 | ||
1904 | <!--There are other mixins for more granular Etag calculation in `rest_framework_extensions.etag.mixins` module:--> | |
1905 | ||
1906 | <!--* **ReadOnlyETAGMixin** - only for `retrieve` and `list` methods--> | |
1907 | <!--* **RetrieveETAGMixin** - only for `retrieve` method--> | |
1908 | <!--* **ListETAGMixin** - only for `list` method--> | |
1909 | <!--* **DestroyETAGMixin** - only for `destroy` method--> | |
1910 | <!--* **UpdateETAGMixin** - only for `update` method--> | |
1911 | ||
1912 | ||
1913 | <!--#### APIETagMixin--> | |
1914 | ||
1915 | <!--*New in DRF-extensions 0.3.2*--> | |
1916 | ||
1917 | <!--In analogy to `ETAGMixin` the `APIETAGMixin` exists. Just mix it into DRF viewsets or `APIViews` --> | |
1918 | <!--and those methods will use the ETag functions, defined in `REST_FRAMEWORK_EXTENSIONS` [settings](#settings):--> | |
1919 | ||
1920 | <!--* *"DEFAULT\_API\_OBJECT\_ETAG\_FUNC"* for `retrieve`, `update` and `destroy` methods--> | |
1921 | <!--* *"DEFAULT\_API\_LIST\_ETAG\_FUNC"* for `list` method--> | |
1922 | ||
1923 | <!--By default those functions are using custom key constructors that create the key from **persisted model attributes**:--> | |
1924 | ||
1925 | <!--* `RetrieveModelKeyBit` (see [definition](#retrievemodelkeybit)) for *"DEFAULT\_API\_OBJECT\_ETAG\_FUNC"*--> | |
1926 | <!--* `ListModelKeyBit` (see [definition](#listmodelkeybit)) for *"DEFAULT\_API\_LIST\_ETAG\_FUNC"*--> | |
1927 | ||
1928 | <!--You can change those settings globally for your custom ETag generation, or use the default values, which should cover --> | |
1929 | <!--the most common use cases:--> | |
1930 | ||
1931 | <!-- REST_FRAMEWORK_EXTENSIONS = {--> | |
1932 | <!-- 'DEFAULT_API_OBJECT_ETAG_FUNC': --> | |
1933 | <!-- 'rest_framework_extensions.utils.default_api_object_etag_func',--> | |
1934 | <!-- 'DEFAULT_API_LIST_ETAG_FUNC': --> | |
1935 | <!-- 'rest_framework_extensions.utils.default_api_list_etag_func',--> | |
1936 | <!-- }--> | |
1937 | ||
1938 | <!--Mixin example usage:--> | |
1939 | ||
1940 | <!-- from myapps.serializers import UserSerializer--> | |
1941 | <!-- from rest_framework_extensions.etag.mixins import APIETAGMixin--> | |
1942 | ||
1943 | <!-- class UserViewSet(APIETAGMixin, viewsets.ModelViewSet):--> | |
1944 | <!-- serializer_class = UserSerializer--> | |
1945 | ||
1946 | <!--You can change etag function by providing `api_object_etag_func` or--> | |
1947 | <!--`api_list_etag_func` methods in view class:--> | |
1948 | ||
1949 | <!-- class UserViewSet(APIETAGMixin, viewsets.ModelViewSet):--> | |
1950 | <!-- serializer_class = UserSerializer--> | |
1951 | ||
1952 | <!-- def api_object_etag_func(self, **kwargs):--> | |
1953 | <!-- return 'some key for object'--> | |
1954 | ||
1955 | <!-- def api_list_etag_func(self, **kwargs):--> | |
1956 | <!-- return 'some key for list'--> | |
1957 | ||
1958 | <!--Of course you can use custom [key constructors](#key-constructor):--> | |
1959 | ||
1960 | <!-- from yourapp.key_constructors import (--> | |
1961 | <!-- CustomObjectKeyConstructor,--> | |
1962 | <!-- CustomListKeyConstructor,--> | |
1963 | <!-- )--> | |
1964 | ||
1965 | <!-- class UserViewSet(APIETAGMixin, viewsets.ModelViewSet):--> | |
1966 | <!-- serializer_class = UserSerializer--> | |
1967 | <!-- api_object_etag_func = CustomObjectKeyConstructor()--> | |
1968 | <!-- api_list_etag_func = CustomListKeyConstructor()--> | |
1969 | ||
1970 | <!--There are other mixins for more granular ETag calculation in `rest_framework_extensions.etag.mixins` module:--> | |
1971 | ||
1972 | <!--* **APIReadOnlyETAGMixin** - only for `retrieve` and `list` methods--> | |
1973 | <!--* **APIRetrieveETAGMixin** - only for `retrieve` method--> | |
1974 | <!--* **APIListETAGMixin** - only for `list` method--> | |
1975 | <!--* **APIDestroyETAGMixin** - only for `destroy` method--> | |
1976 | <!--* **APIUpdateETAGMixin** - only for `update` method--> | |
1977 | ||
1978 | <!--By default, all mixins require the conditional requests, i.e. they use the default `precondition_map` from the --> | |
1979 | <!--`APIETAGProcessor` class.--> | |
1980 | ||
1981 | ||
1982 | <!--#### Gzipped ETags--> | |
1983 | ||
1984 | <!--If you use [GZipMiddleware](https://docs.djangoproject.com/en/dev/ref/middleware/#module-django.middleware.gzip)--> | |
1985 | <!--and your client accepts Gzipped response, then you should return different ETags for compressed and not compressed responses.--> | |
1986 | <!--That's what `GZipMiddleware` does by default while processing response ---> | |
1987 | <!--it adds `;gzip` postfix to ETag response header if client requests compressed response.--> | |
1988 | <!--Lets see it in example. First request without compression:--> | |
1989 | ||
1990 | <!-- # Request--> | |
1991 | <!-- GET /cities/ HTTP/1.1--> | |
1992 | <!-- Accept: application/json--> | |
1993 | ||
1994 | <!-- # Response--> | |
1995 | <!-- HTTP/1.1 200 OK--> | |
1996 | <!-- Content-Type: application/json; charset=UTF-8--> | |
1997 | <!-- ETag: "e7b50490dc"--> | |
1998 | ||
1999 | <!-- ['Moscow', 'London', 'Paris']--> | |
2000 | ||
2001 | <!--Second request with compression:--> | |
2002 | ||
2003 | <!-- # Request--> | |
2004 | <!-- GET /cities/ HTTP/1.1--> | |
2005 | <!-- Accept: application/json--> | |
2006 | <!-- Accept-Encoding: gzip--> | |
2007 | ||
2008 | <!-- # Response--> | |
2009 | <!-- HTTP/1.1 200 OK--> | |
2010 | <!-- Content-Type: application/json--> | |
2011 | <!-- Content-Length: 675--> | |
2012 | <!-- Content-Encoding: gzip--> | |
2013 | <!-- ETag: "e7b50490dc;gzip"--> | |
2014 | ||
2015 | <!-- wS?n?0?_%o?cc?Ҫ?Eʒ?Cժʻ?a\1?a?^T*7q<>[Nvh?[?^9?x:/Ms?79?Fd/???ۦjES?ڽ?&??c%^?C[K۲%N?w{?졭2?m?}?Q&Egz??--> | |
2016 | ||
2017 | <!--As you can see there is `;gzip` postfix in ETag response header.--> | |
2018 | <!--That's ok but there is one caveat - drf-extension doesn't know how you post-processed calculated ETag value.--> | |
2019 | <!--And your clients could have next problem with conditional request:--> | |
2020 | ||
2021 | <!--* Client sends request to retrieve compressed data about cities to `/cities/`--> | |
2022 | <!--* DRF-extensions decorator calculates ETag header for response equals, for example, `123`--> | |
2023 | <!--* `GZipMiddleware` adds `;gzip` postfix to ETag header response, and now it equals `123;gzip`--> | |
2024 | <!--* Client retrieves response with ETag equals `123;gzip`--> | |
2025 | <!--* Client again makes request to retrieve compressed data about cities,--> | |
2026 | <!--but now it's conditional request with `If-None-Match` header equals `123;gzip`--> | |
2027 | <!--* DRF-extensions decorator calculates ETag value for processing conditional request.--> | |
2028 | <!--But it doesn't know, that `GZipMiddleware` added `;gzip` postfix for previous response.--> | |
2029 | <!--DRF-extensions decorator calculates ETag equals `123`, compares it with `123;gzip` and returns--> | |
2030 | <!--response with status code 200, because `123` != `123;gzip`--> | |
2031 | ||
2032 | <!--You can solve this problem by stripping `;gzip` postfix on client side.--> | |
2033 | ||
2034 | <!--But there are so many libraries that just magically uses ETag response header without allowing to--> | |
2035 | <!--pre-process conditional requests (for example, browser). If that's you case then you could add custom middleware which removes `;gzip`--> | |
2036 | <!--postfix from header:--> | |
2037 | ||
2038 | <!-- # yourapp/middleware.py--> | |
2039 | ||
2040 | <!-- class RemoveEtagGzipPostfix(object):--> | |
2041 | <!-- def process_response(self, request, response):--> | |
2042 | <!-- if response.has_header('ETag') and response['ETag'][-6:] == ';gzip"':--> | |
2043 | <!-- response['ETag'] = response['ETag'][:-6] + '"'--> | |
2044 | <!-- return response--> | |
2045 | ||
2046 | <!--Don't forget to add this middleware in your settings before `GZipMiddleware`:--> | |
2047 | ||
2048 | <!-- # settings.py--> | |
2049 | <!-- MIDDLEWARE_CLASSES = (--> | |
2050 | <!-- ...--> | |
2051 | <!-- 'yourapp.RemoveEtagGzipPostfix',--> | |
2052 | <!-- 'django.middleware.gzip.GZipMiddleware',--> | |
2053 | <!-- 'django.middleware.common.CommonMiddleware',--> | |
2054 | <!-- ...--> | |
2055 | <!-- )--> | |
2050 | 2056 | |
2051 | 2057 | |
2052 | 2058 | |
2206 | 2212 | [Django REST framework documentation](https://www.django-rest-framework.org/community/release-notes/). |
2207 | 2213 | |
2208 | 2214 | |
2215 | #### Development version | |
2216 | ||
2217 | * Added support for Django 3.0 (#276) | |
2218 | * Dropped support for Django 2.0 | |
2219 | * Added support for DRF 3.10 and 3.11 (#261, #279) | |
2220 | * Added support for Python 3.8 (#282) | |
2221 | * Added paginate decorator (#266) | |
2222 | * Added limit/offset and cursor pagination to PaginationKeyBit (#204) | |
2223 | ||
2224 | ||
2209 | 2225 | #### 0.5.0 |
2210 | 2226 | |
2211 | * Maay 10, 2019 * | |
2227 | *May 10, 2019* | |
2212 | 2228 | |
2213 | 2229 | * Dropped python 2.7 and 3.4 |
2214 | 2230 | * Fix possible header mutation issue |
0 | <!-- Yandex.Metrika informer --><a href="https://metrika.yandex.ru/stat/?id=24865421&from=informer" target="_blank" rel="nofollow"><img src="//bs.yandex.ru/informer/24865421/3_1_FFFFFFFF_EFEFEFFF_0_pageviews" style="width:88px; height:31px; border:0;" alt="Яндекс.Метрика" title="Яндекс.Метрика: данные за сегодня (просмотры, визиты и уникальные посетители)" onclick="try{Ya.Metrika.informer({i:this,id:24865421,lang:'ru'});return false}catch(e){}"/></a><!-- /Yandex.Metrika informer --><!-- Yandex.Metrika counter --><script type="text/javascript">(function (d, w, c) { (w[c] = w[c] || []).push(function() { try { w.yaCounter24865421 = new Ya.Metrika({id:24865421, webvisor:true, clickmap:true, trackLinks:true, accurateTrackBounce:true, trackHash:true}); } catch(e) { } }); var n = d.getElementsByTagName("script")[0], s = d.createElement("script"), f = function () { n.parentNode.insertBefore(s, n); }; s.type = "text/javascript"; s.async = true; s.src = (d.location.protocol == "https:" ? "https:" : "http:") + "//mc.yandex.ru/metrika/watch.js"; if (w.opera == "[object Opera]") { d.addEventListener("DOMContentLoaded", f, false); } else { f(); } })(document, window, "yandex_metrika_callbacks");</script><noscript><div><img src="//mc.yandex.ru/watch/24865421" style="position:absolute; left:-9999px;" alt="" /></div></noscript><!-- /Yandex.Metrika counter -->⏎ |
0 | docs_file_path = 'docs/index.html' | |
1 | docs_file_content = open(docs_file_path, 'r', encoding='utf8').read()[:-len('</html>')] | |
2 | docs_file = open(docs_file_path, 'w', encoding='utf-8') | |
3 | metrika_file_content = open('docs/metrika_code.txt', 'r', encoding='utf-8').read() | |
4 | docs_file.write(docs_file_content + metrika_file_content + '</html>') |
0 | __version__ = '0.5.0' # from 0.4.0 | |
0 | __version__ = '0.6.0' # from 0.5.0 | |
1 | 1 | |
2 | 2 | VERSION = __version__ |
0 | from django.utils.encoding import force_text | |
0 | from django.utils.encoding import force_str | |
1 | 1 | |
2 | 2 | from rest_framework import status |
3 | 3 | from rest_framework.response import Response |
14 | 14 | |
15 | 15 | def is_valid_bulk_operation(self): |
16 | 16 | if extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME: |
17 | header_name = utils.prepare_header_name(extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME) | |
17 | header_name = utils.prepare_header_name( | |
18 | extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME) | |
18 | 19 | return bool(self.request.META.get(header_name, None)), { |
19 | 20 | 'detail': 'Header \'{0}\' should be provided for bulk operation.'.format( |
20 | 21 | extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME |
66 | 67 | is_valid, errors = self.is_valid_bulk_operation() |
67 | 68 | if is_valid: |
68 | 69 | queryset = self.filter_queryset(self.get_queryset()) |
69 | update_bulk_dict = self.get_update_bulk_dict(serializer=self.get_serializer_class()(), data=request.data) | |
70 | self.pre_save_bulk(queryset, update_bulk_dict) # todo: test and document me | |
70 | update_bulk_dict = self.get_update_bulk_dict( | |
71 | serializer=self.get_serializer_class()(), data=request.data) | |
72 | # todo: test and document me | |
73 | self.pre_save_bulk(queryset, update_bulk_dict) | |
71 | 74 | try: |
72 | 75 | queryset.update(**update_bulk_dict) |
73 | 76 | except ValueError as e: |
74 | 77 | errors = { |
75 | 'detail': force_text(e) | |
78 | 'detail': force_str(e) | |
76 | 79 | } |
77 | 80 | return Response(errors, status=status.HTTP_400_BAD_REQUEST) |
78 | self.post_save_bulk(queryset, update_bulk_dict) # todo: test and document me | |
81 | # todo: test and document me | |
82 | self.post_save_bulk(queryset, update_bulk_dict) | |
79 | 83 | return Response(status=status.HTTP_204_NO_CONTENT) |
80 | 84 | else: |
81 | 85 | return Response(errors, status=status.HTTP_400_BAD_REQUEST) |
0 | from functools import wraps | |
0 | from functools import wraps, WRAPPER_ASSIGNMENTS | |
1 | 1 | |
2 | 2 | from django.http.response import HttpResponse |
3 | from django.utils.decorators import available_attrs | |
4 | 3 | |
5 | 4 | |
6 | 5 | from rest_framework_extensions.settings import extensions_api_settings |
49 | 48 | def __call__(self, func): |
50 | 49 | this = self |
51 | 50 | |
52 | @wraps(func, assigned=available_attrs(func)) | |
51 | @wraps(func, assigned=WRAPPER_ASSIGNMENTS) | |
53 | 52 | def inner(self, request, *args, **kwargs): |
54 | 53 | return this.process_cache_response( |
55 | 54 | view_instance=self, |
95 | 94 | # build smaller Django HttpResponse |
96 | 95 | content, status, headers = response_triple |
97 | 96 | response = HttpResponse(content=content, status=status) |
98 | response._headers = headers | |
97 | for k, v in headers.values(): | |
98 | response[k] = v | |
99 | 99 | |
100 | 100 | if not hasattr(response, '_closable_objects'): |
101 | 101 | response._closable_objects = [] |
1 | 1 | from rest_framework_extensions.settings import extensions_api_settings |
2 | 2 | |
3 | 3 | |
4 | class BaseCacheResponseMixin(object): | |
4 | class BaseCacheResponseMixin: | |
5 | 5 | # todo: test me. Create generic test like |
6 | 6 | # test_cache_reponse(view_instance, method, should_rebuild_after_method_evaluation) |
7 | 7 | object_cache_key_func = extensions_api_settings.DEFAULT_OBJECT_CACHE_KEY_FUNC |
1 | 1 | The `compat` module provides support for backwards compatibility with older |
2 | 2 | versions of django/python, and compatibility wrappers around optional packages. |
3 | 3 | """ |
4 | from __future__ import unicode_literals | |
5 | 4 | |
6 | 5 | |
7 | 6 | # handle different QuerySet representations |
0 | def paginate(pagination_class=None, **kwargs): | |
1 | """ | |
2 | Decorator that adds a pagination_class to GenericViewSet class. | |
3 | Custom pagination class also available. | |
4 | ||
5 | Usage : | |
6 | from rest_framework.pagination import CursorPagination | |
7 | ||
8 | @paginate(pagination_class=CursorPagination, page_size=5, ordering='-created_at') | |
9 | class FooViewSet(viewsets.GenericViewSet): | |
10 | ... | |
11 | ||
12 | """ | |
13 | assert pagination_class is not None, ( | |
14 | "@paginate missing required argument: 'pagination_class'" | |
15 | ) | |
16 | ||
17 | class _Pagination(pagination_class): | |
18 | def __init__(self): | |
19 | self.__dict__.update(kwargs) | |
20 | ||
21 | def decorator(_class): | |
22 | _class.pagination_class = _Pagination | |
23 | return _class | |
24 | ||
25 | return decorator |
0 | 0 | import logging |
1 | from functools import wraps | |
2 | ||
3 | from django.utils.decorators import available_attrs | |
1 | from functools import wraps, WRAPPER_ASSIGNMENTS | |
2 | ||
4 | 3 | from django.utils.http import parse_etags, quote_etag |
5 | 4 | |
6 | 5 | from rest_framework import status |
26 | 25 | def __call__(self, func): |
27 | 26 | this = self |
28 | 27 | |
29 | @wraps(func, assigned=available_attrs(func)) | |
28 | @wraps(func, assigned=WRAPPER_ASSIGNMENTS) | |
30 | 29 | def inner(self, request, *args, **kwargs): |
31 | 30 | return this.process_conditional_request( |
32 | 31 | view_instance=self, |
167 | 166 | 'the key is the HTTP verb, and the value is a list of ' |
168 | 167 | 'HTTP headers that must all be present for that request.') |
169 | 168 | |
170 | super(APIETAGProcessor, self).__init__(etag_func=etag_func, | |
169 | super().__init__(etag_func=etag_func, | |
171 | 170 | rebuild_after_method_evaluation=rebuild_after_method_evaluation) |
172 | 171 | |
173 | 172 | def get_etags_and_matchers(self, request): |
175 | 174 | # evaluate the preconditions, raises 428 if condition is not met |
176 | 175 | self.evaluate_preconditions(request) |
177 | 176 | # alright, headers are present, extract the values and match the conditions |
178 | return super(APIETAGProcessor, self).get_etags_and_matchers(request) | |
177 | return super().get_etags_and_matchers(request) | |
179 | 178 | |
180 | 179 | def evaluate_preconditions(self, request): |
181 | 180 | """Evaluate whether the precondition for the request is met.""" |
11 | 11 | class ListETAGMixin(BaseETAGMixin): |
12 | 12 | @etag(etag_func='list_etag_func') |
13 | 13 | def list(self, request, *args, **kwargs): |
14 | return super(ListETAGMixin, self).list(request, *args, **kwargs) | |
14 | return super().list(request, *args, **kwargs) | |
15 | 15 | |
16 | 16 | |
17 | 17 | class RetrieveETAGMixin(BaseETAGMixin): |
0 | from django.utils.translation import ugettext_lazy as _ | |
0 | from django.utils.translation import gettext_lazy as _ | |
1 | 1 | from rest_framework import status |
2 | 2 | from rest_framework.exceptions import APIException |
3 | 3 |
0 | # -*- coding: utf-8 -*- | |
1 | 0 | from rest_framework.relations import HyperlinkedRelatedField |
2 | 1 | |
3 | 2 | |
4 | 3 | class ResourceUriField(HyperlinkedRelatedField): |
5 | 4 | """ |
6 | Represents a hyperlinking uri that points to the detail view for that object. | |
5 | Represents a hyperlinking uri that points to the | |
6 | detail view for that object. | |
7 | 7 | |
8 | 8 | Example: |
9 | 9 | class SurveySerializer(serializers.ModelSerializer): |
1 | 1 | from django.db.models.query import EmptyQuerySet |
2 | 2 | from django.db.models.sql.datastructures import EmptyResultSet |
3 | 3 | |
4 | from django.utils.encoding import force_text | |
4 | from django.utils.encoding import force_str | |
5 | 5 | |
6 | 6 | from rest_framework_extensions import compat |
7 | 7 | |
8 | 8 | |
9 | class AllArgsMixin(object): | |
9 | class AllArgsMixin: | |
10 | 10 | |
11 | 11 | def __init__(self, params='*'): |
12 | super(AllArgsMixin, self).__init__(params) | |
13 | ||
14 | ||
15 | class KeyBitBase(object): | |
12 | super().__init__(params) | |
13 | ||
14 | ||
15 | class KeyBitBase: | |
16 | 16 | def __init__(self, params=None): |
17 | 17 | self.params = params |
18 | 18 | |
47 | 47 | params = source_dict.keys() |
48 | 48 | |
49 | 49 | for key in params: |
50 | value = source_dict.get(self.prepare_key_for_value_retrieving(key)) | |
50 | value = source_dict.get( | |
51 | self.prepare_key_for_value_retrieving(key)) | |
51 | 52 | if value is not None: |
52 | data[self.prepare_key_for_value_assignment(key)] = force_text(value) | |
53 | data[self.prepare_key_for_value_assignment( | |
54 | key)] = force_str(value) | |
53 | 55 | |
54 | 56 | return data |
55 | 57 | |
65 | 67 | |
66 | 68 | class UniqueViewIdKeyBit(KeyBitBase): |
67 | 69 | def get_data(self, params, view_instance, view_method, request, args, kwargs): |
68 | return u'.'.join([ | |
70 | return '.'.join([ | |
69 | 71 | view_instance.__module__, |
70 | 72 | view_instance.__class__.__name__ |
71 | 73 | ]) |
73 | 75 | |
74 | 76 | class UniqueMethodIdKeyBit(KeyBitBase): |
75 | 77 | def get_data(self, params, view_instance, view_method, request, args, kwargs): |
76 | return u'.'.join([ | |
78 | return '.'.join([ | |
77 | 79 | view_instance.__module__, |
78 | 80 | view_instance.__class__.__name__, |
79 | 81 | view_method.__name__ |
83 | 85 | class LanguageKeyBit(KeyBitBase): |
84 | 86 | """ |
85 | 87 | Return example: |
86 | u'en' | |
87 | ||
88 | """ | |
89 | ||
90 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
91 | return force_text(get_language()) | |
88 | 'en' | |
89 | ||
90 | """ | |
91 | ||
92 | def get_data(self, params, view_instance, view_method, request, args, kwargs): | |
93 | return force_str(get_language()) | |
92 | 94 | |
93 | 95 | |
94 | 96 | class FormatKeyBit(KeyBitBase): |
101 | 103 | """ |
102 | 104 | |
103 | 105 | def get_data(self, params, view_instance, view_method, request, args, kwargs): |
104 | return force_text(request.accepted_renderer.format) | |
106 | return force_str(request.accepted_renderer.format) | |
105 | 107 | |
106 | 108 | |
107 | 109 | class UserKeyBit(KeyBitBase): |
115 | 117 | |
116 | 118 | def get_data(self, params, view_instance, view_method, request, args, kwargs): |
117 | 119 | if hasattr(request, 'user') and request.user and request.user.is_authenticated: |
118 | return force_text(self._get_id_from_user(request.user)) | |
119 | else: | |
120 | return u'anonymous' | |
120 | return force_str(self._get_id_from_user(request.user)) | |
121 | else: | |
122 | return 'anonymous' | |
121 | 123 | |
122 | 124 | def _get_id_from_user(self, user): |
123 | 125 | return user.id |
129 | 131 | {'accept-language': u'ru', 'x-geobase-id': '123'} |
130 | 132 | |
131 | 133 | """ |
134 | ||
132 | 135 | def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): |
133 | 136 | return request.META |
134 | 137 | |
135 | 138 | def prepare_key_for_value_retrieving(self, key): |
136 | 139 | from rest_framework_extensions.utils import prepare_header_name |
137 | 140 | |
138 | return prepare_header_name(key.lower()) # Accept-Language => http_accept_language | |
141 | # Accept-Language => http_accept_language | |
142 | return prepare_header_name(key.lower()) | |
139 | 143 | |
140 | 144 | def prepare_key_for_value_assignment(self, key): |
141 | 145 | return key.lower() # Accept-Language => accept-language |
169 | 173 | {'page_size': 100, 'page': '1'} |
170 | 174 | |
171 | 175 | """ |
176 | paginator_attrs = [ | |
177 | 'page_query_param', 'page_size_query_param', | |
178 | 'limit_query_param', 'offset_query_param', | |
179 | 'cursor_query_param', | |
180 | ] | |
181 | ||
172 | 182 | def get_data(self, **kwargs): |
173 | 183 | kwargs['params'] = [] |
174 | if hasattr(kwargs['view_instance'], 'paginator'): | |
175 | if hasattr(kwargs['view_instance'].paginator, 'page_query_param'): | |
176 | kwargs['params'].append( | |
177 | kwargs['view_instance'].paginator.page_query_param) | |
178 | if hasattr(kwargs['view_instance'].paginator, | |
179 | 'page_size_query_param'): | |
180 | kwargs['params'].append( | |
181 | kwargs['view_instance'].paginator.page_size_query_param) | |
182 | return super(PaginationKeyBit, self).get_data(**kwargs) | |
184 | paginator = getattr(kwargs['view_instance'], 'paginator', None) | |
185 | ||
186 | if paginator: | |
187 | for attr in self.paginator_attrs: | |
188 | param = getattr(paginator, attr, None) | |
189 | if param: | |
190 | kwargs['params'].append(param) | |
191 | ||
192 | return super().get_data(**kwargs) | |
183 | 193 | |
184 | 194 | |
185 | 195 | class SqlQueryKeyBitBase(KeyBitBase): |
188 | 198 | return None |
189 | 199 | else: |
190 | 200 | try: |
191 | return force_text(queryset.query.__str__()) | |
201 | return force_str(queryset.query.__str__()) | |
192 | 202 | except EmptyResultSet: |
193 | 203 | return None |
194 | 204 | |
198 | 208 | Return the actual contents of the query set. |
199 | 209 | This class is similar to the `SqlQueryKeyBitBase`. |
200 | 210 | """ |
211 | ||
201 | 212 | def _get_queryset_query_values(self, queryset): |
202 | 213 | if isinstance(queryset, EmptyQuerySet) or queryset.count() == 0: |
203 | 214 | return None |
204 | 215 | else: |
205 | 216 | try: |
206 | 217 | # run through the instances and collect all values in ordered fashion |
207 | return compat.queryset_to_value_list(force_text(queryset.values_list())) | |
218 | return compat.queryset_to_value_list(force_str(queryset.values_list())) | |
208 | 219 | except EmptyResultSet: |
209 | 220 | return None |
210 | 221 | |
234 | 245 | Return example: |
235 | 246 | u"[(3, False)]" |
236 | 247 | """ |
248 | ||
237 | 249 | def get_data(self, params, view_instance, view_method, request, args, kwargs): |
238 | 250 | lookup_value = view_instance.kwargs[view_instance.lookup_field] |
239 | 251 | try: |
252 | 264 | Return example: |
253 | 265 | u"[(1, True), (2, True), (3, False)]" |
254 | 266 | """ |
267 | ||
255 | 268 | def get_data(self, params, view_instance, view_method, request, args, kwargs): |
256 | 269 | queryset = view_instance.filter_queryset(view_instance.get_queryset()) |
257 | 270 | return self._get_queryset_query_values(queryset) |
0 | # -*- coding: utf-8 -*- | |
1 | # Try to import six from Django, fallback to included `six`. | |
2 | ||
3 | ||
4 | 0 | from rest_framework_extensions.cache.mixins import CacheResponseMixin |
5 | 1 | # from rest_framework_extensions.etag.mixins import ReadOnlyETAGMixin, ETAGMixin |
6 | from rest_framework_extensions.bulk_operations.mixins import ListUpdateModelMixin | |
2 | from rest_framework_extensions.bulk_operations.mixins import ListUpdateModelMixin, ListDestroyModelMixin | |
7 | 3 | from rest_framework_extensions.settings import extensions_api_settings |
8 | 4 | from django.http import Http404 |
9 | 5 |
0 | # -*- coding: utf-8 -*- | |
1 | 0 | from rest_framework.routers import DefaultRouter, SimpleRouter |
2 | 1 | from rest_framework_extensions.utils import compose_parent_pk_kwarg_name |
3 | 2 |
102 | 102 | return self.generic('OPTIONS', path, data, content_type, **extra) |
103 | 103 | |
104 | 104 | def request(self, **kwargs): |
105 | request = super(APIRequestFactory, self).request(**kwargs) | |
105 | request = super().request(**kwargs) | |
106 | 106 | request._dont_enforce_csrf_checks = not self.enforce_csrf_checks |
107 | 107 | return request |
108 | 108 | |
127 | 127 | |
128 | 128 | class APIClient(APIRequestFactory, DjangoClient): |
129 | 129 | def __init__(self, enforce_csrf_checks=False, **defaults): |
130 | super(APIClient, self).__init__(**defaults) | |
130 | super().__init__(**defaults) | |
131 | 131 | self.handler = ForceAuthClientHandler(enforce_csrf_checks) |
132 | 132 | self._credentials = {} |
133 | 133 |
35 | 35 | |
36 | 36 | def get_unique_method_id(view_instance, view_method): |
37 | 37 | # todo: test me as UniqueMethodIdKeyBit |
38 | return u'.'.join([ | |
38 | return '.'.join([ | |
39 | 39 | view_instance.__module__, |
40 | 40 | view_instance.__class__.__name__, |
41 | 41 | view_method.__name__ |
0 | 0 | # Django settings for testproject project. |
1 | 1 | import multiprocessing |
2 | import os | |
3 | 2 | |
4 | 3 | DEBUG = True |
5 | 4 | DEBUG_PROPAGATE_EXCEPTIONS = True |
0 | # -*- coding: utf-8 -*- | |
1 | 0 | try: |
2 | 1 | from django.utils.deprecation import MiddlewareMixin |
3 | 2 | except ImportError: |
0 | # -*- coding: utf-8 -*- | |
1 | 0 | from django.views import View |
2 | 1 | from django.http import HttpResponse |
3 | 2 |
0 | 0 | from django.test import TestCase, override_settings |
1 | from django.utils.encoding import force_text | |
1 | from django.utils.encoding import force_str | |
2 | 2 | |
3 | 3 | |
4 | 4 | @override_settings(ROOT_URLCONF='tests_app.tests.functional.cache.decorators.urls') |
6 | 6 | |
7 | 7 | def test_should_return_response(self): |
8 | 8 | resp = self.client.get('/hello/') |
9 | self.assertEqual(force_text(resp.content), '"Hello world"') | |
9 | self.assertEqual(force_str(resp.content), '"Hello world"') | |
10 | 10 | |
11 | 11 | def test_should_return_same_response_if_cached(self): |
12 | 12 | resp_1 = self.client.get('/hello/') |
6 | 6 | class HelloView(views.APIView): |
7 | 7 | @cache_response() |
8 | 8 | def get(self, request, *args, **kwargs): |
9 | return Response('Hello world')⏎ | |
9 | return Response('Hello world') |
0 | # -*- coding: utf-8 -*- | |
1 | 0 | from rest_framework import routers |
2 | 1 | |
3 | 2 | from .views import UserModelViewSet |
38 | 38 | queryset_detail = Comment.objects.filter(id=1) |
39 | 39 | |
40 | 40 | def get_queryset(self): |
41 | return super(CommentWithDetailSerializerAndNoArgsForGetQuerySetViewSet, self).get_queryset()⏎ | |
41 | return super().get_queryset() |
1 | 1 | |
2 | 2 | import unittest |
3 | 3 | |
4 | import django | |
4 | 5 | from django.test import override_settings |
5 | 6 | |
6 | 7 | from rest_framework_extensions.test import APITestCase |
154 | 155 | self.fail('Errors with invalid for DB data should be caught') |
155 | 156 | else: |
156 | 157 | self.assertEqual(resp.status_code, 400) |
157 | expected_message = { | |
158 | 'detail': "invalid literal for int() with base 10: 'Not integer value'" | |
159 | } | |
158 | if django.VERSION < (3, 0, 0): | |
159 | expected_message = { | |
160 | 'detail': "invalid literal for int() with base 10: 'Not integer value'" | |
161 | } | |
162 | else: | |
163 | expected_message = { | |
164 | 'detail': "Field 'age' expected a number but got 'Not integer value'." | |
165 | } | |
160 | 166 | self.assertEqual(resp.data, expected_message) |
161 | 167 | |
162 | 168 | def test_should_use_source_if_it_set_in_serializer(self): |
79 | 79 | |
80 | 80 | class TaskCommentViewSet(CommentViewSet): |
81 | 81 | def get_queryset(self): |
82 | return super(TaskCommentViewSet, self).get_queryset().filter( | |
82 | return super().get_queryset().filter( | |
83 | 83 | content_type=ContentType.objects.get_for_model(TaskModel) |
84 | 84 | ) |
85 | 85 | |
86 | 86 | |
87 | 87 | class BookCommentViewSet(CommentViewSet): |
88 | 88 | def get_queryset(self): |
89 | return super(BookCommentViewSet, self).get_queryset().filter( | |
89 | return super().get_queryset().filter( | |
90 | 90 | content_type=ContentType.objects.get_for_model(BookModel) |
91 | 91 | ) |
92 | 92 |
220 | 220 | |
221 | 221 | class ETAGProcessorTestBehavior_if_none_match(ETAGProcessorTestBehaviorMixin, TestCase): |
222 | 222 | def setUp(self): |
223 | super(ETAGProcessorTestBehavior_if_none_match, self).setUp() | |
223 | super().setUp() | |
224 | 224 | self.header_name = 'if-none-match' |
225 | 225 | self.experiments = [ |
226 | 226 | { |
274 | 274 | |
275 | 275 | class ETAGProcessorTestBehavior_if_match(ETAGProcessorTestBehaviorMixin, TestCase): |
276 | 276 | def setUp(self): |
277 | super(ETAGProcessorTestBehavior_if_match, self).setUp() | |
277 | super().setUp() | |
278 | 278 | self.header_name = 'if-match' |
279 | 279 | self.experiments = [ |
280 | 280 | { |
341 | 341 | class View(views.APIView): |
342 | 342 | @api_etag() |
343 | 343 | def get(self, request, *args, **kwargs): |
344 | return super(View, self).get(request, *args, **kwargs) | |
344 | return super().get(request, *args, **kwargs) | |
345 | 345 | |
346 | 346 | def test_should_raise_assertion_error_if_precondition_map_not_a_dict(self): |
347 | 347 | with self.assertRaises(AssertionError): |
352 | 352 | class View(views.APIView): |
353 | 353 | @api_etag(dummy_api_etag_func, precondition_map=['header-name']) |
354 | 354 | def get(self, request, *args, **kwargs): |
355 | return super(View, self).get(request, *args, **kwargs) | |
355 | return super().get(request, *args, **kwargs) | |
356 | 356 | |
357 | 357 | def test_should_add_object_etag_value(self): |
358 | 358 | class TestView(views.APIView): |
607 | 607 | |
608 | 608 | class APIETAGProcessorTestBehavior_if_match(APIETAGProcessorTestBehaviorMixin, TestCase): |
609 | 609 | def setUp(self): |
610 | super(APIETAGProcessorTestBehavior_if_match, self).setUp() | |
610 | super().setUp() | |
611 | 611 | self.header_name = 'if-match' |
612 | 612 | self.experiments = [ |
613 | 613 | { |
661 | 661 | |
662 | 662 | class APIETAGProcessorTestBehavior_if_none_match(APIETAGProcessorTestBehaviorMixin, TestCase): |
663 | 663 | def setUp(self): |
664 | super(APIETAGProcessorTestBehavior_if_none_match, self).setUp() | |
664 | super().setUp() | |
665 | 665 | self.header_name = 'if-none-match' |
666 | 666 | self.experiments = [ |
667 | 667 | { |
13 | 13 | |
14 | 14 | class CacheResponseTest(TestCase): |
15 | 15 | def setUp(self): |
16 | super(CacheResponseTest, self).setUp() | |
16 | super().setUp() | |
17 | 17 | self.request = factory.get('') |
18 | 18 | self.cache = caches[extensions_api_settings.DEFAULT_USE_CACHE] |
19 | 19 | |
37 | 37 | |
38 | 38 | view_instance = TestView() |
39 | 39 | response = view_instance.dispatch(request=self.request) |
40 | self.assertEqual(self.cache.get('cache_response_key')[0], response.content) | |
40 | self.assertEqual(self.cache.get('cache_response_key') | |
41 | [0], response.content) | |
41 | 42 | self.assertEqual(type(response), Response) |
42 | 43 | |
43 | 44 | def test_should_store_response_in_cache_by_key_function_which_specified_in_arguments(self): |
51 | 52 | |
52 | 53 | view_instance = TestView() |
53 | 54 | response = view_instance.dispatch(request=self.request) |
54 | self.assertEqual(self.cache.get('cache_response_key_from_func')[0], response.content) | |
55 | self.assertEqual(self.cache.get( | |
56 | 'cache_response_key_from_func')[0], response.content) | |
55 | 57 | self.assertEqual(type(response), Response) |
56 | 58 | |
57 | 59 | def test_should_store_response_in_cache_by_key_which_calculated_by_view_method__if__key_func__is_string(self): |
65 | 67 | |
66 | 68 | view_instance = TestView() |
67 | 69 | response = view_instance.dispatch(request=self.request) |
68 | self.assertEqual(self.cache.get('cache_response_key_from_method')[0], response.content) | |
70 | self.assertEqual(self.cache.get( | |
71 | 'cache_response_key_from_method')[0], response.content) | |
69 | 72 | self.assertEqual(type(response), Response) |
70 | 73 | |
71 | 74 | def test_key_func_call_arguments(self): |
82 | 85 | |
83 | 86 | view_instance = TestView() |
84 | 87 | response = view_instance.dispatch(self.request, 'hello', hello='world') |
85 | self.assertEqual(called_with_kwargs.get('view_instance'), view_instance) | |
88 | self.assertEqual(called_with_kwargs.get( | |
89 | 'view_instance'), view_instance) | |
86 | 90 | # self.assertEqual(called_with_kwargs.get('view_method'), view_instance.get) # todo: test me |
87 | 91 | self.assertEqual(called_with_kwargs.get('args'), ('hello',)) |
88 | 92 | self.assertEqual(called_with_kwargs.get('kwargs'), {'hello': 'world'}) |
126 | 130 | cache_response_decorator.cache.set.call_args_list[0][0][2], 3) |
127 | 131 | |
128 | 132 | def test_should_store_response_in_cache_with_timeout_from_object_cache_timeout_property(self): |
129 | cache_response_decorator = cache_response(timeout='object_cache_timeout') | |
133 | cache_response_decorator = cache_response( | |
134 | timeout='object_cache_timeout') | |
130 | 135 | cache_response_decorator.cache.set = Mock() |
131 | 136 | |
132 | 137 | class TestView(views.APIView): |
141 | 146 | self.assertTrue( |
142 | 147 | cache_response_decorator.cache.set.called, |
143 | 148 | 'Cache saving should be performed') |
144 | self.assertEqual(cache_response_decorator.cache.set.call_args_list[0][0][2], 20) | |
149 | self.assertEqual( | |
150 | cache_response_decorator.cache.set.call_args_list[0][0][2], 20) | |
145 | 151 | |
146 | 152 | def test_should_store_response_in_cache_with_timeout_from_list_cache_timeout_property(self): |
147 | 153 | cache_response_decorator = cache_response(timeout='list_cache_timeout') |
159 | 165 | self.assertTrue( |
160 | 166 | cache_response_decorator.cache.set.called, |
161 | 167 | 'Cache saving should be performed') |
162 | self.assertEqual(cache_response_decorator.cache.set.call_args_list[0][0][2], 10) | |
168 | self.assertEqual( | |
169 | cache_response_decorator.cache.set.call_args_list[0][0][2], 10) | |
163 | 170 | |
164 | 171 | def test_should_return_response_from_cache_if_it_is_in_it(self): |
165 | 172 | def key_func(**kwargs): |
172 | 179 | |
173 | 180 | view_instance = TestView() |
174 | 181 | view_instance.headers = {} |
175 | cached_response = Response(u'Cached response from method 4') | |
176 | view_instance.finalize_response(request=self.request, response=cached_response) | |
182 | cached_response = Response('Cached response from method 4') | |
183 | view_instance.finalize_response( | |
184 | request=self.request, response=cached_response) | |
177 | 185 | cached_response.render() |
178 | 186 | response_dict = ( |
179 | 187 | cached_response.rendered_content, |
185 | 193 | response = view_instance.dispatch(request=self.request) |
186 | 194 | self.assertEqual( |
187 | 195 | response.content.decode('utf-8'), |
188 | u'"Cached response from method 4"') | |
196 | '"Cached response from method 4"') | |
189 | 197 | |
190 | 198 | @override_extensions_api_settings( |
191 | 199 | DEFAULT_USE_CACHE='special_cache' |
221 | 229 | |
222 | 230 | view_instance = TestView() |
223 | 231 | view_instance.dispatch(request=self.request) |
224 | data_from_cache = caches['another_special_cache'].get('cache_response_key') | |
232 | data_from_cache = caches['another_special_cache'].get( | |
233 | 'cache_response_key') | |
225 | 234 | self.assertEqual(len(data_from_cache), 3) |
226 | self.assertEqual(data_from_cache[0].decode('utf-8'), u'"Response from method 6"') | |
235 | self.assertEqual(data_from_cache[0].decode( | |
236 | 'utf-8'), u'"Response from method 6"') | |
227 | 237 | |
228 | 238 | def test_should_reuse_cache_singleton(self): |
229 | 239 | """ |
232 | 242 | """ |
233 | 243 | cache_response_instance = cache_response() |
234 | 244 | another_cache_response_instance = cache_response() |
235 | self.assertTrue(cache_response_instance.cache is another_cache_response_instance.cache) | |
245 | self.assertTrue( | |
246 | cache_response_instance.cache is another_cache_response_instance.cache) | |
236 | 247 | |
237 | 248 | def test_dont_cache_response_with_error_if_cache_error_false(self): |
238 | 249 | cache_response_decorator = cache_response(cache_errors=False) |
241 | 252 | |
242 | 253 | def __init__(self, status, *args, **kwargs): |
243 | 254 | self.status = status |
244 | super(TestView, self).__init__(*args, **kwargs) | |
255 | super().__init__(*args, **kwargs) | |
245 | 256 | |
246 | 257 | @cache_response_decorator |
247 | 258 | def get(self, request, *args, **kwargs): |
261 | 272 | |
262 | 273 | def __init__(self, status, *args, **kwargs): |
263 | 274 | self.status = status |
264 | super(TestView, self).__init__(*args, **kwargs) | |
275 | super().__init__(*args, **kwargs) | |
265 | 276 | |
266 | 277 | @cache_response_decorator |
267 | 278 | def get(self, request, *args, **kwargs): |
285 | 296 | ) |
286 | 297 | def test_should_use_cache_error_from_decorator_if_it_is_specified(self): |
287 | 298 | self.assertTrue(cache_response(cache_errors=True).cache_errors) |
299 | ||
300 | def test_should_return_response_with_tuple_headers(self): | |
301 | def key_func(**kwargs): | |
302 | return 'cache_response_key' | |
303 | ||
304 | class TestView(views.APIView): | |
305 | @cache_response(key_func=key_func) | |
306 | def get(self, request, *args, **kwargs): | |
307 | return Response(u'') | |
308 | ||
309 | view_instance = TestView() | |
310 | view_instance.headers = {'Test': 'foo'} | |
311 | cached_response = Response(u'') | |
312 | view_instance.finalize_response( | |
313 | request=self.request, response=cached_response) | |
314 | cached_response.render() | |
315 | response_dict = ( | |
316 | cached_response.rendered_content, | |
317 | cached_response.status_code, | |
318 | {k: list(v) for k, v in cached_response._headers.items()} | |
319 | ) | |
320 | self.cache.set('cache_response_key', response_dict) | |
321 | ||
322 | response = view_instance.dispatch(request=self.request) | |
323 | self.assertTrue(all(isinstance(v, tuple) | |
324 | for v in response._headers.values())) | |
325 | self.assertEqual(response._headers['test'], ('Test', 'foo')) |
0 | from django.test import TestCase | |
1 | from rest_framework import pagination, viewsets | |
2 | from rest_framework_extensions.decorators import paginate | |
3 | ||
4 | ||
5 | class TestPaginateDecorator(TestCase): | |
6 | ||
7 | def test_empty_pagination_class(self): | |
8 | msg = "@paginate missing required argument: 'pagination_class'" | |
9 | with self.assertRaisesMessage(AssertionError, msg): | |
10 | @paginate() | |
11 | class MockGenericViewSet(viewsets.GenericViewSet): | |
12 | pass | |
13 | ||
14 | def test_adding_page_number_pagination(self): | |
15 | """ | |
16 | Other default pagination classes' test result will be same as this even if kwargs changed to anything. | |
17 | """ | |
18 | ||
19 | @paginate(pagination_class=pagination.PageNumberPagination, page_size=5, ordering='-created_at') | |
20 | class MockGenericViewSet(viewsets.GenericViewSet): | |
21 | pass | |
22 | ||
23 | assert hasattr(MockGenericViewSet, 'pagination_class') | |
24 | assert MockGenericViewSet.pagination_class().page_size == 5 | |
25 | assert MockGenericViewSet.pagination_class().ordering == '-created_at' | |
26 | ||
27 | def test_adding_custom_pagination(self): | |
28 | class CustomPagination(pagination.BasePagination): | |
29 | pass | |
30 | ||
31 | @paginate(pagination_class=CustomPagination, kwarg1='kwarg1', kwarg2='kwarg2') | |
32 | class MockGenericViewSet(viewsets.GenericViewSet): | |
33 | pass | |
34 | ||
35 | assert hasattr(MockGenericViewSet, 'pagination_class') | |
36 | assert MockGenericViewSet.pagination_class().kwarg1 == 'kwarg1' | |
37 | assert MockGenericViewSet.pagination_class().kwarg2 == 'kwarg2' |
339 | 339 | 'params': None, |
340 | 340 | 'view_instance': Mock(spec_set=['paginator']), |
341 | 341 | 'view_method': None, |
342 | 'request': factory.get('?page_size=10&page=1'), | |
342 | 'request': factory.get('?page_size=10&page=1&limit=5&offset=15&cursor=foo'), | |
343 | 343 | 'args': None, |
344 | 344 | 'kwargs': None |
345 | 345 | } |
356 | 356 | def test_view_with_page_kwarg(self): |
357 | 357 | self.kwargs['view_instance'].paginator.page_query_param = 'page' |
358 | 358 | self.kwargs['view_instance'].paginator.page_size_query_param = None |
359 | self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'page': u'1'}) | |
359 | self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'page': '1'}) | |
360 | 360 | |
361 | 361 | def test_view_with_paginate_by_param(self): |
362 | 362 | self.kwargs['view_instance'].paginator.page_query_param = None |
363 | 363 | self.kwargs['view_instance'].paginator.page_size_query_param = 'page_size' |
364 | self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'page_size': u'10'}) | |
364 | self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'page_size': '10'}) | |
365 | 365 | |
366 | 366 | def test_view_with_all_pagination_attrs(self): |
367 | 367 | self.kwargs['view_instance'].paginator.page_query_param = 'page' |
368 | 368 | self.kwargs['view_instance'].paginator.page_size_query_param = 'page_size' |
369 | self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'page_size': u'10', 'page': u'1'}) | |
369 | self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'page_size': '10', 'page': '1'}) | |
370 | 370 | |
371 | 371 | def test_view_with_all_pagination_attrs__without_query_params(self): |
372 | 372 | self.kwargs['view_instance'].paginator.page_query_param = 'page' |
373 | 373 | self.kwargs['view_instance'].paginator.page_size_query_param = 'page_size' |
374 | 374 | self.kwargs['request'] = factory.get('') |
375 | 375 | self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {}) |
376 | ||
377 | def test_view_with_offset_pagination_attrs(self): | |
378 | self.kwargs['view_instance'].paginator.limit_query_param = 'limit' | |
379 | self.kwargs['view_instance'].paginator.offset_query_param = 'offset' | |
380 | self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'limit': '5', 'offset': '15'}) | |
381 | ||
382 | def test_view_with_cursor_pagination_attrs(self): | |
383 | self.kwargs['view_instance'].paginator.cursor_query_param = 'cursor' | |
384 | self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'cursor': 'foo'}) | |
376 | 385 | |
377 | 386 | |
378 | 387 | class ListSqlQueryKeyBitTest(TestCase): |
0 | 0 | [tox] |
1 | envlist = py{35,36,37}-django{111}-drf39, | |
2 | py{35,36,37}-django{20,21,22}-drf39 | |
1 | envlist = py{36,37}-django{111}-drf39, | |
2 | py{36,37}-django{21}-drf{39,310,311} | |
3 | py{36,37,38}-django{22}-drf{39,310,311} | |
4 | py{36,37,38}-django{30}-drf{310,311} | |
5 | ||
3 | 6 | |
4 | 7 | [testenv] |
5 | 8 | deps= |
7 | 10 | django-guardian>=1.4.4 |
8 | 11 | drf39: djangorestframework>=3.9.3,<3.10 |
9 | 12 | djangorestframework-guardian |
13 | drf310: djangorestframework>=3.10,<3.11 | |
14 | djangorestframework-guardian | |
15 | drf311: djangorestframework>=3.11,<3.12 | |
16 | djangorestframework-guardian | |
10 | 17 | django111: Django>=1.11,<2.0 |
11 | django20: Django>=2.0,<2.1 | |
12 | 18 | django21: Django>=2.1,<2.2 |
13 | 19 | django22: Django>=2.2,<3.0 |
20 | django30: Django>=3.0,<3.1 | |
14 | 21 | setenv = |
15 | 22 | PYTHONPATH = {toxinidir}:{toxinidir}/tests_app |
16 | 23 | commands = |