Describe the bug
fast_app() automatically registers a root-level static-extension route via:
app.static_route_exts(static_path=static_path)
with path pattern:
/{fname:path}.{ext:static}
In FastHTML 0.14.2, the default static extension list includes xls, xlsx, csv, zip, pdf, xml, yaml, and many others.
Unexpectedly, this auto static-extension route is registered for GET, HEAD, and POST. As a result, it FULL-matches both GET and POST requests and can shadow explicitly registered dynamic {path:path} routes whenever the URL ends in one of those extensions.
For example, a dynamic catch-all route such as:
@rt("/x/{a}/{path:path}", methods=["GET", "POST", "PUT"])
works for ordinary multi-segment paths, but when using fast_app():
/x/SID/a/b/c/d GET 200 POST 200 PUT 200
/x/SID/file.xls GET 404 POST 404 PUT 200
/x/SID/sub/file.xlsx GET 404 POST 404 PUT 200
The same route works as expected when using FastHTML() directly instead of fast_app().
This affected a real file-proxy route for VM-mode spreadsheet uploads:
POST /java/{session_id}/simulation/input/upload/example.xls
POST /java/{session_id}/simulation/input/upload/example.xlsx
These requests returned Starlette/Uvicorn’s plain 404 Not Found, rather than reaching the dynamic proxy handler.
Minimal Reproducible Example
from fasthtml.common import FastHTML, fast_app
from starlette.routing import Match
from starlette.testclient import TestClient
def test_fast_app_static_ext_route_shadows_dynamic_path_route_current_behaviour():
"""
This test documents the current behaviour in FastHTML 0.14.2.
With fast_app(), the auto static-extension route shadows the explicit
dynamic {path:path} route for GET and POST URLs ending in .xls/.xlsx.
"""
app, rt = fast_app()
@rt("/x/{a}/{path:path}", methods=["GET", "POST", "PUT"])
async def catchall(a: str, path: str, request):
return {"matched": True, "a": a, "path": path, "method": request.method}
client = TestClient(app)
# Ordinary multi-segment dynamic paths work for all methods.
assert client.get("/x/SID/a/b/c/d").status_code == 200
assert client.post("/x/SID/a/b/c/d").status_code == 200
assert client.put("/x/SID/a/b/c/d").status_code == 200
# Current surprising behaviour: extension-looking paths are shadowed for GET/POST.
assert client.get("/x/SID/file.xls").status_code == 404
assert client.post("/x/SID/file.xls").status_code == 404
assert client.put("/x/SID/file.xls").status_code == 200
assert client.get("/x/SID/sub/file.xlsx").status_code == 404
assert client.post("/x/SID/sub/file.xlsx").status_code == 404
assert client.put("/x/SID/sub/file.xlsx").status_code == 200
def test_fasthtml_without_fast_app_does_not_shadow_dynamic_path_route():
"""
The same route works when using FastHTML() directly, showing that the issue
is introduced by fast_app()'s automatic static-extension route.
"""
app = FastHTML()
rt = app.route
@rt("/x/{a}/{path:path}", methods=["GET", "POST", "PUT"])
async def catchall(a: str, path: str, request):
return {"matched": True, "a": a, "path": path, "method": request.method}
client = TestClient(app)
assert client.get("/x/SID/file.xls").status_code == 200
assert client.post("/x/SID/file.xls").status_code == 200
assert client.put("/x/SID/file.xls").status_code == 200
assert client.get("/x/SID/sub/file.xlsx").status_code == 200
assert client.post("/x/SID/sub/file.xlsx").status_code == 200
assert client.put("/x/SID/sub/file.xlsx").status_code == 200
def test_fast_app_static_route_full_matches_get_and_post_but_not_put():
"""
Shows why GET/POST return 404 while PUT reaches the dynamic route.
"""
app, rt = fast_app()
@rt("/x/{a}/{path:path}", methods=["GET", "POST", "PUT"])
async def catchall(a: str, path: str, request):
return {"matched": True}
def scope(method, path):
return {
"type": "http",
"method": method,
"path": path,
"headers": [],
"query_string": b"",
"app": app,
}
static_route = next(
r for r in app.router.routes
if getattr(r, "path", "") == "/{fname:path}.{ext:static}"
)
dynamic_route = next(
r for r in app.router.routes
if getattr(r, "path", "") == "/x/{a}/{path:path}"
)
assert static_route.matches(scope("GET", "/x/SID/file.xls"))[0] == Match.FULL
assert static_route.matches(scope("POST", "/x/SID/file.xls"))[0] == Match.FULL
assert static_route.matches(scope("PUT", "/x/SID/file.xls"))[0] == Match.PARTIAL
assert dynamic_route.matches(scope("GET", "/x/SID/file.xls"))[0] == Match.FULL
assert dynamic_route.matches(scope("POST", "/x/SID/file.xls"))[0] == Match.FULL
assert dynamic_route.matches(scope("PUT", "/x/SID/file.xls"))[0] == Match.FULL
Expected behavior
I would expect one of the following:
- The auto static-extension route should not be registered for POST, since it is a static-file-serving route; and/or
- An explicitly registered dynamic
{path:path} route should be able to take precedence over the auto static-extension route for matching paths.
In the minimal example above, I would expect:
/x/SID/file.xls GET 200 POST 200 PUT 200
/x/SID/sub/file.xlsx GET 200 POST 200 PUT 200
Environment Information
- fastlite version: 0.2.4
- fastcore version: 1.13.2
- fasthtml version: 0.14.2
- Distribution name appears to be
python-fasthtml
Confirmation
Additional context
The route matching evidence shows that the auto static-extension route is registered for POST and FULL-matches extension-looking paths:
== GET
/{fname:path}.{ext:static} ['HEAD', 'POST', 'GET'] FULL
/x/{a}/{path:path} ['HEAD','POST','PUT','GET'] FULL
== POST
/{fname:path}.{ext:static} ['HEAD', 'POST', 'GET'] FULL
/x/{a}/{path:path} ['HEAD','POST','PUT','GET'] FULL
== PUT
/{fname:path}.{ext:static} ['HEAD', 'POST', 'GET'] PARTIAL
/x/{a}/{path:path} ['HEAD','POST','PUT','GET'] FULL
So:
- GET: static route FULL-matches first → tries to serve a missing file → 404
- POST: static route also FULL-matches first because it includes POST → 404
- PUT: static route only PARTIAL-matches because it does not include PUT → falls through to the dynamic route → 200
The static handler is defined inside static_route_exts as a nested async def get(...). Its __qualname__ is something like static_route_exts.<locals>.get, not the bare method name get, so FastHTML’s method inference may be falling back to its default ['get', 'post']. This may be why the static route accepts POST.
In our app, a workaround was to remove the auto static-extension route after fast_app(), because all assets are served via an explicit /static mount:
app.router.routes = [
r for r in app.router.routes
if getattr(r, "path", "") != "/{fname:path}.{ext:static}"
]
This restored the dynamic proxy route for extension-looking paths.
Question: is it intentional that the auto static-extension route registered by fast_app() handles POST as well as GET/HEAD?
If not, restricting it to GET/HEAD would appear to fix this case.
If it is intentional, what is the recommended way to make an explicit dynamic {path:path} route take precedence for extension-looking paths while still using fast_app()?
Screenshots
Not applicable.
Describe the bug
fast_app()automatically registers a root-level static-extension route via:with path pattern:
In FastHTML 0.14.2, the default
staticextension list includesxls,xlsx,csv,zip,pdf,xml,yaml, and many others.Unexpectedly, this auto static-extension route is registered for GET, HEAD, and POST. As a result, it FULL-matches both GET and POST requests and can shadow explicitly registered dynamic
{path:path}routes whenever the URL ends in one of those extensions.For example, a dynamic catch-all route such as:
@rt("/x/{a}/{path:path}", methods=["GET", "POST", "PUT"])works for ordinary multi-segment paths, but when using
fast_app():The same route works as expected when using
FastHTML()directly instead offast_app().This affected a real file-proxy route for VM-mode spreadsheet uploads:
These requests returned Starlette/Uvicorn’s plain
404 Not Found, rather than reaching the dynamic proxy handler.Minimal Reproducible Example
Expected behavior
I would expect one of the following:
{path:path}route should be able to take precedence over the auto static-extension route for matching paths.In the minimal example above, I would expect:
Environment Information
python-fasthtmlConfirmation
Additional context
The route matching evidence shows that the auto static-extension route is registered for POST and FULL-matches extension-looking paths:
So:
The static handler is defined inside
static_route_extsas a nestedasync def get(...). Its__qualname__is something likestatic_route_exts.<locals>.get, not the bare method nameget, so FastHTML’s method inference may be falling back to its default['get', 'post']. This may be why the static route accepts POST.In our app, a workaround was to remove the auto static-extension route after
fast_app(), because all assets are served via an explicit/staticmount:This restored the dynamic proxy route for extension-looking paths.
Question: is it intentional that the auto static-extension route registered by
fast_app()handles POST as well as GET/HEAD?If not, restricting it to GET/HEAD would appear to fix this case.
If it is intentional, what is the recommended way to make an explicit dynamic
{path:path}route take precedence for extension-looking paths while still usingfast_app()?Screenshots
Not applicable.