How to capture arbitrary paths at one route in FastAPI?
Question:
I’m serving React app from FastAPI by
mounting
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.route('/session')
async def renderReactApp(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
by this React app get served and React routing also works fine at client side
but as soon as client reloads on a route which is not defined on server but used in React app FastAPI return not found
to fix this I did something as below.
@app.route('/network')
@app.route('/gat')
@app.route('/session')
async def renderReactApp(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
but it seems weird and wrong to me as I need to add every route at the back-end as well as at frontend.
I’m sure there must be something like Flask @flask_app.add_url_rule('/<path:path>', 'index', index)
in FastAPI which will server all arbitrary path
Answers:
Let’s say you have a app structure like this:
├── main.py
└── routers
└── my_router.py
And the routers we created in my_router.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/some")
async def some_path():
pass
@router.get("/path")
async def some_other_path():
pass
@router.post("/some_post_path")
async def some_post_path():
pass
Let’s dive in to the main.py
first we need to import our router we declared with
from routers import my_router
Then let’s create a app instance
from fastapi import FastAPI
from routers import my_router
app = FastAPI()
So how do we add our routers?
from fastapi import FastAPI
from routers import my_router
app = FastAPI()
app.include_router(my_router.router)
You can also add prefix, tag, etc.
from fastapi import FastAPI
from routers import my_router
app = FastAPI()
app.include_router(
my_router.router,
prefix="/custom_path",
tags=["We are from router!"],
)
Let’s check the docs
Since FastAPI is based on Starlette, you can use what they call "converters" with your route parameters, using type path
in this case, which "returns the rest of the path, including any additional /
characers."
See https://www.starlette.io/routing/#path-parameters for reference.
If your react (or vue or …) app is using a base path, you can do something like this, which assigns anything after /my-app/
to the rest_of_path
variable:
@app.get("/my-app/{rest_of_path:path}")
async def serve_my_app(request: Request, rest_of_path: str):
print("rest_of_path: "+rest_of_path)
return templates.TemplateResponse("index.html", {"request": request})
If you are not using a unique base path like /my-app/
(which seems to be your use case), you can still accomplish this with a catch-all route, which should go after any other routes so that it doesn’t overwrite them:
@app.route("/{full_path:path}")
async def catch_all(request: Request, full_path: str):
print("full_path: "+full_path)
return templates.TemplateResponse("index.html", {"request": request})
(In fact you would want to use this catch-all regardless in order to catch the difference between requests for /my-app/
and /my-app
)
Here is an example of serving multiple routes (or lazy loading functions) using a single post url. The body of a request to the url would contain the name of a function to call and data to pass to the function if any. The *.py
files in the routes/
directory contain the functions, and functions share the same name as their files.
project structure
app.py
routes/
|__helloworld.py
|_*.py
routes/helloworld.py
def helloworld(data):
return data
app.py
from os.path import split, realpath
from importlib.machinery import SourceFileLoader as sfl
import uvicorn
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel
# set app's root directory
API_DIR = split(realpath(__file__))[0]
class RequestPayload(BaseModel):
"""payload for post requests"""
# function in `/routes` to call
route: str = 'function_to_call'
# data to pass to the function
data: Any = None
app = FastAPI()
@app.post('/api')
async def api(payload: RequestPayload):
"""post request to call function"""
# load `.py` file from `/routes`
route = sfl(payload.route,
f'{API_DIR}/routes/{payload.route}.py').load_module()
# load function from `.py` file
func = getattr(route, payload.route)
# check if function requires data
if ('data' not in payload.dict().keys()):
return func()
return func(payload.data)
This example returns {"hello": "world"}
with the post request below.
curl -X POST "http://localhost:70/api" -H "accept: application/json" -H "Content-Type: application/json" -d "{"route":"helloworld","data":{"hello": "world"}}"
The benefit of this setup is that a single post url can be used to complete any type of request (get, delete, put, etc), as the "type of request" is the logic defined in the function. For example, if get_network.py
and delete_network.py
are added to the routes/
directory
routes/get_network.py
def get_network(id: str):
network_name = ''
# logic to retrieve network by id from db
return network_name
routes/delete_network.py
def delete_network(id: str):
network_deleted = False
# logic to delete network by id from db
return network_deleted
then a request payload of {"route": "get_network", "data": "network_id"}
returns a network name, and {"route": "delete_network", "data": "network_id"}
would return a boolean indicating wether the network was deleted or not.
As @mecampbellsoup pointed out: there are usually other static files that need to be served with an application like this.
Hopefully this comes in handy to someone else:
import os
from typing import Tuple
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
class SinglePageApplication(StaticFiles):
"""Acts similar to the bripkens/connect-history-api-fallback
NPM package."""
def __init__(self, directory: os.PathLike, index='index.html') -> None:
self.index = index
# set html=True to resolve the index even when no
# the base path is passed in
super().__init__(directory=directory, packages=None, html=True, check_dir=True)
async def lookup_path(self, path: str) -> Tuple[str, os.stat_result]:
"""Returns the index file when no match is found.
Args:
path (str): Resource path.
Returns:
[tuple[str, os.stat_result]]: Always retuens a full path and stat result.
"""
full_path, stat_result = await super().lookup_path(path)
# if a file cannot be found
if stat_result is None:
return await super().lookup_path(self.index)
return (full_path, stat_result)
app.mount(
path='/',
app=SinglePageApplication(directory='path/to/dist'),
name='SPA'
)
These modifications make the StaticFiles mount act similar to the connect-history-api-fallback NPM package.
Simple and effective solution compatible with react-router
I made a very simple function that it is fully compatible react-router
and create-react-app
applications (most use cases)
The function
from pathlib import Path
from typing import Union
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
def serve_react_app(app: FastAPI, build_dir: Union[Path, str]) -> FastAPI:
"""Serves a React application in the root directory `/`
Args:
app: FastAPI application instance
build_dir: React build directory (generated by `yarn build` or
`npm run build`)
Returns:
FastAPI: instance with the react application added
"""
if isinstance(build_dir, str):
build_dir = Path(build_dir)
app.mount(
"/static/",
StaticFiles(directory=build_dir / "static"),
name="React App static files",
)
templates = Jinja2Templates(directory=build_dir.as_posix())
@app.get("/{full_path:path}")
async def serve_react_app(request: Request, full_path: str):
"""Serve the react app
`full_path` variable is necessary to serve each possible endpoint with
`index.html` file in order to be compatible with `react-router-dom
"""
return templates.TemplateResponse("index.html", {"request": request})
return app
Usage
import uvicorn
from fastapi import FastAPI
app = FastAPI()
path_to_react_app_build_dir = "./frontend/build"
app = serve_react_app(app, path_to_react_app_build_dir)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8001)
I’m serving React app from FastAPI by
mounting
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.route('/session')
async def renderReactApp(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
by this React app get served and React routing also works fine at client side
but as soon as client reloads on a route which is not defined on server but used in React app FastAPI return not found
to fix this I did something as below.
@app.route('/network')
@app.route('/gat')
@app.route('/session')
async def renderReactApp(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
but it seems weird and wrong to me as I need to add every route at the back-end as well as at frontend.
I’m sure there must be something like Flask @flask_app.add_url_rule('/<path:path>', 'index', index)
in FastAPI which will server all arbitrary path
Let’s say you have a app structure like this:
├── main.py
└── routers
└── my_router.py
And the routers we created in my_router.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/some")
async def some_path():
pass
@router.get("/path")
async def some_other_path():
pass
@router.post("/some_post_path")
async def some_post_path():
pass
Let’s dive in to the main.py
first we need to import our router we declared with
from routers import my_router
Then let’s create a app instance
from fastapi import FastAPI
from routers import my_router
app = FastAPI()
So how do we add our routers?
from fastapi import FastAPI
from routers import my_router
app = FastAPI()
app.include_router(my_router.router)
You can also add prefix, tag, etc.
from fastapi import FastAPI
from routers import my_router
app = FastAPI()
app.include_router(
my_router.router,
prefix="/custom_path",
tags=["We are from router!"],
)
Let’s check the docs
Since FastAPI is based on Starlette, you can use what they call "converters" with your route parameters, using type path
in this case, which "returns the rest of the path, including any additional /
characers."
See https://www.starlette.io/routing/#path-parameters for reference.
If your react (or vue or …) app is using a base path, you can do something like this, which assigns anything after /my-app/
to the rest_of_path
variable:
@app.get("/my-app/{rest_of_path:path}")
async def serve_my_app(request: Request, rest_of_path: str):
print("rest_of_path: "+rest_of_path)
return templates.TemplateResponse("index.html", {"request": request})
If you are not using a unique base path like /my-app/
(which seems to be your use case), you can still accomplish this with a catch-all route, which should go after any other routes so that it doesn’t overwrite them:
@app.route("/{full_path:path}")
async def catch_all(request: Request, full_path: str):
print("full_path: "+full_path)
return templates.TemplateResponse("index.html", {"request": request})
(In fact you would want to use this catch-all regardless in order to catch the difference between requests for /my-app/
and /my-app
)
Here is an example of serving multiple routes (or lazy loading functions) using a single post url. The body of a request to the url would contain the name of a function to call and data to pass to the function if any. The *.py
files in the routes/
directory contain the functions, and functions share the same name as their files.
project structure
app.py
routes/
|__helloworld.py
|_*.py
routes/helloworld.py
def helloworld(data):
return data
app.py
from os.path import split, realpath
from importlib.machinery import SourceFileLoader as sfl
import uvicorn
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel
# set app's root directory
API_DIR = split(realpath(__file__))[0]
class RequestPayload(BaseModel):
"""payload for post requests"""
# function in `/routes` to call
route: str = 'function_to_call'
# data to pass to the function
data: Any = None
app = FastAPI()
@app.post('/api')
async def api(payload: RequestPayload):
"""post request to call function"""
# load `.py` file from `/routes`
route = sfl(payload.route,
f'{API_DIR}/routes/{payload.route}.py').load_module()
# load function from `.py` file
func = getattr(route, payload.route)
# check if function requires data
if ('data' not in payload.dict().keys()):
return func()
return func(payload.data)
This example returns {"hello": "world"}
with the post request below.
curl -X POST "http://localhost:70/api" -H "accept: application/json" -H "Content-Type: application/json" -d "{"route":"helloworld","data":{"hello": "world"}}"
The benefit of this setup is that a single post url can be used to complete any type of request (get, delete, put, etc), as the "type of request" is the logic defined in the function. For example, if get_network.py
and delete_network.py
are added to the routes/
directory
routes/get_network.py
def get_network(id: str):
network_name = ''
# logic to retrieve network by id from db
return network_name
routes/delete_network.py
def delete_network(id: str):
network_deleted = False
# logic to delete network by id from db
return network_deleted
then a request payload of {"route": "get_network", "data": "network_id"}
returns a network name, and {"route": "delete_network", "data": "network_id"}
would return a boolean indicating wether the network was deleted or not.
As @mecampbellsoup pointed out: there are usually other static files that need to be served with an application like this.
Hopefully this comes in handy to someone else:
import os
from typing import Tuple
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
class SinglePageApplication(StaticFiles):
"""Acts similar to the bripkens/connect-history-api-fallback
NPM package."""
def __init__(self, directory: os.PathLike, index='index.html') -> None:
self.index = index
# set html=True to resolve the index even when no
# the base path is passed in
super().__init__(directory=directory, packages=None, html=True, check_dir=True)
async def lookup_path(self, path: str) -> Tuple[str, os.stat_result]:
"""Returns the index file when no match is found.
Args:
path (str): Resource path.
Returns:
[tuple[str, os.stat_result]]: Always retuens a full path and stat result.
"""
full_path, stat_result = await super().lookup_path(path)
# if a file cannot be found
if stat_result is None:
return await super().lookup_path(self.index)
return (full_path, stat_result)
app.mount(
path='/',
app=SinglePageApplication(directory='path/to/dist'),
name='SPA'
)
These modifications make the StaticFiles mount act similar to the connect-history-api-fallback NPM package.
Simple and effective solution compatible with react-router
I made a very simple function that it is fully compatible react-router
and create-react-app
applications (most use cases)
The function
from pathlib import Path
from typing import Union
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
def serve_react_app(app: FastAPI, build_dir: Union[Path, str]) -> FastAPI:
"""Serves a React application in the root directory `/`
Args:
app: FastAPI application instance
build_dir: React build directory (generated by `yarn build` or
`npm run build`)
Returns:
FastAPI: instance with the react application added
"""
if isinstance(build_dir, str):
build_dir = Path(build_dir)
app.mount(
"/static/",
StaticFiles(directory=build_dir / "static"),
name="React App static files",
)
templates = Jinja2Templates(directory=build_dir.as_posix())
@app.get("/{full_path:path}")
async def serve_react_app(request: Request, full_path: str):
"""Serve the react app
`full_path` variable is necessary to serve each possible endpoint with
`index.html` file in order to be compatible with `react-router-dom
"""
return templates.TemplateResponse("index.html", {"request": request})
return app
Usage
import uvicorn
from fastapi import FastAPI
app = FastAPI()
path_to_react_app_build_dir = "./frontend/build"
app = serve_react_app(app, path_to_react_app_build_dir)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8001)