fastapi icon indicating copy to clipboard operation
fastapi copied to clipboard

[BUG] Mounting StaticFiles with an APIRouter doesn't work

Open lazka opened this issue 4 years ago • 13 comments

Describe the bug

Mounting StaticFiles with an APIRouter doesn't work.

To Reproduce

from typing import Any
from fastapi import FastAPI, Request, APIRouter
from fastapi.staticfiles import StaticFiles
from fastapi.testclient import TestClient

router = APIRouter()

@router.get("/")
async def foo(request: Request) -> Any:
    # this raises starlette.routing.NoMatchFound
    return request.url_for("static", path="/bar")

app = FastAPI()

router.mount("/static", StaticFiles(directory="."), name="static")
# uncomment to fix
# app.mount("/static", StaticFiles(directory="."), name="static")

app.include_router(router)

client = TestClient(app)
client.get("/")
  • Execute the script, raises NoMatchFound
  • Uncomment line to mount with app instead
  • Executes as expected

Expected behavior

I can use APIRouter() as if it was a FastAPI() as noted in the docs.

lazka avatar May 24 '20 13:05 lazka

The include_router function in FastAPI is expecting an APIRouter, and will only register Routes that are included on that APIRouter. A StaticFiles is a sub-application, not a Route. I believe that FastAPI only supports mounting sub-applications on the app.

I'm not sure it makes sense to mount it on an APIRouter as the features of that class (default dependencies, response models) don't do much for static files. Maybe the bug here is that the mount function should be overloaded (from Starlette's) to raise an exception directly telling developers it's not supported?

If there is a specific need to include static files under a router, could you outline why that feature would be more useful then the basic mounting on the app?

Could you also point out the specific docs that were confusing so they can be clarified?

Finally, if you do need some Router functionality for your StaticFiles, you could use the Starlette methods directly in the meantime. Something like this:

from typing import Any
from fastapi import FastAPI, Request, APIRouter
from fastapi.staticfiles import StaticFiles
from fastapi.testclient import TestClient
from starlette.routing import Router

app = FastAPI()
router = APIRouter()

@router.get("/")
async def foo(request: Request) -> Any:
    # this raises starlette.routing.NoMatchFound
    return request.url_for("static", path="/bar")

app.include_router(router)

static_router = Router()
static_router.mount("/", StaticFiles(directory="."), name="static")
app.mount("/static", other_router)

client = TestClient(app)
client.get("/")

dbanty avatar May 24 '20 14:05 dbanty

If there is a specific need to include static files under a router, could you outline why that feature would be more useful then the basic mounting on the app?

I have different modules with different static things in my app and wanted to separate everything. So that changing the prefix also changes the static prefix. And so they can live in another package not in the same repo.

Could you also point out the specific docs that were confusing so they can be clarified?

It's here https://fastapi.tiangolo.com/tutorial/bigger-applications/#path-operations-with-apirouter "All the same options are supported.", but I now see that this only refers to path operations.

(btw the search in the docs is broken, it just says "Initializing search" forever)

lazka avatar May 24 '20 15:05 lazka

@tiangolo we probably need your opinion here on design. Would it be appropriate for include_router to also mount any sub-applications that have been mounted to thatAPIRouter (with appropriate prefix)? Or should we explicitly disable the mount function on APIRouter and make a note in the docs about it?

@lazka there is an open issue about the search (#1448). It works on some platforms and not on others (not sure that's been narrowed down yet). I have good luck on Chrome on macOS.

dbanty avatar May 24 '20 21:05 dbanty

Turns out what I actually want is to mount another FastAPI() like so

subapp = FastAPI()
app = FastAPI()
subapp.mount("/prefix", subapp)

This is more in line with flask blueprints. Only downside is the "startup" event isn't triggered for "subapp" :( -> I've filed #1480

lazka avatar May 26 '20 06:05 lazka

I just ran into the same issue. I agree that at the very least there should be an error (although I would definitely support mounting within routers).

@lazka Good to know that's a solution. Would this introduce any additional latency (because of the duplicated "bookkeeping", FastAPI/starlette does for each ASGI request? Or is that not something to even worry about?

Basically, I'm in a similar situation where I have organized my app into around 7 different APIRouter instances that each have around 3 routes defined and are all imported and included in the root router. While I assumed this was one of the intended purposes of APIRouter, if not perhaps we should update the bigger-applications docs page or at least mention when an APIRouter behaves differently than the FastAPI class.

MatthewScholefield avatar Feb 08 '21 02:02 MatthewScholefield

@lazka Good to know that's a solution. Would this introduce any additional latency (because of the duplicated "bookkeeping", FastAPI/starlette does for each ASGI request? Or is that not something to even worry about?

I don't know. I'm still using it that way and hadn't had any issues (besides the events not working, which prevents me from modularization the app)

lazka avatar Feb 08 '21 08:02 lazka

I have the same problem, I cannot render static files in an APIRouter.

facundopadilla avatar Jun 20 '21 16:06 facundopadilla

Same here. I use @lazka's solution for now, but it would be nice to have this more intuitive. Or at least throw an error if someone tries to mount StaticFiles to a APIRouter. It took some time to get to this page.

Netzvamp avatar Sep 24 '21 12:09 Netzvamp

I tried the same way as lazka and failed.

It confuses me a lot that APIRouter has mount() which doesn't work actually. image

JalinWang avatar Oct 20 '21 02:10 JalinWang

the same issue, still can't figure it out, how to serve static files with the router.

nikeshnaik avatar Nov 13 '21 11:11 nikeshnaik

The underlying problems seems to be that the route does not get applied to the application. The cause is a very restrictive check:

https://github.com/tiangolo/fastapi/blob/f0388915a8b1cd9f3ae2259bace234ac6249c51a/fastapi/routing.py#L713-L721

This should be expanded to work with routing.BaseRoute

septatrix avatar Jan 16 '22 21:01 septatrix

Hello, same issue here.

I use an APIRouter instance like so: prefix_router = APIRouter(prefix=ROOT_PATH).

After that, app.mount("/static", StaticFiles(directory="static"), name="static") delivers the static files iff ROOT_PATH == "".

If I set a non-empty ROOT_PATH, I get 404s on everything under e.g. http://example.com/ROOT_PATH/static.

Switching app.mount() to prefix_router.mount() fails.

Any workarounds until https://github.com/tiangolo/fastapi/issues/1469#issuecomment-1013952356 gets addressed?

waseigo avatar Jul 22 '22 08:07 waseigo

You need to refer to your static folder as a root folder. So, in a response, if you refer to it as static/some_file.ext, it will parse it to http://example.com/ROOT_PATH/static/some_file.ext. But, if you refer to it as /static/some_file.ext, it will parse it to http://example.com/static/some_file.ext.

Below is a full working example of this behaviour:

from fastapi import APIRouter, FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse

app = FastAPI()

app.mount('/static', StaticFiles(directory='static'), name='static')

my_router = APIRouter(prefix='/router')


@my_router.get("/not-working")
async def router_root():
    content = "<img src='static/black.png'>"
    # -------------------^ note, not absolute path!
    return HTMLResponse(content=content)

@my_router.get("/working")
async def router_root():
    content = "<img src='/static/black.png'>"
    # -------------------^ note, prefixed slash!
    return HTMLResponse(content=content)

app.include_router(my_router)

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Output (first called not-working, then working):

INFO:     127.0.0.1:53410 - "GET /router/not-working HTTP/1.1" 200 OK
INFO:     127.0.0.1:53410 - "GET /router/static/black.png HTTP/1.1" 404 Not Found
INFO:     127.0.0.1:53412 - "GET /router/working HTTP/1.1" 200 OK
INFO:     127.0.0.1:53412 - "GET /black.png HTTP/1.1" 200 OK

JarroVGIT avatar Jul 23 '22 11:07 JarroVGIT

As @septatrix has pointed out, the problem lies inside a loop which copies routes from downstream router to an upstream. Mount routes are simply ingored. But it seems that it's quite easy to make it work by appending the following code:

elif isinstance(route, routing.Mount):
    self.mount(prefix + route.path, route.app, route.name)

to if-else chain at https://github.com/tiangolo/fastapi/blob/f0388915a8b1cd9f3ae2259bace234ac6249c51a/fastapi/routing.py#L726-L729 I haven't tested it thoroughly but for my use case everything looks fine. Here is the full snippet I use for current fastapi version as less hacky solution is not available to my knowledge.

SF-300 avatar Nov 14 '22 13:11 SF-300

any update on this? Running into the same issue.

isConic avatar Nov 30 '22 19:11 isConic

I find there is not else statement. Maybe we can add else statement to avoid unexpected type and help us to address error?

            elif isinstance(route, routing.WebSocketRoute):
                self.add_websocket_route(
                    prefix + route.path, route.endpoint, name=route.name
                )
            elif isinstance(route, routing.Mount):
                self.mount(prefix + route.path, route.app, route.name)
            else:
                raise Exception(f"Unexcepted route type {type(route)}")

TaiJuWu avatar Feb 15 '23 09:02 TaiJuWu