Commit b203db6e authored by Malcolm Tredinnick's avatar Malcolm Tredinnick
Browse files

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
parent 5ac154e0
Loading
Loading
Loading
Loading
+23 −0
Original line number Diff line number Diff line
import re
import urllib
from email.Utils import formatdate

from django.utils.encoding import smart_str, force_unicode
from django.utils.functional import allow_lazy

ETAG_MATCH = re.compile(r'(?:W/)?"((?:\\.|[^"])*)"')

def urlquote(url, safe='/'):
    """
    A version of Python's urllib.quote() function that can operate on unicode
@@ -94,3 +97,23 @@ def int_to_base36(i):
        i = i % j
        factor -= 1
    return ''.join(base36)

def parse_etags(etag_str):
    """
    Parses a string with one or several etags passed in If-None-Match and
    If-Match headers by the rules in RFC 2616. Returns a list of etags
    without surrounding double quotes (") and unescaped from \<CHAR>.
    """
    etags = ETAG_MATCH.findall(etag_str)
    if not etags:
        # etag_str has wrong format, treat it as an opaque string then
        return [etag_str]
    etags = [e.decode('string_escape') for e in etags]
    return etags

def quote_etag(etag):
    """
    Wraps a string in double quotes escaping contents as necesary.
    """
    return '"%s"' % etag.replace('\\', '\\\\').replace('"', '\\"')
+98 −2
Original line number Diff line number Diff line
@@ -7,9 +7,15 @@ try:
except ImportError:
    from django.utils.functional import wraps  # Python 2.3, 2.4 fallback.

from calendar import timegm
from datetime import timedelta
from email.Utils import formatdate

from django.utils.decorators import decorator_from_middleware
from django.utils.http import parse_etags, quote_etag
from django.middleware.http import ConditionalGetMiddleware
from django.http import HttpResponseNotAllowed
from django.http import HttpResponseNotAllowed, HttpResponseNotModified, HttpResponse


conditional_page = decorator_from_middleware(ConditionalGetMiddleware)

@@ -37,3 +43,93 @@ require_GET.__doc__ = "Decorator to require that a view only accept the GET meth

require_POST = require_http_methods(["POST"])
require_POST.__doc__ = "Decorator to require that a view only accept the POST method."

def condition(etag_func=None, last_modified_func=None):
    """
    Decorator to support conditional retrieval (or change) for a view
    function.

    The parameters are callables to compute the ETag and last modified time for
    the requested resource, respectively. The callables are passed the same
    parameters as the view itself. The Etag function should return a string (or
    None if the resource doesn't exist), whilst the last_modified function
    should return a datetime object (or None if the resource doesn't exist).

    If both parameters are provided, all the preconditions must be met before
    the view is processed.

    This decorator will either pass control to the wrapped view function or
    return an HTTP 304 response (unmodified) or 412 response (preconditions
    failed), depending upon the request method.

    Any behavior marked as "undefined" in the HTTP spec (e.g. If-none-match
    plus If-modified-since headers) will result in the view function being
    called.
    """
    def decorator(func):
        def inner(request, *args, **kwargs):
            # Get HTTP request headers
            if_modified_since = request.META.get("HTTP_IF_MODIFIED_SINCE")
            if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
            if_match = request.META.get("HTTP_IF_MATCH")
            if if_none_match or if_match:
                # There can be more than one ETag in the request, so we
                # consider the list of values.
                etags = parse_etags(if_none_match)

            # Compute values (if any) for the requested resource.
            if etag_func:
                res_etag = etag_func(request, *args, **kwargs)
            else:
                res_etag = None
            if last_modified_func:
                dt = last_modified_func(request, *args, **kwargs)
                if dt:
                    res_last_modified = formatdate(timegm(dt.utctimetuple()))[:26] + 'GMT'
                else:
                    res_last_modified = None
            else:
                res_last_modified = None

            response = None
            if not ((if_match and (if_modified_since or if_none_match)) or
                    (if_match and if_none_match)):
                # We only get here if no undefined combinations of headers are
                # specified.
                if ((if_none_match and (res_etag in etags or
                        "*" in etags and res_etag)) and
                        (not if_modified_since or
                            res_last_modified == if_modified_since)):
                    if request.method in ("GET", "HEAD"):
                        response = HttpResponseNotModified()
                    else:
                        response = HttpResponse(status=412)
                elif if_match and ((not res_etag and "*" in etags) or
                        (res_etag and res_etag not in etags)):
                    response = HttpResponse(status=412)
                elif (not if_none_match and if_modified_since and
                        request.method == "GET" and
                        res_last_modified == if_modified_since):
                    response = HttpResponseNotModified()

            if response is None:
                response = func(request, *args, **kwargs)

            # Set relevant headers on the response if they don't already exist.
            if res_last_modified and not response.has_header('Last-Modified'):
                response['Last-Modified'] = res_last_modified
            if res_etag and not response.has_header('ETag'):
                response['ETag'] = quote_etag(res_etag)

            return response

        return inner
    return decorator

# Shortcut decorators for common cases based on ETag or Last-Modified only
def etag(callable):
    return condition(etag=callable)

def last_modified(callable):
    return condition(last_modified=callable)
+1 −0
Original line number Diff line number Diff line
@@ -81,6 +81,7 @@ Other batteries included
    * :ref:`Admin site <ref-contrib-admin>`
    * :ref:`Authentication <topics-auth>`
    * :ref:`Cache system <topics-cache>`
    * :ref:`Conditional content processing <topics-conditional-processing>`
    * :ref:`Comments <ref-contrib-comments-index>`
    * :ref:`Content types <ref-contrib-contenttypes>`
    * :ref:`Cross Site Request Forgery protection <ref-contrib-csrf>`
+134 −0
Original line number Diff line number Diff line
.. _topics-conditional-processing:

===========================
Conditional View Processing
===========================

.. versionadded:: 1.1

HTTP clients can send a number of headers to tell the server about copies of a
resource that they have already seen. This is commonly used when retrieving a
web page (using an HTTP ``GET`` request) to avoid sending all the data for
something the client has already retrieved. However, the same headers can be
used for all HTTP methods (``POST``, ``PUT``, ``DELETE``, etc).

For each page (response) that Django sends back from a view, it might provide
two HTTP headers: the ``ETag`` header and the ``Last-Modified`` header. These
headers are optional on HTTP responses. They can be set by your view function,
or you can rely on the :class:`~django.middleware.common.CommonMiddleware`
middleware to set the ``ETag`` header.

When the client next requests the same resource, it might send along a header
such as `If-modified-since`_, containing the date of the last modification
time it was sent, or `If-none-match`_, containing the ``ETag`` it was sent.
If there is no match with the ETag, or if the resource has not been modified,
a 304 status code can be sent back, instead of a full response, telling the
client that nothing has changed.

.. _If-none-match: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
.. _If-modified-since: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.25

Django allows simple usage of this feature with
:class:`django.middleware.http.ConditionalGetMiddleware` and
:class:`~django.middleware.common.CommonMiddleware`. However, whilst being
easy to use and suitable for many situations, they both have limitations for
advanced usage:

    * They are applied globally to all views in your project
    * They don't save you from generating the response itself, which may be
      expensive
    * They are only appropriate for HTTP ``GET`` requests.

.. conditional-decorators:

Decorators
==========

When you need more fine-grained control you may use per-view conditional
processing functions. 

The decorators ``django.views.decorators.http.etag`` and
``django.views.decorators.http.last_modified`` each accept a user-defined
function that takes the same parameters as the view itself. The function
passed ``last_modified`` should return a standard datetime value specifying
the last time the resource was modified, or ``None`` if the resource doesn't
exist. The function passed to the ``etag`` decorator should return a string
representing the `Etag`_ for the resource, or ``None`` if it doesn't exist.

.. _ETag: http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11

For example::

    # Compute the last-modified time from when the object was last saved.
    @last_modified(lambda r, obj_id: MyObject.objects.get(pk=obj_id).update_time)
    def my_object_view(request, obj_id):
        # Expensive generation of response with MyObject instance
        ...

Of course, you can always use the non-decorator form if you're using Python
2.3 or don't like the decorator syntax::

    def my_object_view(request, obj_id):
        ...
    my_object_view = last_modified(my_func)(my_object_view)

Using the ``etag`` decorator is similar.

In practice, though, you won't know if the client is going to send the
``Last-modified`` or the ``If-none-match`` header. If you can quickly compute
both values and want to short-circuit as often as possible, you'll need to use
the ``conditional`` decorator described below.

HTTP allows to use both "ETag" and "Last-Modified" headers in your response.
Then a response is considered not modified only if the client sends both
headers back and they're both equal to the response headers. This means that
you can't just chain decorators on your view::

    # Bad code. Don't do this!
    @etag(etag_func)
    @last_modified(last_modified_func)
    def my_view(request):
        # ...

    # End of bad code.

The first decorator doesn't know anything about the second and might
answer that the response is not modified even if the second decorators would
determine otherwise. In this case you should use a more general decorator -
``django.views.decorator.http.condition`` that accepts two functions at once::

    # The correct way to implement the above example
    @condition(etag_func, last_modified_func)
    def my_view(request):
        # ...

Using the decorators with other HTTP methods
============================================

The ``conditional`` decorator is useful for more than only ``GET`` and
``HEAD`` requests (``HEAD`` requests are the same as ``GET`` in this
situation). It can be used also to be used to provide checking for ``POST``,
``PUT`` and ``DELETE`` requests. In these situations, the idea isn't to return
a "not modified" response, but to tell the client that the resource they are
trying to change has been altered in the meantime.

For example, consider the following exchange between the client and server:

    1. Client requests ``/foo/``.
    2. Server responds with some content with an ETag of ``"abcd1234"``.
    3. Client sends and HTTP ``PUT`` request to ``/foo/`` to update the
       resource. It sends an ``If-Match: "abcd1234"`` header to specify the
       version it is trying to update.
    4. Server checks to see if the resource has changed, by computing the ETag
       the same way it does for a ``GET`` request (using the same function).
       If the resource *has* changed, it will return a 412 status code code,
       meaning "precondition failed".
    5. Client sends a ``GET`` request to ``/foo/``, after receiving a 412
       response, to retrieve an updated version of the content before updating
       it.

The important thing this example shows is that the same functions can be used
to compute the ETag and last modification values in all situations. In fact,
you *should* use the same functions, so that the same values are returned
every time.
+1 −0
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ Introductions to all the key parts of Django you'll need to know:
   testing
   auth
   cache
   conditional-view-processing
   email
   i18n
   pagination
Loading