Skip to content

Commit 394f7d0

Browse files
Cycloctanevytas7
andauthored
feat(static): implement Last-Modified header for static routes (#2426)
* add last-modified support for static * add mtime attr to static test fakestat * update _open_range docstring * close file handler before raising exception * fix `resp.last_modified` type * add tests * replace `os.fstat` with `os.stat` * add tests for read error * remove useless st_mode in fakestat * handle permission error for os.stat * add more tests for permission error * format * revert "replace `os.fstat` with `os.stat`" * add test for coverage * update tests * fix pep8 warning * Format with `ruff` * add docstring for _open_file * remove PermissionError handler * add changelog * fix test * docs(static): improve documentation of static routes * fix(static): drop the microsecond part in If-Modified-Since comparison --------- Co-authored-by: Vytautas Liuolia <[email protected]>
1 parent 62c80e6 commit 394f7d0

File tree

4 files changed

+133
-22
lines changed

4 files changed

+133
-22
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:class:`~falcon.routing.StaticRoute` now sets the ``Last-Modified`` header when
2+
serving static files. The improved implementation also checks the value of the
3+
``If-Modified-Since`` header, and renders an HTTP 304 response when the
4+
requested file has not been modified.

docs/api/routing.rst

+15
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,21 @@ be used by custom routing engines.
483483
.. autofunction:: falcon.app_helpers.prepare_middleware_ws
484484

485485

486+
Static File Routes
487+
------------------
488+
489+
Falcon can serve static files directly from a WSGI or ASGI application
490+
using the below sink-like :class:`~falcon.routing.StaticRoute`.
491+
492+
Instances of :class:`~falcon.routing.StaticRoute` are normally created via
493+
:meth:`falcon.App.add_static_route`
494+
(please see, however, the documentation of :meth:`~falcon.App.add_static_route`
495+
for the performance implications of serving files through a Python app).
496+
497+
.. autoclass:: falcon.routing.StaticRoute
498+
:members:
499+
500+
486501
Custom HTTP Methods
487502
-------------------
488503

falcon/routing/static.py

+51-21
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import asyncio
4+
from datetime import datetime
5+
from datetime import timezone
46
from functools import partial
57
import io
68
import os
@@ -17,13 +19,33 @@
1719
from falcon import Response
1820

1921

20-
def _open_range(
21-
file_path: Union[str, Path], req_range: Optional[Tuple[int, int]]
22+
def _open_file(file_path: Union[str, Path]) -> Tuple[io.BufferedReader, os.stat_result]:
23+
"""Open a file for a static file request and read file stat.
24+
25+
Args:
26+
file_path (Union[str, Path]): Path to the file to open.
27+
Returns:
28+
tuple: Tuple of (BufferedReader, stat_result).
29+
"""
30+
fh: Optional[io.BufferedReader] = None
31+
try:
32+
fh = io.open(file_path, 'rb')
33+
st = os.fstat(fh.fileno())
34+
except IOError:
35+
if fh is not None:
36+
fh.close()
37+
raise falcon.HTTPNotFound()
38+
return fh, st
39+
40+
41+
def _set_range(
42+
fh: io.BufferedReader, st: os.stat_result, req_range: Optional[Tuple[int, int]]
2243
) -> Tuple[ReadableIO, int, Optional[Tuple[int, int, int]]]:
23-
"""Open a file for a ranged request.
44+
"""Process file handle for a ranged request.
2445
2546
Args:
26-
file_path (str): Path to the file to open.
47+
fh (io.BufferedReader): file handle of the file.
48+
st (os.stat_result): fs stat result of the file.
2749
req_range (Optional[Tuple[int, int]]): Request.range value.
2850
Returns:
2951
tuple: Three-member tuple of (stream, content-length, content-range).
@@ -32,8 +54,7 @@ def _open_range(
3254
possibly bounded, and the content-range will be a tuple of
3355
(start, end, size).
3456
"""
35-
fh = io.open(file_path, 'rb')
36-
size = os.fstat(fh.fileno()).st_size
57+
size = st.st_size
3758
if req_range is None:
3859
return fh, size, None
3960

@@ -217,24 +238,33 @@ def __call__(self, req: Request, resp: Response, **kw: Any) -> None:
217238
if '..' in file_path or not file_path.startswith(self._directory):
218239
raise falcon.HTTPNotFound()
219240

220-
req_range = req.range
221-
if req.range_unit != 'bytes':
222-
req_range = None
223-
try:
224-
stream, length, content_range = _open_range(file_path, req_range)
225-
resp.set_stream(stream, length)
226-
except IOError:
227-
if self._fallback_filename is None:
228-
raise falcon.HTTPNotFound()
241+
if self._fallback_filename is None:
242+
fh, st = _open_file(file_path)
243+
else:
229244
try:
230-
stream, length, content_range = _open_range(
231-
self._fallback_filename, req_range
232-
)
233-
resp.set_stream(stream, length)
245+
fh, st = _open_file(file_path)
246+
except falcon.HTTPNotFound:
247+
fh, st = _open_file(self._fallback_filename)
234248
file_path = self._fallback_filename
235-
except IOError:
236-
raise falcon.HTTPNotFound()
237249

250+
last_modified = datetime.fromtimestamp(st.st_mtime, timezone.utc)
251+
# NOTE(vytas): Strip the microsecond part because that is not reflected
252+
# in HTTP date, and when the client passes a previous value via
253+
# If-Modified-Since, it will look as if our copy is ostensibly newer.
254+
last_modified = last_modified.replace(microsecond=0)
255+
resp.last_modified = last_modified
256+
if req.if_modified_since is not None and last_modified <= req.if_modified_since:
257+
resp.status = falcon.HTTP_304
258+
return
259+
260+
req_range = req.range if req.range_unit == 'bytes' else None
261+
try:
262+
stream, length, content_range = _set_range(fh, st, req_range)
263+
except IOError:
264+
fh.close()
265+
raise falcon.HTTPNotFound()
266+
267+
resp.set_stream(stream, length)
238268
suffix = os.path.splitext(file_path)[1]
239269
resp.content_type = resp.options.static_media_types.get(
240270
suffix, 'application/octet-stream'

tests/test_static.py

+63-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import pathlib
55
import posixpath
6+
from unittest import mock
67

78
import pytest
89

@@ -51,14 +52,15 @@ def create_sr(asgi, prefix, directory, **kwargs):
5152

5253
@pytest.fixture
5354
def patch_open(monkeypatch):
54-
def patch(content=None, validate=None):
55+
def patch(content=None, validate=None, mtime=1736617934):
5556
def open(path, mode):
5657
class FakeFD(int):
5758
pass
5859

5960
class FakeStat:
6061
def __init__(self, size):
6162
self.st_size = size
63+
self.st_mtime = mtime
6264

6365
if validate:
6466
validate(path)
@@ -633,3 +635,63 @@ def test_options_request(client, patch_open):
633635
assert resp.text == ''
634636
assert int(resp.headers['Content-Length']) == 0
635637
assert resp.headers['Access-Control-Allow-Methods'] == 'GET'
638+
639+
640+
def test_last_modified(client, patch_open):
641+
mtime = (1736617934, 'Sat, 11 Jan 2025 17:52:14 GMT')
642+
patch_open(mtime=mtime[0])
643+
644+
client.app.add_static_route('/assets/', '/opt/somesite/assets')
645+
646+
response = client.simulate_request(path='/assets/css/main.css')
647+
assert response.status == falcon.HTTP_200
648+
assert response.headers['Last-Modified'] == mtime[1]
649+
650+
651+
def test_if_modified_since(client, patch_open):
652+
mtime = (1736617934.133701, 'Sat, 11 Jan 2025 17:52:14 GMT')
653+
patch_open(mtime=mtime[0])
654+
655+
client.app.add_static_route('/assets/', '/opt/somesite/assets')
656+
657+
resp = client.simulate_request(
658+
path='/assets/css/main.css',
659+
headers={'If-Modified-Since': 'Sat, 11 Jan 2025 17:52:14 GMT'},
660+
)
661+
assert resp.status == falcon.HTTP_304
662+
assert resp.text == ''
663+
664+
resp = client.simulate_request(
665+
path='/assets/css/main.css',
666+
headers={'If-Modified-Since': 'Sat, 11 Jan 2025 17:52:13 GMT'},
667+
)
668+
assert resp.status == falcon.HTTP_200
669+
assert resp.text != ''
670+
671+
672+
def test_fstat_error(client, patch_open):
673+
patch_open()
674+
675+
client.app.add_static_route('/assets/', '/opt/somesite/assets')
676+
677+
with mock.patch('os.fstat') as m:
678+
m.side_effect = IOError
679+
resp = client.simulate_request(path='/assets/css/main.css')
680+
681+
assert resp.status == falcon.HTTP_404
682+
assert patch_open.current_file is not None
683+
assert patch_open.current_file.closed
684+
685+
686+
def test_set_range_error(client, patch_open):
687+
patch_open()
688+
689+
client.app.add_static_route('/assets/', '/opt/somesite/assets')
690+
691+
with mock.patch('falcon.routing.static._set_range') as m:
692+
m.side_effect = IOError()
693+
resp = client.simulate_request(path='/assets/css/main.css')
694+
695+
assert resp.status == falcon.HTTP_404
696+
assert patch_open.current_file is not None
697+
assert patch_open.current_file.closed

0 commit comments

Comments
 (0)