| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2007-2008 Christopher Lenz |
|---|
| 4 | # All rights reserved. |
|---|
| 5 | # |
|---|
| 6 | # This software is licensed as described in the file COPYING, which |
|---|
| 7 | # you should have received as part of this distribution. |
|---|
| 8 | |
|---|
| 9 | """Various objects and functions for working with HTTP exchanges.""" |
|---|
| 10 | |
|---|
| 11 | from calendar import timegm |
|---|
| 12 | from datetime import datetime, timedelta |
|---|
| 13 | from email.Utils import formatdate |
|---|
| 14 | import logging |
|---|
| 15 | |
|---|
| 16 | from diva import app |
|---|
| 17 | from diva.core import register_filter |
|---|
| 18 | from diva.util import decorator |
|---|
| 19 | from webob.exc import status_map |
|---|
| 20 | |
|---|
| 21 | __all__ = ['abort', 'allow', 'caching', 'match'] |
|---|
| 22 | __docformat__ = 'restructuredtext en' |
|---|
| 23 | |
|---|
| 24 | log = logging.getLogger('diva.http') |
|---|
| 25 | |
|---|
| 26 | |
|---|
| 27 | def abort(status, message='', headers=None): |
|---|
| 28 | """Aborts the request processing immediately by raising an HTTP exception |
|---|
| 29 | corresponding to the specified status code. |
|---|
| 30 | |
|---|
| 31 | >>> abort(404) |
|---|
| 32 | Traceback (most recent call last): |
|---|
| 33 | ... |
|---|
| 34 | HTTPNotFound: 404 Not Found |
|---|
| 35 | Content-Type: text/html; charset=UTF-8 |
|---|
| 36 | Content-Length: 0 |
|---|
| 37 | <BLANKLINE> |
|---|
| 38 | <BLANKLINE> |
|---|
| 39 | |
|---|
| 40 | >>> abort(301, headers=[('Location', 'http://example.org/')]) |
|---|
| 41 | Traceback (most recent call last): |
|---|
| 42 | ... |
|---|
| 43 | HTTPMovedPermanently: 301 Moved Permanently |
|---|
| 44 | Content-Type: text/html; charset=UTF-8 |
|---|
| 45 | Content-Length: 0 |
|---|
| 46 | Location: http://example.org/ |
|---|
| 47 | <BLANKLINE> |
|---|
| 48 | <BLANKLINE> |
|---|
| 49 | |
|---|
| 50 | :param status: the HTTP status code |
|---|
| 51 | :param message: an optional message to be displayed to the user (for example |
|---|
| 52 | on the error page) |
|---|
| 53 | :param headers: a list of ``(name, value)`` tuples that define any headers |
|---|
| 54 | that should be included with the response |
|---|
| 55 | """ |
|---|
| 56 | log.debug('Abort with status %d (%r)', status, message) |
|---|
| 57 | raise status_map[status](headers=headers, detail=message).exception |
|---|
| 58 | |
|---|
| 59 | |
|---|
| 60 | def allow(*methods): |
|---|
| 61 | """Decorator for request handlers that handle only requests using the |
|---|
| 62 | specified methods. |
|---|
| 63 | |
|---|
| 64 | Requests using a method not in the allowed list result in a ``405 Method |
|---|
| 65 | Not Allowed`` error response. |
|---|
| 66 | |
|---|
| 67 | Note that allowing ``GET`` implies allowing ``HEAD``. |
|---|
| 68 | |
|---|
| 69 | :param methods: the allowed request methods as strings |
|---|
| 70 | """ |
|---|
| 71 | methods = [m.upper() for m in methods] |
|---|
| 72 | if 'GET' in methods and 'HEAD' not in methods: |
|---|
| 73 | methods.append('HEAD') |
|---|
| 74 | def decorate(func): |
|---|
| 75 | @decorator(func) |
|---|
| 76 | def wrapper(*args, **kwargs): |
|---|
| 77 | __traceback_hide__ = True |
|---|
| 78 | if app.ctxt.request.method.upper() not in methods: |
|---|
| 79 | abort(405, headers=[('Allow', ','.join(methods))]) |
|---|
| 80 | return func(*args, **kwargs) |
|---|
| 81 | return wrapper |
|---|
| 82 | return decorate |
|---|
| 83 | |
|---|
| 84 | |
|---|
| 85 | def _add_caching_headers(response, max_age=None, vary_on=None, **options): |
|---|
| 86 | status = response.status_int |
|---|
| 87 | if status < 200 or status >= 300: |
|---|
| 88 | # Caching headers only for success status codes |
|---|
| 89 | return |
|---|
| 90 | |
|---|
| 91 | if max_age is not None and not isinstance(max_age, timedelta): |
|---|
| 92 | max_age = timedelta(0, max_age) |
|---|
| 93 | |
|---|
| 94 | headers = response.headers |
|---|
| 95 | if 'Cache-Control' not in headers: |
|---|
| 96 | params = [] |
|---|
| 97 | for name, value in options.items(): |
|---|
| 98 | name = name.replace('_', '-') |
|---|
| 99 | if value is True: |
|---|
| 100 | params.append(name) |
|---|
| 101 | if max_age is not None: |
|---|
| 102 | params.append('max-age=%d' % ( |
|---|
| 103 | max_age.seconds + max_age.days * 86400 |
|---|
| 104 | )) |
|---|
| 105 | if params: |
|---|
| 106 | headers['Cache-Control'] = '; '.join(params) |
|---|
| 107 | |
|---|
| 108 | if max_age is not None and 'Expires' not in headers: |
|---|
| 109 | expires = datetime.utcnow() + max_age |
|---|
| 110 | headers['Expires'] = expires.strftime('%a, %d %b %Y %H:%M:%S GMT') |
|---|
| 111 | |
|---|
| 112 | if vary_on and 'Vary' not in headers: |
|---|
| 113 | headers['Vary'] = ','.join(vary_on) |
|---|
| 114 | |
|---|
| 115 | |
|---|
| 116 | def caching(max_age=None, vary_on=None, **options): |
|---|
| 117 | """Decorator that allows controlling HTTP headers related to caching. |
|---|
| 118 | |
|---|
| 119 | :param max_age: the maximum number of seconds the output should be cached |
|---|
| 120 | :param vary_on: a list of HTTP header names that should be taken into |
|---|
| 121 | account by cache |
|---|
| 122 | :keyword options: any additional keyword arguments are added to the |
|---|
| 123 | "Cache-Control" header of the response if they have a |
|---|
| 124 | truth value; for example, ``must_revalidate=1`` would add |
|---|
| 125 | the "must-revalidate" flag to the header |
|---|
| 126 | """ |
|---|
| 127 | def decorate(func): |
|---|
| 128 | @decorator(func) |
|---|
| 129 | def wrapper(*args, **kwargs): |
|---|
| 130 | __traceback_hide__ = True |
|---|
| 131 | retval = func(*args, **kwargs) |
|---|
| 132 | _add_caching_headers(app.ctxt.response, max_age=max_age, |
|---|
| 133 | vary_on=vary_on, **options) |
|---|
| 134 | return retval |
|---|
| 135 | return wrapper |
|---|
| 136 | return decorate |
|---|
| 137 | |
|---|
| 138 | |
|---|
| 139 | def match(etag=None, last_modified=None): |
|---|
| 140 | """Checks the current request for conditional GET semantics based on the |
|---|
| 141 | "If-Modified-Since" and/or "If-None-Match" headers. |
|---|
| 142 | |
|---|
| 143 | If the request includes one of those headers, and their value matches the |
|---|
| 144 | given parameters, a "204 Not Modified" response is sent. Otherwise, the |
|---|
| 145 | headers "Last-Modified" and/or "ETag" are headers are added to the response. |
|---|
| 146 | |
|---|
| 147 | :param etag: the entity tag value |
|---|
| 148 | :param last_modified: the last modification date (either as a ``datetime`` |
|---|
| 149 | object or as a numeric timestamp) |
|---|
| 150 | """ |
|---|
| 151 | request = app.ctxt.request |
|---|
| 152 | response = app.ctxt.response |
|---|
| 153 | if response.status_int < 200 or response.status_int >= 300: |
|---|
| 154 | # Only do the matching for responses with a HTTP success codes |
|---|
| 155 | return |
|---|
| 156 | |
|---|
| 157 | if etag is not None: |
|---|
| 158 | response.etag = str('"%s"' % etag) # FIXME: WebOb #251 |
|---|
| 159 | if request.method in ('PUT', 'DELETE') and request.if_match \ |
|---|
| 160 | and etag not in request.if_match: |
|---|
| 161 | log.debug('Etag %r did not match %r', etag, request.if_match) |
|---|
| 162 | abort(412) |
|---|
| 163 | elif request.method in ('GET', 'HEAD') and request.if_none_match \ |
|---|
| 164 | and etag in request.if_none_match: |
|---|
| 165 | log.debug('Etag %r matched %r', etag, request.if_none_match) |
|---|
| 166 | abort(304) |
|---|
| 167 | |
|---|
| 168 | if last_modified is not None: |
|---|
| 169 | response.last_modified = last_modified |
|---|
| 170 | if request.method in ('PUT', 'DELETE') \ |
|---|
| 171 | and request.if_unmodified_since \ |
|---|
| 172 | and response.last_modified != request.if_unmodified_since: |
|---|
| 173 | log.debug('Modification time "%s" did not match "%s"', |
|---|
| 174 | response.last_modified, request.if_unmodified_since) |
|---|
| 175 | abort(412) |
|---|
| 176 | elif request.method in ('GET', 'HEAD') and request.if_modified_since \ |
|---|
| 177 | and response.last_modified == request.if_modified_since: |
|---|
| 178 | log.debug('Modification time "%s" matched "%s"', |
|---|
| 179 | response.last_modified, request.if_modified_since) |
|---|
| 180 | abort(304) |
|---|
| 181 | |
|---|
| 182 | |
|---|
| 183 | @register_filter('caching') |
|---|
| 184 | def caching_filter(request, response, chain): |
|---|
| 185 | """Request filter that adds standard cache disabling headers to responses |
|---|
| 186 | that have no explicit cache control. |
|---|
| 187 | """ |
|---|
| 188 | try: |
|---|
| 189 | return chain.next()(request, response, chain) |
|---|
| 190 | finally: |
|---|
| 191 | _add_caching_headers(response, max_age=0) |
|---|