Skip to content

Commit

Permalink
Merge pull request #386 from magv/master
Browse files Browse the repository at this point in the history
Make StaticRoute support Last-Modified and If-Modified-Since headers
  • Loading branch information
asvetlov committed Jun 5, 2015
2 parents ff3dec4 + 7823f61 commit 8c3d16c
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 6 deletions.
49 changes: 47 additions & 2 deletions aiohttp/web_reqrep.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
import binascii
import cgi
import collections
import datetime
import http.cookies
import io
import json
import math
import time
import warnings

from urllib.parse import urlsplit, parse_qsl, unquote
from email.utils import parsedate
from types import MappingProxyType
from urllib.parse import urlsplit, parse_qsl, unquote

from . import hdrs
from .helpers import reify
Expand Down Expand Up @@ -65,7 +69,6 @@ def content_length(self, _CONTENT_LENGTH=hdrs.CONTENT_LENGTH):
else:
return int(l)


FileField = collections.namedtuple('Field', 'name filename file content_type')


Expand Down Expand Up @@ -198,6 +201,20 @@ def headers(self):
"""A case-insensitive multidict proxy with all headers."""
return self._headers

@property
def if_modified_since(self, _IF_MODIFIED_SINCE=hdrs.IF_MODIFIED_SINCE):
"""The value of If-Modified-Since HTTP header, or None.
This header is represented as a `datetime` object.
"""
httpdate = self.headers.get(_IF_MODIFIED_SINCE)
if httpdate is not None:
timetuple = parsedate(httpdate)
if timetuple is not None:
return datetime.datetime(*timetuple[:6],
tzinfo=datetime.timezone.utc)
return None

@property
def keep_alive(self):
"""Is keepalive enabled by client?"""
Expand Down Expand Up @@ -513,6 +530,34 @@ def charset(self, value):
self._content_dict['charset'] = str(value).lower()
self._generate_content_type_header()

@property
def last_modified(self, _LAST_MODIFIED=hdrs.LAST_MODIFIED):
"""The value of Last-Modified HTTP header, or None.
This header is represented as a `datetime` object.
"""
httpdate = self.headers.get(_LAST_MODIFIED)
if httpdate is not None:
timetuple = parsedate(httpdate)
if timetuple is not None:
return datetime.datetime(*timetuple[:6],
tzinfo=datetime.timezone.utc)
return None

@last_modified.setter
def last_modified(self, value):
if value is None:
if hdrs.LAST_MODIFIED in self.headers:
del self.headers[hdrs.LAST_MODIFIED]
elif isinstance(value, (int, float)):
self.headers[hdrs.LAST_MODIFIED] = time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", time.gmtime(math.ceil(value)))
elif isinstance(value, datetime.datetime):
self.headers[hdrs.LAST_MODIFIED] = time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", value.utctimetuple())
elif isinstance(value, str):
self.headers[hdrs.LAST_MODIFIED] = value

def _generate_content_type_header(self, CONTENT_TYPE=hdrs.CONTENT_TYPE):
params = '; '.join("%s=%s" % i for i in self._content_dict.items())
if params:
Expand Down
16 changes: 12 additions & 4 deletions aiohttp/web_urldispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from . import hdrs
from .abc import AbstractRouter, AbstractMatchInfo
from .protocol import HttpVersion11
from .web_exceptions import HTTPMethodNotAllowed, HTTPNotFound
from .web_exceptions import HTTPMethodNotAllowed, HTTPNotFound, HTTPNotModified
from .web_reqrep import StreamResponse


Expand Down Expand Up @@ -169,22 +169,30 @@ def url(self, *, filename, query=None):

@asyncio.coroutine
def handle(self, request):
resp = StreamResponse()
filename = request.match_info['filename']
filepath = os.path.abspath(os.path.join(self._directory, filename))
if not filepath.startswith(self._directory):
raise HTTPNotFound()
if not os.path.exists(filepath) or not os.path.isfile(filepath):
raise HTTPNotFound()

st = os.stat(filepath)

modsince = request.if_modified_since
if modsince is not None and st.st_mtime <= modsince.timestamp():
raise HTTPNotModified()

ct, encoding = mimetypes.guess_type(filepath)
if not ct:
ct = 'application/octet-stream'

resp = StreamResponse()
resp.content_type = ct
if encoding:
resp.headers['content-encoding'] = encoding
resp.headers[hdrs.CONTENT_ENCODING] = encoding
resp.last_modified = st.st_mtime

file_size = os.stat(filepath).st_size
file_size = st.st_size
single_chunk = file_size < self._chunk_size

if single_chunk:
Expand Down
18 changes: 18 additions & 0 deletions docs/web_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,15 @@ first positional parameter.

Returns :class:`int` or ``None`` if *Content-Length* is absent.

.. attribute:: if_modified_since

Read-only property that returns the date specified in the
*If-Modified-Since* header.

Returns :class:`datetime.datetime` or ``None`` if
*If-Modified-Since* header is absent or is not a valid
HTTP date.

.. coroutinemethod:: read()

Read request body, returns :class:`bytes` object with body content.
Expand Down Expand Up @@ -503,6 +512,15 @@ StreamResponse

The value converted to lower-case on attribute assigning.

.. attribute:: last_modified

*Last-Modified* header for outgoing response.

This property accepts raw :class:`str` values,
:class:`datetime.datetime` objects, Unix timestamps specified
as an :class:`int` or a :class:`float` object, and the
value ``None`` to unset the header.

.. method:: start(request)

:param aiohttp.web.Request request: HTTP request object, that the
Expand Down
81 changes: 81 additions & 0 deletions tests/test_web_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,87 @@ def go(dirname, relpath):
filename = '../README.rst'
self.loop.run_until_complete(go(here, filename))

def test_static_file_if_modified_since(self):

@asyncio.coroutine
def go(dirname, filename):
app, _, url = yield from self.create_server(
'GET', '/static/' + filename
)
app.router.add_static('/static', dirname)

resp = yield from request('GET', url, loop=self.loop)
self.assertEqual(200, resp.status)
lastmod = resp.headers.get('Last-Modified')
self.assertIsNotNone(lastmod)
resp.close()

resp = yield from request('GET', url, loop=self.loop,
headers={'If-Modified-Since': lastmod})
self.assertEqual(304, resp.status)
resp.close()

here = os.path.dirname(__file__)
filename = 'data.unknown_mime_type'
self.loop.run_until_complete(go(here, filename))

def test_static_file_if_modified_since_past_date(self):

@asyncio.coroutine
def go(dirname, filename):
app, _, url = yield from self.create_server(
'GET', '/static/' + filename
)
app.router.add_static('/static', dirname)

lastmod = 'Mon, 1 Jan 1990 01:01:01 GMT'
resp = yield from request('GET', url, loop=self.loop,
headers={'If-Modified-Since': lastmod})
self.assertEqual(200, resp.status)
resp.close()

here = os.path.dirname(__file__)
filename = 'data.unknown_mime_type'
self.loop.run_until_complete(go(here, filename))

def test_static_file_if_modified_since_future_date(self):

@asyncio.coroutine
def go(dirname, filename):
app, _, url = yield from self.create_server(
'GET', '/static/' + filename
)
app.router.add_static('/static', dirname)

lastmod = 'Fri, 31 Dec 9999 23:59:59 GMT'
resp = yield from request('GET', url, loop=self.loop,
headers={'If-Modified-Since': lastmod})
self.assertEqual(304, resp.status)
resp.close()

here = os.path.dirname(__file__)
filename = 'data.unknown_mime_type'
self.loop.run_until_complete(go(here, filename))

def test_static_file_if_modified_since_invalid_date(self):

@asyncio.coroutine
def go(dirname, filename):
app, _, url = yield from self.create_server(
'GET', '/static/' + filename
)
app.router.add_static('/static', dirname)

lastmod = 'not a valid HTTP-date'
resp = yield from request('GET', url, loop=self.loop,
headers={'If-Modified-Since': lastmod})
self.assertEqual(200, resp.status)
resp.close()

here = os.path.dirname(__file__)
filename = 'data.unknown_mime_type'
self.loop.run_until_complete(go(here, filename))

def test_static_route_path_existence_check(self):
directory = os.path.dirname(__file__)
web.StaticRoute(None, "/", directory)
Expand Down
37 changes: 37 additions & 0 deletions tests/test_web_response.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import datetime
import unittest
from unittest import mock
from aiohttp import hdrs
Expand Down Expand Up @@ -103,6 +104,42 @@ def test_charset_without_content_type(self):
with self.assertRaises(RuntimeError):
resp.charset = 'koi8-r'

def test_last_modified_initial(self):
resp = StreamResponse()
self.assertIsNone(resp.last_modified)

def test_last_modified_string(self):
resp = StreamResponse()

dt = datetime.datetime(1990, 1, 2, 3, 4, 5, 0, datetime.timezone.utc)
resp.last_modified = 'Mon, 2 Jan 1990 03:04:05 GMT'
self.assertEqual(resp.last_modified, dt)

def test_last_modified_timestamp(self):
resp = StreamResponse()

dt = datetime.datetime(1970, 1, 1, 0, 0, 0, 0, datetime.timezone.utc)

resp.last_modified = 0
self.assertEqual(resp.last_modified, dt)

resp.last_modified = 0.0
self.assertEqual(resp.last_modified, dt)

def test_last_modified_datetime(self):
resp = StreamResponse()

dt = datetime.datetime(2001, 2, 3, 4, 5, 6, 0, datetime.timezone.utc)
resp.last_modified = dt
self.assertEqual(resp.last_modified, dt)

def test_last_modified_reset(self):
resp = StreamResponse()

resp.last_modified = 0
resp.last_modified = None
self.assertEqual(resp.last_modified, None)

@mock.patch('aiohttp.web_reqrep.ResponseImpl')
def test_start(self, ResponseImpl):
req = self.make_request('GET', '/')
Expand Down

0 comments on commit 8c3d16c

Please sign in to comment.