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

add append_version feature into StaticResource url resolver #2158

Merged
merged 11 commits into from
Aug 5, 2017
1 change: 1 addition & 0 deletions CONTRIBUTORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Günther Jena
Hu Bo
Hugo Herter
Hynek Schlawack
Igor Alexandrov
Igor Davydenko
Igor Pavlov
Ingmar Steen
Expand Down
59 changes: 49 additions & 10 deletions aiohttp/web_urldispatcher.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import abc
import asyncio
import base64
import collections
import hashlib
import inspect
import keyword
import os
Expand Down Expand Up @@ -422,11 +424,13 @@ def add_prefix(self, prefix):


class StaticResource(PrefixResource):
VERSION_KEY = 'v'

def __init__(self, prefix, directory, *, name=None,
expect_handler=None, chunk_size=256*1024,
expect_handler=None, chunk_size=256 * 1024,
response_factory=StreamResponse,
show_index=False, follow_symlinks=False):
show_index=False, follow_symlinks=False,
append_version=False):
super().__init__(prefix, name=name)
try:
directory = Path(directory)
Expand All @@ -443,24 +447,57 @@ def __init__(self, prefix, directory, *, name=None,
self._chunk_size = chunk_size
self._follow_symlinks = follow_symlinks
self._expect_handler = expect_handler
self._append_version = append_version

self._routes = {'GET': ResourceRoute('GET', self._handle, self,
expect_handler=expect_handler),

'HEAD': ResourceRoute('HEAD', self._handle, self,
expect_handler=expect_handler)}

def url(self, *, filename, query=None):
return str(self.url_for(filename=filename).with_query(query))

def url_for(self, *, filename):
def url(self, *, filename, append_version=None, query=None):
# is there with_query need to be used?
# with_query remove previous query options
url = self.url_for(filename=filename, append_version=append_version)
if query is not None:
return str(url.update_query(query))
Copy link
Member

Choose a reason for hiding this comment

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

Please add a test for this case

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

return str(url)

def url_for(self, *, filename, append_version=None):
if append_version is None:
append_version = self._append_version
if isinstance(filename, Path):
filename = str(filename)
while filename.startswith('/'):
filename = filename[1:]
filename = '/' + filename
url = self._prefix + URL(filename).raw_path
return URL(url)
url = URL(url)
if append_version is True:
try:
if filename.startswith('/'):
Copy link
Member

Choose a reason for hiding this comment

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

Please add a test if filename doesn't start with /

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

filename = filename[1:]
filepath = self._directory.joinpath(filename).resolve()
if not self._follow_symlinks:
Copy link
Member

Choose a reason for hiding this comment

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

Test for _follow_symlink needed

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

filepath.relative_to(self._directory)
except FileNotFoundError:
# relatively safe
return url
if filepath.is_file():
# TODO cache file content
# with file watcher for cache invalidation
with open(str(filepath), mode='rb') as f:
file_bytes = f.read()
h = self._get_file_hash(file_bytes)
url = url.with_query({self.VERSION_KEY: h})
return url
return url

def _get_file_hash(self, byte_array):
m = hashlib.sha256() # todo sha256 can be configurable param
m.update(byte_array)
b64 = base64.urlsafe_b64encode(m.digest())
return b64.decode('ascii')

def get_info(self):
return {'directory': self._directory,
Expand Down Expand Up @@ -828,8 +865,9 @@ def add_route(self, method, path, handler,
expect_handler=expect_handler)

def add_static(self, prefix, path, *, name=None, expect_handler=None,
chunk_size=256*1024, response_factory=StreamResponse,
show_index=False, follow_symlinks=False):
chunk_size=256 * 1024, response_factory=StreamResponse,
show_index=False, follow_symlinks=False,
append_version=False):
"""Add static files view.

prefix - url prefix
Expand All @@ -845,7 +883,8 @@ def add_static(self, prefix, path, *, name=None, expect_handler=None,
chunk_size=chunk_size,
response_factory=response_factory,
show_index=show_index,
follow_symlinks=follow_symlinks)
follow_symlinks=follow_symlinks,
append_version=append_version)
self.register_resource(resource)
return resource

Expand Down
2 changes: 2 additions & 0 deletions changes/2157.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
add `append_version` arg into `StaticResource.url` and `StaticResource.url_for` methods
for getting an url with hash (version) of the file.
11 changes: 11 additions & 0 deletions docs/web.rst
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,17 @@ symlinks, parameter ``follow_symlinks`` should be set to ``True``::

app.router.add_static('/prefix', path_to_static_folder, follow_symlinks=True)

When you want to enable cache busting,
parameter ``append_version`` can be set to ``True``

Cache busting is the process of appending some form of file version hash
to the filename of resources like JavaScript and CSS files.
The performance advantage of doing this is that we can tell the browser
to cache these files indefinitely without worrying about the client not getting
the latest version when the file changes::

app.router.add_static('/prefix', path_to_static_folder, append_version=True)

Template Rendering
------------------

Expand Down
10 changes: 9 additions & 1 deletion docs/web_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1924,7 +1924,7 @@ Resource classes hierarchy::
The class corresponds to resources for :ref:`static file serving
<aiohttp-web-static-file-handling>`.

.. method:: url_for(filename)
.. method:: url_for(filename, append_version=None)

Returns a :class:`~yarl.URL` for file path under resource prefix.

Expand All @@ -1935,6 +1935,14 @@ Resource classes hierarchy::
E.g. an URL for ``'/prefix/dir/file.txt'`` should
be generated as ``resource.url_for(filename='dir/file.txt')``

:param bool append_version: -- a flag for adding file version (hash) to the url query string for cache boosting

By default has value from an constructor (``False`` by default)
When set to ``True`` - ``v=FILE_HASH`` query string param will be added
When set to ``False`` has no impact

if file not found has no impact

.. versionadded:: 1.1

.. class:: PrefixedSubAppResource
Expand Down
40 changes: 40 additions & 0 deletions tests/test_urldispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,46 @@ def test_add_static(router):
assert len(resource) == 2


def test_add_static_append_version(router):
resource = router.add_static('/st',
os.path.dirname(__file__),
name='static')
url = resource.url(filename='/data.unknown_mime_type', append_version=True)
expect_url = '/st/data.unknown_mime_type?' \
'v=aUsn8CHEhhszc81d28QmlcBW0KQpfS2F4trgQKhOYd8%3D'
assert expect_url == url


def test_add_static_append_version_set_from_constructor(router):
resource = router.add_static('/st',
os.path.dirname(__file__),
append_version=True,
name='static')
url = resource.url(filename='/data.unknown_mime_type')
expect_url = '/st/data.unknown_mime_type?' \
'v=aUsn8CHEhhszc81d28QmlcBW0KQpfS2F4trgQKhOYd8%3D'
assert expect_url == url


def test_add_static_append_version_override_constructor(router):
resource = router.add_static('/st',
os.path.dirname(__file__),
append_version=True,
name='static')
url = resource.url(filename='/data.unknown_mime_type',
append_version=False)
expect_url = '/st/data.unknown_mime_type'
assert expect_url == url


def test_add_static_append_version_non_exists_file(router):
resource = router.add_static('/st',
os.path.dirname(__file__),
name='static')
url = resource.url(filename='/non_exists_file', append_version=True)
assert '/st/non_exists_file' == url


def test_plain_not_match(router):
handler = make_handler()
router.add_route('GET', '/get/path', handler, name='name')
Expand Down