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

feat(static): implement Etag rendering for static files #2333

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
78 changes: 59 additions & 19 deletions falcon/routing/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ def _open_range(
return _BoundedFile(fh, length), length, (start, end, size)


def _generate_etag(file_path: str) -> falcon.ETag:
"""Generate an Etag for a file under a given path.

Args:
file_path (str): Path to the file the ETag should be generated for.

Returns:
falcon.ETag: ETag generated for the file using its modification time and size.
"""
fh = io.open(file_path, 'rb')
content_length = os.fstat(fh.fileno()).st_size
Copy link
Member

Choose a reason for hiding this comment

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

We don't want to run os.fstat() multiple times on the same file handle as it is a real syscall.
We only want to call it once per serving, and save the result in a variable.

last_modified_time = os.fstat(fh.fileno()).st_mtime
return falcon.ETag(f'"{int(last_modified_time):X}-{content_length:X}"')


class _BoundedFile:
"""Wrap a file to only allow part of it to be read.

Expand Down Expand Up @@ -180,6 +195,19 @@ def match(self, path: str) -> bool:
return path.startswith(self._prefix)
return path.startswith(self._prefix) or path == self._prefix[:-1]

def generate_etag(self, file_path: str) -> falcon.ETag:
"""Generate an ETag for a file under the file_path or fallback_filename."""
try:
etag = _generate_etag(file_path)
except IOError:
try:
if self._fallback_filename is None:
raise falcon.HTTPNotFound()
etag = _generate_etag(self._fallback_filename)
Copy link
Member

Choose a reason for hiding this comment

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

Same as above, we are already calling fstat and handling potential errors elsewhere in this file, we can just reuse the same result.

except IOError:
raise falcon.HTTPNotFound()
return etag

def __call__(self, req: Request, resp: Response, **kw: Any) -> None:
"""Resource responder for this route."""
assert not kw
Expand Down Expand Up @@ -217,35 +245,47 @@ def __call__(self, req: Request, resp: Response, **kw: Any) -> None:
if '..' in file_path or not file_path.startswith(self._directory):
raise falcon.HTTPNotFound()

etag = self.generate_etag(file_path)

if req.get_header('If-None-Match') == etag:
resp.status = falcon.HTTP_304
resp.content_type = None
resp.text = None

req_range = req.range
if req.range_unit != 'bytes':
req_range = None
try:
stream, length, content_range = _open_range(file_path, req_range)
resp.set_stream(stream, length)
except IOError:
if self._fallback_filename is None:
raise falcon.HTTPNotFound()

if resp.status != falcon.HTTP_304:
try:
stream, length, content_range = _open_range(
self._fallback_filename, req_range
)
stream, length, content_range = _open_range(file_path, req_range)
resp.set_stream(stream, length)
file_path = self._fallback_filename
except IOError:
raise falcon.HTTPNotFound()
if self._fallback_filename is None:
raise falcon.HTTPNotFound()
try:
stream, length, content_range = _open_range(
self._fallback_filename, req_range
)
resp.set_stream(stream, length)
file_path = self._fallback_filename
except IOError:
raise falcon.HTTPNotFound()

suffix = os.path.splitext(file_path)[1]
resp.content_type = resp.options.static_media_types.get(
suffix, 'application/octet-stream'
)
resp.accept_ranges = 'bytes'

if content_range:
resp.status = falcon.HTTP_206
resp.content_range = content_range

suffix = os.path.splitext(file_path)[1]
resp.content_type = resp.options.static_media_types.get(
suffix, 'application/octet-stream'
)
resp.accept_ranges = 'bytes'
resp.set_header('ETag', etag)

if self._downloadable:
resp.downloadable_as = os.path.basename(file_path)
if content_range:
resp.status = falcon.HTTP_206
resp.content_range = content_range


class StaticRouteAsync(StaticRoute):
Expand Down
162 changes: 159 additions & 3 deletions tests/test_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from falcon.routing import StaticRoute
from falcon.routing import StaticRouteAsync
from falcon.routing.static import _BoundedFile
from falcon.routing.static import _generate_etag
import falcon.testing as testing


Expand Down Expand Up @@ -57,16 +58,17 @@ class FakeFD(int):
pass

class FakeStat:
def __init__(self, size):
def __init__(self, size, mtime):
self.st_size = size
self.st_mtime = mtime

if validate:
validate(path)

data = path.encode() if content is None else content
fake_file = io.BytesIO(data)
fd = FakeFD(1337)
fd._stat = FakeStat(len(data))
fd._stat = FakeStat(len(data), 1297230027)
fake_file.fileno = lambda: fd

patch.current_file = fake_file
Expand Down Expand Up @@ -277,6 +279,10 @@ def test_range_requests(
monkeypatch,
use_fallback,
):
monkeypatch.setattr(
'falcon.routing.static._generate_etag', lambda path: '"fixed-etag"'
)

def validate(path):
if use_fallback and not path.endswith('index.html'):
raise OSError(errno.ENOENT, 'File not found')
Expand Down Expand Up @@ -441,7 +447,11 @@ def test_downloadable(client, patch_open):
assert 'Content-Disposition' not in response.headers


def test_downloadable_not_found(client):
def test_downloadable_not_found(client, monkeypatch):
monkeypatch.setattr(
'falcon.routing.static._generate_etag', lambda path: '"fixed-etag"'
)

client.app.add_static_route(
'/downloads', '/opt/somesite/downloads', downloadable=True
)
Expand Down Expand Up @@ -479,6 +489,10 @@ def test_fallback_filename(
patch_open,
monkeypatch,
):
monkeypatch.setattr(
'falcon.routing.static._generate_etag', lambda path: '"fixed-etag"'
)

def validate(path):
if normalize_path(default) not in path:
raise IOError()
Expand Down Expand Up @@ -537,6 +551,10 @@ async def run():
def test_e2e_fallback_filename(
client, patch_open, monkeypatch, strip_slash, path, fallback, static_exp, assert_axp
):
monkeypatch.setattr(
'falcon.routing.static._generate_etag', lambda path: '"fixed-etag"'
)

def validate(path):
if 'index' not in path or 'raise' in path:
raise IOError()
Expand Down Expand Up @@ -633,3 +651,141 @@ def test_options_request(client, patch_open):
assert resp.text == ''
assert int(resp.headers['Content-Length']) == 0
assert resp.headers['Access-Control-Allow-Methods'] == 'GET'


def test_render_etag_header(client, patch_open):
patch_open(b'0123456789abcdef')

client.app.add_static_route('/downloads', '/opt/somesite/downloads')

response = client.simulate_request(path='/downloads/thing.zip')

assert response.status == falcon.HTTP_200
assert response.headers.get('Etag') is not None


def test_renders_the_same_etag_header_when_file_does_not_change(client, patch_open):
patch_open(b'0123456789abcdef')

client.app.add_static_route('/downloads', '/opt/somesite/downloads')

response = client.simulate_request(path='/downloads/thing.zip')

assert response.status == falcon.HTTP_200
assert response.headers.get('Etag') is not None

first_etag = response.headers.get('Etag')

response = client.simulate_request(path='/downloads/thing.zip')

assert response.status == falcon.HTTP_200
assert response.headers.get('Etag') is not None

second_etag = response.headers.get('Etag')

assert first_etag == second_etag


def test_if_none_match_header_when_etag_has_not_changed(client, patch_open):
patch_open(b'0123456789abcdef')

client.app.add_static_route('/downloads', '/opt/somesite/downloads')

expected_etag = _generate_etag('/downloads/thing.zip')

response = client.simulate_request(
path='/downloads/thing.zip', headers={'If-None-Match': expected_etag}
)
assert response.status == falcon.HTTP_304
assert response.text == ''


def test_if_none_match_header_when_etag_has_changed(client, patch_open):
patch_open(b'0123456789abcdef')

client.app.add_static_route('/downloads', '/opt/somesite/downloads')

response = client.simulate_request(
path='/downloads/thing.zip', headers={'If-None-Match': 'outdated etag'}
)
assert response.status == falcon.HTTP_200
assert response.headers.get('Etag') is not None


def test_etag_with_fallback_filename(client, patch_open, monkeypatch):
def fake_generate_etag(file_path):
if normalize_path('thing.zip') in file_path:
raise OSError(errno.ENOENT, 'File not found')
elif normalize_path('index.html') in file_path:
return '"etag-fallback-file"'
return '"some etag"'

monkeypatch.setattr('falcon.routing.static._generate_etag', fake_generate_etag)

patch_open(b'<html>Fallback content</html>')

monkeypatch.setattr('os.path.isfile', lambda file: file.endswith('index.html'))

client.app.add_static_route(
'/downloads', '/opt/somesite/downloads', fallback_filename='index.html'
)

response = client.simulate_request(path='/downloads/thing.zip')

assert response.status == falcon.HTTP_200
assert response.text == '<html>Fallback content</html>'
assert response.headers.get('Etag') == '"etag-fallback-file"'


def test_etag_with_fallback_filename_also_missing(client, patch_open, monkeypatch):
def fake_generate_etag(file_path):
if normalize_path('thing.zip') in file_path:
raise OSError(errno.ENOENT, 'File not found')
elif normalize_path('index.html') in file_path:
raise OSError(errno.ENOENT, 'File not found')
return '"some etag"'

monkeypatch.setattr('falcon.routing.static._generate_etag', fake_generate_etag)

def validate(path):
if normalize_path('thing.zip') in path:
raise IOError()

patch_open(b'<html>Fallback content</html>', validate)

monkeypatch.setattr('os.path.isfile', lambda file: file.endswith('index.html'))

client.app.add_static_route(
'/downloads', '/opt/somesite/downloads', fallback_filename='index.html'
)

response = client.simulate_request(path='/downloads/thing.zip')

assert response.headers.get('Etag') is None
assert response.status == falcon.HTTP_404
assert response.text != '<html>Fallback content</html>'


def test_etag_with_no_fallback_filename(client, patch_open, monkeypatch):
def fake_generate_etag(file_path):
if normalize_path('thing.zip') in file_path:
raise OSError(errno.ENOENT, 'File not found')
return '"some etag"'

monkeypatch.setattr('falcon.routing.static._generate_etag', fake_generate_etag)

def validate(path):
if normalize_path('thing.zip') in path:
raise IOError()

patch_open(b'<html>Fallback content</html>', validate)

client.app.add_static_route(
'/downloads', '/opt/somesite/downloads', fallback_filename=None
)

response = client.simulate_request(path='/downloads/thing.zip')

assert response.headers.get('Etag') is None
assert response.status == falcon.HTTP_404
assert response.text != '<html>Fallback content</html>'