Skip to content

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

Description

@rer98

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:

  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:

/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

  • I have read the FAQ (https://docs.fastht.ml/explains/faq.html)
  • I have provided a minimal reproducible example
  • I have included the versions of fastlite, fastcore, and fasthtml
  • 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:

== 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions