Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make StaticRoute support Last-Modified and If-Modified-Since headers #386

Merged
merged 4 commits into from
Jun 5, 2015
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion 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,6 +69,33 @@ def content_length(self, _CONTENT_LENGTH=hdrs.CONTENT_LENGTH):
else:
return int(l)

@property
def if_modified_since(self, _IF_MODIFIED_SINCE=hdrs.IF_MODIFIED_SINCE):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move it into Request object: it's request-only header

"""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 last_modified(self, _LAST_MODIFIED=hdrs.LAST_MODIFIED):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move response-only header into Response object

"""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

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

Expand Down Expand Up @@ -513,6 +544,25 @@ def charset(self, value):
self._content_dict['charset'] = str(value).lower()
self._generate_content_type_header()

@property
def last_modified(self):
# Just a placeholder for adding setter
return super().last_modified

@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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please split the test into several (five?) ones.

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