AnswerDotAI / AnswerDotAI/fasthtml
[BUG] fast_app() static-extension route handles POST and shadows dynamic {path:path} routes
- Dominant language
- Jupyter Notebook
- Stars
- 7k
- Forks
- 319
- Avg merge
- 28m
- Merged PRs (30d)
- 3
Description
**Describe the bug**
`fast_app()` automatically registers a root-level static-extension route via:
```python
app.static_route_exts(static_path=static_path)
```
with path pattern:
```text
/{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:
```python
@rt("/x/{a}/{path:path}", methods=["GET", "POST", "PUT"])
```
works for ordinary multi-segment paths, but when using `fast_app()`:
```text
/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:
```text
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**
```python
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:
1. The auto static-extension route should not be registered for POST, since it is a static-file-serving route; and/or
2. 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:
```text
/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**
- [x] I have read the FAQ (https://docs.fastht.ml/explains/faq.html)
- [x] I have provided a minimal reproducible example
- [x] I have included the versions of fastlite, fastcore, and fasthtml
- [x] I understand that this is a volunteer open source project with no commercial support.
**Additional context**
The route matching evidence shows that the auto static-extension route is registered for POST and FULL-matches extension-looking paths:
```text
== 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..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:
```python
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.
Contributor guide
Assessment
This issue has not been assessed yet.