Skip to content
Closed
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
9 changes: 9 additions & 0 deletions docs/_newsfragments/2337.newandimproved.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Static routes now support ``HEAD`` requests properly. A ``HEAD`` request
returns all the same headers as a ``GET`` request (``Content-Type``,
``Content-Length``, ``ETag``, etc.) but does not open or stream file
contents.

Additionally, static routes now block unsupported HTTP methods (such as
``POST``, ``PUT``, ``PATCH``, and ``DELETE``) with ``405 Method Not
Allowed``, and the ``Allow`` header in ``OPTIONS`` responses now correctly
lists all supported methods (``GET``, ``HEAD``, ``OPTIONS``).
34 changes: 25 additions & 9 deletions falcon/routing/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,19 @@ def match(self, path: str) -> bool:
return path.startswith(self._prefix)
return path.startswith(self._prefix) or path == self._prefix[:-1]

_ALLOWED_METHODS: ClassVar[frozenset[str]] = frozenset(
{'GET', 'HEAD', 'OPTIONS'}
)

def __call__(self, req: Request, resp: Response, **kw: Any) -> None:
"""Resource responder for this route."""
assert not kw

if req.method not in self._ALLOWED_METHODS:
raise falcon.HTTPMethodNotAllowed(sorted(self._ALLOWED_METHODS))

if req.method == 'OPTIONS':
# it's likely a CORS request. Set the allow header to the appropriate value.
resp.set_header('Allow', 'GET')
resp.set_header('Allow', ', '.join(sorted(self._ALLOWED_METHODS)))
resp.set_header('Content-Length', '0')
return

Expand Down Expand Up @@ -279,11 +286,27 @@ def __call__(self, req: Request, resp: Response, **kw: Any) -> None:
last_modified = last_modified.replace(microsecond=0)
resp.last_modified = last_modified

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 self._downloadable:
resp.downloadable_as = os.path.basename(file_path)

if _is_not_modified(req, etag, last_modified):
fh.close()
resp.status = falcon.HTTP_304
return

if req.method == 'HEAD':
# NOTE(alexchenai): For HEAD requests, we set headers but do not
# open a file stream, as the response must not include a body.
resp.content_length = st.st_size
fh.close()
return

req_range = req.range if req.range_unit == 'bytes' else None
try:
stream, length, content_range = _set_range(fh, st, req_range)
Expand All @@ -292,14 +315,7 @@ def __call__(self, req: Request, resp: Response, **kw: Any) -> None:
raise falcon.HTTPNotFound()

resp.set_stream(stream, length)
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 self._downloadable:
resp.downloadable_as = os.path.basename(file_path)
if content_range:
resp.status = falcon.HTTP_206
resp.content_range = content_range
Expand Down
65 changes: 65 additions & 0 deletions tests/test_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -757,3 +757,68 @@ def test_if_none_match_precedence(client, patch_open):
)
assert resp2.status == falcon.HTTP_304
assert resp2.text == ''


def test_head_request(client, patch_open):
content = b'Hello, World!'
patch_open(content=content)

client.app.add_static_route('/static', '/var/www/statics')

resp = client.simulate_head(path='/static/foo/bar.txt')
assert resp.status == falcon.HTTP_200
assert resp.headers.get('Content-Type') is not None
assert int(resp.headers['Content-Length']) == len(content)
assert resp.headers.get('ETag') is not None
assert resp.headers.get('Accept-Ranges') == 'bytes'
# HEAD response must not include a body
assert resp.content == b''


def test_head_request_not_modified(client, patch_open):
mtime = (1736617934, 'Sat, 11 Jan 2025 17:52:14 GMT')
patch_open(mtime=mtime[0])

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

# First, get the ETag
resp1 = client.simulate_request(path='/assets/css/main.css')
etag = resp1.headers['ETag']

# HEAD with matching ETag should return 304
resp2 = client.simulate_head(
path='/assets/css/main.css',
headers={'If-None-Match': etag},
)
assert resp2.status == falcon.HTTP_304
assert resp2.content == b''


@pytest.mark.parametrize('method', ['POST', 'PUT', 'PATCH', 'DELETE'])
def test_method_not_allowed(client, patch_open, method):
patch_open()

client.app.add_static_route('/static', '/var/www/statics')

resp = client.simulate_request(
method=method,
path='/static/foo/bar.txt',
)
assert resp.status == falcon.HTTP_405
allow = resp.headers.get('Allow')
assert allow is not None
allowed_methods = {m.strip() for m in allow.split(',')}
assert allowed_methods == {'GET', 'HEAD', 'OPTIONS'}


def test_options_returns_all_allowed_methods(client, patch_open):
patch_open()

client.app.add_static_route('/static', '/var/www/statics')

resp = client.simulate_options(path='/static/foo/bar.txt')
assert resp.status_code == 200
allow = resp.headers.get('Allow')
assert allow is not None
allowed_methods = {m.strip() for m in allow.split(',')}
assert allowed_methods == {'GET', 'HEAD', 'OPTIONS'}
Loading