AnswerDotAI / AnswerDotAI/fasthtml

[BUG] fast_app() static-extension route handles POST and shadows dynamic {path:path} routes

Open
#888 1 comment 0 reactions 0 assignees View on GitHub
bug
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.