Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Fixed #5791 -- Added early-bailout support for views (ETags and Last-…

…modified).

This provides support for views that can have their ETag and/or Last-modified
values computed much more quickly than the view itself. Supports all HTTP
verbs (not just GET).

Documentation and tests need a little more fleshing out (I'm not happy with the
documentation at the moment, since it's a bit backwards), but the functionality
is correct.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@10114 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
commit b203db6ec850fee9ad8f2e2c8873be986325572b 1 parent 5ac154e
Malcolm Tredinnick authored
23  django/utils/http.py
... ...
@@ -1,9 +1,12 @@
  1
+import re
1 2
 import urllib
2 3
 from email.Utils import formatdate
3 4
 
4 5
 from django.utils.encoding import smart_str, force_unicode
5 6
 from django.utils.functional import allow_lazy
6 7
 
  8
+ETAG_MATCH = re.compile(r'(?:W/)?"((?:\\.|[^"])*)"')
  9
+
7 10
 def urlquote(url, safe='/'):
8 11
     """
9 12
     A version of Python's urllib.quote() function that can operate on unicode
@@ -94,3 +97,23 @@ def int_to_base36(i):
94 97
         i = i % j
95 98
         factor -= 1
96 99
     return ''.join(base36)
  100
+
  101
+def parse_etags(etag_str):
  102
+    """
  103
+    Parses a string with one or several etags passed in If-None-Match and
  104
+    If-Match headers by the rules in RFC 2616. Returns a list of etags
  105
+    without surrounding double quotes (") and unescaped from \<CHAR>.
  106
+    """
  107
+    etags = ETAG_MATCH.findall(etag_str)
  108
+    if not etags:
  109
+        # etag_str has wrong format, treat it as an opaque string then
  110
+        return [etag_str]
  111
+    etags = [e.decode('string_escape') for e in etags]
  112
+    return etags
  113
+
  114
+def quote_etag(etag):
  115
+    """
  116
+    Wraps a string in double quotes escaping contents as necesary.
  117
+    """
  118
+    return '"%s"' % etag.replace('\\', '\\\\').replace('"', '\\"')
  119
+
100  django/views/decorators/http.py
@@ -7,9 +7,15 @@
7 7
 except ImportError:
8 8
     from django.utils.functional import wraps  # Python 2.3, 2.4 fallback.
9 9
 
  10
+from calendar import timegm
  11
+from datetime import timedelta
  12
+from email.Utils import formatdate
  13
+
10 14
 from django.utils.decorators import decorator_from_middleware
  15
+from django.utils.http import parse_etags, quote_etag
11 16
 from django.middleware.http import ConditionalGetMiddleware
12  
-from django.http import HttpResponseNotAllowed
  17
+from django.http import HttpResponseNotAllowed, HttpResponseNotModified, HttpResponse
  18
+
13 19
 
14 20
 conditional_page = decorator_from_middleware(ConditionalGetMiddleware)
15 21
 
@@ -36,4 +42,94 @@ def inner(request, *args, **kwargs):
36 42
 require_GET.__doc__ = "Decorator to require that a view only accept the GET method."
37 43
 
38 44
 require_POST = require_http_methods(["POST"])
39  
-require_POST.__doc__ = "Decorator to require that a view only accept the POST method."
  45
+require_POST.__doc__ = "Decorator to require that a view only accept the POST method."
  46
+
  47
+def condition(etag_func=None, last_modified_func=None):
  48
+    """
  49
+    Decorator to support conditional retrieval (or change) for a view
  50
+    function.
  51
+
  52
+    The parameters are callables to compute the ETag and last modified time for
  53
+    the requested resource, respectively. The callables are passed the same
  54
+    parameters as the view itself. The Etag function should return a string (or
  55
+    None if the resource doesn't exist), whilst the last_modified function
  56
+    should return a datetime object (or None if the resource doesn't exist).
  57
+
  58
+    If both parameters are provided, all the preconditions must be met before
  59
+    the view is processed.
  60
+
  61
+    This decorator will either pass control to the wrapped view function or
  62
+    return an HTTP 304 response (unmodified) or 412 response (preconditions
  63
+    failed), depending upon the request method.
  64
+
  65
+    Any behavior marked as "undefined" in the HTTP spec (e.g. If-none-match
  66
+    plus If-modified-since headers) will result in the view function being
  67
+    called.
  68
+    """
  69
+    def decorator(func):
  70
+        def inner(request, *args, **kwargs):
  71
+            # Get HTTP request headers
  72
+            if_modified_since = request.META.get("HTTP_IF_MODIFIED_SINCE")
  73
+            if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
  74
+            if_match = request.META.get("HTTP_IF_MATCH")
  75
+            if if_none_match or if_match:
  76
+                # There can be more than one ETag in the request, so we
  77
+                # consider the list of values.
  78
+                etags = parse_etags(if_none_match)
  79
+
  80
+            # Compute values (if any) for the requested resource.
  81
+            if etag_func:
  82
+                res_etag = etag_func(request, *args, **kwargs)
  83
+            else:
  84
+                res_etag = None
  85
+            if last_modified_func:
  86
+                dt = last_modified_func(request, *args, **kwargs)
  87
+                if dt:
  88
+                    res_last_modified = formatdate(timegm(dt.utctimetuple()))[:26] + 'GMT'
  89
+                else:
  90
+                    res_last_modified = None
  91
+            else:
  92
+                res_last_modified = None
  93
+
  94
+            response = None
  95
+            if not ((if_match and (if_modified_since or if_none_match)) or
  96
+                    (if_match and if_none_match)):
  97
+                # We only get here if no undefined combinations of headers are
  98
+                # specified.
  99
+                if ((if_none_match and (res_etag in etags or
  100
+                        "*" in etags and res_etag)) and
  101
+                        (not if_modified_since or
  102
+                            res_last_modified == if_modified_since)):
  103
+                    if request.method in ("GET", "HEAD"):
  104
+                        response = HttpResponseNotModified()
  105
+                    else:
  106
+                        response = HttpResponse(status=412)
  107
+                elif if_match and ((not res_etag and "*" in etags) or
  108
+                        (res_etag and res_etag not in etags)):
  109
+                    response = HttpResponse(status=412)
  110
+                elif (not if_none_match and if_modified_since and
  111
+                        request.method == "GET" and
  112
+                        res_last_modified == if_modified_since):
  113
+                    response = HttpResponseNotModified()
  114
+
  115
+            if response is None:
  116
+                response = func(request, *args, **kwargs)
  117
+
  118
+            # Set relevant headers on the response if they don't already exist.
  119
+            if res_last_modified and not response.has_header('Last-Modified'):
  120
+                response['Last-Modified'] = res_last_modified
  121
+            if res_etag and not response.has_header('ETag'):
  122
+                response['ETag'] = quote_etag(res_etag)
  123
+
  124
+            return response
  125
+
  126
+        return inner
  127
+    return decorator
  128
+
  129
+# Shortcut decorators for common cases based on ETag or Last-Modified only
  130
+def etag(callable):
  131
+    return condition(etag=callable)
  132
+
  133
+def last_modified(callable):
  134
+    return condition(last_modified=callable)
  135
+
1  docs/index.txt
@@ -81,6 +81,7 @@ Other batteries included
81 81
     * :ref:`Admin site <ref-contrib-admin>`
82 82
     * :ref:`Authentication <topics-auth>`
83 83
     * :ref:`Cache system <topics-cache>`
  84
+    * :ref:`Conditional content processing <topics-conditional-processing>`
84 85
     * :ref:`Comments <ref-contrib-comments-index>`
85 86
     * :ref:`Content types <ref-contrib-contenttypes>`
86 87
     * :ref:`Cross Site Request Forgery protection <ref-contrib-csrf>`
134  docs/topics/conditional-view-processing.txt
... ...
@@ -0,0 +1,134 @@
  1
+.. _topics-conditional-processing:
  2
+
  3
+===========================
  4
+Conditional View Processing
  5
+===========================
  6
+
  7
+.. versionadded:: 1.1
  8
+
  9
+HTTP clients can send a number of headers to tell the server about copies of a
  10
+resource that they have already seen. This is commonly used when retrieving a
  11
+web page (using an HTTP ``GET`` request) to avoid sending all the data for
  12
+something the client has already retrieved. However, the same headers can be
  13
+used for all HTTP methods (``POST``, ``PUT``, ``DELETE``, etc).
  14
+
  15
+For each page (response) that Django sends back from a view, it might provide
  16
+two HTTP headers: the ``ETag`` header and the ``Last-Modified`` header. These
  17
+headers are optional on HTTP responses. They can be set by your view function,
  18
+or you can rely on the :class:`~django.middleware.common.CommonMiddleware`
  19
+middleware to set the ``ETag`` header.
  20
+
  21
+When the client next requests the same resource, it might send along a header
  22
+such as `If-modified-since`_, containing the date of the last modification
  23
+time it was sent, or `If-none-match`_, containing the ``ETag`` it was sent.
  24
+If there is no match with the ETag, or if the resource has not been modified,
  25
+a 304 status code can be sent back, instead of a full response, telling the
  26
+client that nothing has changed.
  27
+
  28
+.. _If-none-match: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
  29
+.. _If-modified-since: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.25
  30
+
  31
+Django allows simple usage of this feature with
  32
+:class:`django.middleware.http.ConditionalGetMiddleware` and
  33
+:class:`~django.middleware.common.CommonMiddleware`. However, whilst being
  34
+easy to use and suitable for many situations, they both have limitations for
  35
+advanced usage:
  36
+
  37
+    * They are applied globally to all views in your project
  38
+    * They don't save you from generating the response itself, which may be
  39
+      expensive
  40
+    * They are only appropriate for HTTP ``GET`` requests.
  41
+
  42
+.. conditional-decorators:
  43
+
  44
+Decorators
  45
+==========
  46
+
  47
+When you need more fine-grained control you may use per-view conditional
  48
+processing functions. 
  49
+
  50
+The decorators ``django.views.decorators.http.etag`` and
  51
+``django.views.decorators.http.last_modified`` each accept a user-defined
  52
+function that takes the same parameters as the view itself. The function
  53
+passed ``last_modified`` should return a standard datetime value specifying
  54
+the last time the resource was modified, or ``None`` if the resource doesn't
  55
+exist. The function passed to the ``etag`` decorator should return a string
  56
+representing the `Etag`_ for the resource, or ``None`` if it doesn't exist.
  57
+
  58
+.. _ETag: http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
  59
+
  60
+For example::
  61
+
  62
+    # Compute the last-modified time from when the object was last saved.
  63
+    @last_modified(lambda r, obj_id: MyObject.objects.get(pk=obj_id).update_time)
  64
+    def my_object_view(request, obj_id):
  65
+        # Expensive generation of response with MyObject instance
  66
+        ...
  67
+
  68
+Of course, you can always use the non-decorator form if you're using Python
  69
+2.3 or don't like the decorator syntax::
  70
+
  71
+    def my_object_view(request, obj_id):
  72
+        ...
  73
+    my_object_view = last_modified(my_func)(my_object_view)
  74
+
  75
+Using the ``etag`` decorator is similar.
  76
+
  77
+In practice, though, you won't know if the client is going to send the
  78
+``Last-modified`` or the ``If-none-match`` header. If you can quickly compute
  79
+both values and want to short-circuit as often as possible, you'll need to use
  80
+the ``conditional`` decorator described below.
  81
+
  82
+HTTP allows to use both "ETag" and "Last-Modified" headers in your response.
  83
+Then a response is considered not modified only if the client sends both
  84
+headers back and they're both equal to the response headers. This means that
  85
+you can't just chain decorators on your view::
  86
+
  87
+    # Bad code. Don't do this!
  88
+    @etag(etag_func)
  89
+    @last_modified(last_modified_func)
  90
+    def my_view(request):
  91
+        # ...
  92
+
  93
+    # End of bad code.
  94
+
  95
+The first decorator doesn't know anything about the second and might
  96
+answer that the response is not modified even if the second decorators would
  97
+determine otherwise. In this case you should use a more general decorator -
  98
+``django.views.decorator.http.condition`` that accepts two functions at once::
  99
+
  100
+    # The correct way to implement the above example
  101
+    @condition(etag_func, last_modified_func)
  102
+    def my_view(request):
  103
+        # ...
  104
+
  105
+Using the decorators with other HTTP methods
  106
+============================================
  107
+
  108
+The ``conditional`` decorator is useful for more than only ``GET`` and
  109
+``HEAD`` requests (``HEAD`` requests are the same as ``GET`` in this
  110
+situation). It can be used also to be used to provide checking for ``POST``,
  111
+``PUT`` and ``DELETE`` requests. In these situations, the idea isn't to return
  112
+a "not modified" response, but to tell the client that the resource they are
  113
+trying to change has been altered in the meantime.
  114
+
  115
+For example, consider the following exchange between the client and server:
  116
+
  117
+    1. Client requests ``/foo/``.
  118
+    2. Server responds with some content with an ETag of ``"abcd1234"``.
  119
+    3. Client sends and HTTP ``PUT`` request to ``/foo/`` to update the
  120
+       resource. It sends an ``If-Match: "abcd1234"`` header to specify the
  121
+       version it is trying to update.
  122
+    4. Server checks to see if the resource has changed, by computing the ETag
  123
+       the same way it does for a ``GET`` request (using the same function).
  124
+       If the resource *has* changed, it will return a 412 status code code,
  125
+       meaning "precondition failed".
  126
+    5. Client sends a ``GET`` request to ``/foo/``, after receiving a 412
  127
+       response, to retrieve an updated version of the content before updating
  128
+       it.
  129
+
  130
+The important thing this example shows is that the same functions can be used
  131
+to compute the ETag and last modification values in all situations. In fact,
  132
+you *should* use the same functions, so that the same values are returned
  133
+every time.
  134
+
1  docs/topics/index.txt
@@ -18,6 +18,7 @@ Introductions to all the key parts of Django you'll need to know:
18 18
    testing
19 19
    auth
20 20
    cache
  21
+   conditional-view-processing
21 22
    email
22 23
    i18n
23 24
    pagination
1  tests/regressiontests/conditional_processing/__init__.py
... ...
@@ -0,0 +1 @@
  1
+# -*- coding:utf-8 -*-
100  tests/regressiontests/conditional_processing/models.py
... ...
@@ -0,0 +1,100 @@
  1
+# -*- coding:utf-8 -*-
  2
+from datetime import datetime, timedelta
  3
+from calendar import timegm
  4
+
  5
+from django.test import TestCase
  6
+from django.utils.http import parse_etags, quote_etag
  7
+
  8
+FULL_RESPONSE = 'Test conditional get response'
  9
+LAST_MODIFIED = datetime(2007, 10, 21, 23, 21, 47)
  10
+LAST_MODIFIED_STR = 'Sun, 21 Oct 2007 23:21:47 GMT'
  11
+EXPIRED_LAST_MODIFIED_STR = 'Sat, 20 Oct 2007 23:21:47 GMT'
  12
+ETAG = 'b4246ffc4f62314ca13147c9d4f76974'
  13
+EXPIRED_ETAG = '7fae4cd4b0f81e7d2914700043aa8ed6'
  14
+
  15
+class ConditionalGet(TestCase):
  16
+    def assertFullResponse(self, response, check_last_modified=True, check_etag=True):
  17
+        self.assertEquals(response.status_code, 200)
  18
+        self.assertEquals(response.content, FULL_RESPONSE)
  19
+        if check_last_modified:
  20
+            self.assertEquals(response['Last-Modified'], LAST_MODIFIED_STR)
  21
+        if check_etag:
  22
+            self.assertEquals(response['ETag'], '"%s"' % ETAG)
  23
+
  24
+    def assertNotModified(self, response):
  25
+        self.assertEquals(response.status_code, 304)
  26
+        self.assertEquals(response.content, '')
  27
+
  28
+    def testWithoutConditions(self):
  29
+        response = self.client.get('/condition/')
  30
+        self.assertFullResponse(response)
  31
+
  32
+    def testIfModifiedSince(self):
  33
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
  34
+        response = self.client.get('/condition/')
  35
+        self.assertNotModified(response)
  36
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
  37
+        response = self.client.get('/condition/')
  38
+        self.assertFullResponse(response)
  39
+
  40
+    def testIfNoneMatch(self):
  41
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
  42
+        response = self.client.get('/condition/')
  43
+        self.assertNotModified(response)
  44
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % EXPIRED_ETAG
  45
+        response = self.client.get('/condition/')
  46
+        self.assertFullResponse(response)
  47
+
  48
+        # Several etags in If-None-Match is a bit exotic but why not?
  49
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s", "%s"' % (ETAG, EXPIRED_ETAG)
  50
+        response = self.client.get('/condition/')
  51
+        self.assertNotModified(response)
  52
+
  53
+    def testBothHeaders(self):
  54
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
  55
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
  56
+        response = self.client.get('/condition/')
  57
+        self.assertNotModified(response)
  58
+
  59
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
  60
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
  61
+        response = self.client.get('/condition/')
  62
+        self.assertFullResponse(response)
  63
+
  64
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
  65
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % EXPIRED_ETAG
  66
+        response = self.client.get('/condition/')
  67
+        self.assertFullResponse(response)
  68
+
  69
+    def testSingleCondition1(self):
  70
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
  71
+        response = self.client.get('/condition/last_modified/')
  72
+        self.assertNotModified(response)
  73
+        response = self.client.get('/condition/etag/')
  74
+        self.assertFullResponse(response, check_last_modified=False)
  75
+
  76
+    def testSingleCondition2(self):
  77
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
  78
+        response = self.client.get('/condition/etag/')
  79
+        self.assertNotModified(response)
  80
+        response = self.client.get('/condition/last_modified/')
  81
+        self.assertFullResponse(response, check_etag=False)
  82
+
  83
+    def testSingleCondition3(self):
  84
+        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
  85
+        response = self.client.get('/condition/last_modified/')
  86
+        self.assertFullResponse(response, check_etag=False)
  87
+
  88
+    def testSingleCondition4(self):
  89
+        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % EXPIRED_ETAG
  90
+        response = self.client.get('/condition/etag/')
  91
+        self.assertFullResponse(response, check_last_modified=False)
  92
+
  93
+class ETagProcesing(TestCase):
  94
+    def testParsing(self):
  95
+        etags = parse_etags(r'"", "etag", "e\"t\"ag", "e\\tag", W/"weak"')
  96
+        self.assertEquals(etags, ['', 'etag', 'e"t"ag', r'e\tag', 'weak'])
  97
+
  98
+    def testQuoting(self):
  99
+        quoted_etag = quote_etag(r'e\t"ag')
  100
+        self.assertEquals(quoted_etag, r'"e\\t\"ag"')
8  tests/regressiontests/conditional_processing/urls.py
... ...
@@ -0,0 +1,8 @@
  1
+from django.conf.urls.defaults import *
  2
+import views
  3
+
  4
+urlpatterns = patterns('',
  5
+    ('^$', views.index),
  6
+    ('^last_modified/$', views.last_modified),
  7
+    ('^etag/$', views.etag),
  8
+)
17  tests/regressiontests/conditional_processing/views.py
... ...
@@ -0,0 +1,17 @@
  1
+# -*- coding:utf-8 -*-
  2
+from django.views.decorators.http import condition
  3
+from django.http import HttpResponse
  4
+
  5
+from models import FULL_RESPONSE, LAST_MODIFIED, ETAG
  6
+
  7
+@condition(lambda r: ETAG, lambda r: LAST_MODIFIED)
  8
+def index(request):
  9
+    return HttpResponse(FULL_RESPONSE)
  10
+
  11
+@condition(last_modified_func=lambda r: LAST_MODIFIED)
  12
+def last_modified(request):
  13
+    return HttpResponse(FULL_RESPONSE)
  14
+
  15
+@condition(etag_func=lambda r: ETAG)
  16
+def etag(request):
  17
+    return HttpResponse(FULL_RESPONSE)
7  tests/urls.py
@@ -20,11 +20,11 @@
20 20
 
21 21
     # test urlconf for middleware tests
22 22
     (r'^middleware/', include('regressiontests.middleware.urls')),
23  
-    
  23
+
24 24
     # admin view tests
25 25
     (r'^test_admin/', include('regressiontests.admin_views.urls')),
26 26
     (r'^generic_inline_admin/', include('regressiontests.generic_inline_admin.urls')),
27  
-    
  27
+
28 28
     # admin widget tests
29 29
     (r'widget_admin/', include('regressiontests.admin_widgets.urls')),
30 30
 
@@ -32,4 +32,7 @@
32 32
 
33 33
     # test urlconf for syndication tests
34 34
     (r'^syndication/', include('regressiontests.syndication.urls')),
  35
+
  36
+    # conditional get views
  37
+    (r'condition/', include('regressiontests.conditional_processing.urls')),
35 38
 )

0 notes on commit b203db6

Please sign in to comment.
Something went wrong with that request. Please try again.