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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions