Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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