FastAPI is a modern, high-performance web framework for building APIs with Python 3.7+ based on standard Python type hints. It’s designed to create fast, reliable, and production-ready APIs with minimal effort, making it an excellent choice for developers looking to streamline backend development. FastAPI leverages asynchronous programming, making it highly efficient and capable of handling a large number of concurrent requests with minimal latency. Additionally, it includes built-in support for data validation, serialization, and documentation generation, making it both powerful and developer-friendly.
In FastAPI, routing plays a crucial role in defining how HTTP requests are handled and directed within an application. Basic routing is easy to set up, but advanced routing features allow developers to build more dynamic, complex, and customizable APIs. This blog post dives into the advanced routing capabilities of FastAPI.
Setup the environment
For the next steps we will work with
- python 3.12.2
- If you don’t have this available on your system you can easily install using pyenv (detailed steps in this article https://brewedbrilliance.net/switch-python-version-using-pyenv/)
- poetry (our package manager that can be found here https://python-poetry.org/docs/)
- FastAPI
- VisualStudio Code or any other IDE tool that will help you to debug the python application
- 1 coffee, maybe 2
Create the Poetry project and install dependencies
First of all we have to create the poetry project, so for doing that, navigate to working folder and run this command
% poetry new advanced-routing
poetry new advanced-routing
Created package advanced_routing in advanced-routing
In your working directory now you should have a new folder advanced-routing, which structure should match the one below
advanced-routing
├── pyproject.toml
├── README.md
├── advanced-routing
│ └── __init__.py
└── tests
└── __init__.py
Add FastAPI to our project
poetry add FastAPI
Creating virtualenv advanced-routing-csjMiuwX-py3.13 in /Users/brewedbrilliance/Library/Caches/pypoetry/virtualenvs
Using version ^0.115.4 for fastapi
Updating dependencies
Resolving dependencies... (1.5s)
Package operations: 9 installs, 0 updates, 0 removals
- Installing idna (3.10)
- Installing sniffio (1.3.1)
- Installing typing-extensions (4.12.2)
- Installing annotated-types (0.7.0)
- Installing anyio (4.6.2.post1)
- Installing pydantic-core (2.23.4)
- Installing pydantic (2.9.2)
- Installing starlette (0.41.2)
- Installing fastapi (0.115.4)
Writing lock file
At this stage we have everything we need!
Note: make sure that you will run now the poetry shell command, so you can activate the virtual env
So let’s start with creating initially a standard FastAPI application
Create the basic FastAPI application
Let’s create our basic FastAPI application like explained here (https://fastapi.tiangolo.com/#installation)
Create a main.py file in the advanced-routing folder so that the directory structure would match this one below
advanced-routing
├── pyproject.toml
├── README.md
├── advanced-routing
│ └── __init__.py
│ └── main.py # <=== new file
└── tests
└── __init__.py
In main.py we will add just these lines
from typing import Union
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"Hello": "World"}
@app.get("/items/{item_id}")
def read_item(item_id: int):
return {"item_id": item_id}
All we need to do now is to start our dev server with this application by doing from the command line
fastapi dev main.py --port 8085
This command will start our development server, with reload set to true, that means that the application will be constantly watched for changes and if a change is applied to any of the source file, the server will reload.
If we want to check our results all we have to do is just to navigate with our browser to http://localhost:8085/ or http://localhost:8085/items/3
If we want to add more endpoints all we have to do, accordingly to the way FastAPI works, is to just add functions with the decorator we will want (@app.get, @app.post, @app.delete, @app.put)
Using the FastAPI Route
The example above is pretty basic, for bigger application we need to add more endpoints and is not plausible to have all of them defined in one file, for any application that is constantly evolving the maintenance will start to become really hard for an evergrowing list of endpoints. For solving this problem, we can leverage the Routes that FastAPI has and we can split our application into smaller indipendent parts. For this purpose we will have the items.py that will create the route for items endpoints, and the users.py for the users endpoints
We want that our directory structure to look like
advanced-routing
├── pyproject.toml
├── README.md
├── advanced-routing
│ └── __init__.py
│ └── main.py
│ └── routers. # <=== new folder
│ │ ├── __init__.py # <=== new file
│ │ ├── items.py # <=== new file
│ │ ├── users.py # <=== new file
└── tests
└── __init__.py
For the simplicity of this guide we will have 2 end points per file mostly identical
Our items.py will looks like
# items.py
from fastapi import APIRouter
from typing import Union
router = APIRouter()
@router.get("/items/{item_id}")
def read_item(item_id: int):
return {
"item_id": item_id,
"source": "items"
}
Our users.py will looks like
from fastapi import APIRouter
from typing import Union
router = APIRouter()
@router.get("/users/{user_id}")
def read_user(user_id: int):
return {
"user_id": user_id,
"source": "users"
}
Our main.py instead will be slightly changed
from fastapi import FastAPI
from .routes import items, users
app = FastAPI()
app.include_router(items.router)
app.include_router(users.router)
@app.get("/")
def read_root():
return {"Hello": "World2"}
In the FastAPI documenation these are called submodules and more info can be found at this page
https://fastapi.tiangolo.com/tutorial/bigger-applications
So far, we’ve taken a simple approach: we split the application into submodules, each imported as an APIRouter
instance and then registered with the main app
. This setup works well, but it becomes challenging when you want to list, document, or inspect all the endpoints to understand expected requests and responses. What if we could load all the endpoints dynamically from a JSON file or dictionary? Wouldn’t that be great? Well, the APIRouter
class provides a method just for this: add_api_route
.”
FastAPI add_api_route
In the FastAPIRouter documentation page (https://fastapi.tiangolo.com/reference/apirouter/#fastapi.APIRouter.include_router–example) can be found (together with all the source code) the call to this method that is the essence of how the APIRouter class works. Around line 1300 you can see this interesting piece of code
self.add_api_route(
prefix + route.path,
route.endpoint,
response_model=route.response_model,
status_code=route.status_code,
tags=current_tags,
dependencies=current_dependencies,
summary=route.summary,
description=route.description,
response_description=route.response_description,
responses=combined_responses,
deprecated=route.deprecated or deprecated or self.deprecated,
methods=route.methods,
operation_id=route.operation_id,
response_model_include=route.response_model_include,
response_model_exclude=route.response_model_exclude,
response_model_by_alias=route.response_model_by_alias,
response_model_exclude_unset=route.response_model_exclude_unset,
response_model_exclude_defaults=route.response_model_exclude_defaults,
response_model_exclude_none=route.response_model_exclude_none,
include_in_schema=route.include_in_schema
and self.include_in_schema
and include_in_schema,
response_class=use_response_class,
name=route.name,
route_class_override=type(route),
callbacks=current_callbacks,
openapi_extra=route.openapi_extra,
generate_unique_id_function=current_generate_unique_id,
)
This lead to my curiosity and by looking at the source code of fastapi/routing.py you can see that as part of the class definition there is this method defined as
def add_api_route(
self,
path: str,
endpoint: Callable[..., Any],
*,
response_model: Any = Default(None),
status_code: Optional[int] = None,
tags: Optional[List[Union[str, Enum]]] = None,
dependencies: Optional[Sequence[params.Depends]] = None,
summary: Optional[str] = None,
description: Optional[str] = None,
response_description: str = "Successful Response",
responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None,
deprecated: Optional[bool] = None,
methods: Optional[Union[Set[str], List[str]]] = None,
operation_id: Optional[str] = None,
response_model_include: Optional[IncEx] = None,
response_model_exclude: Optional[IncEx] = None,
response_model_by_alias: bool = True,
response_model_exclude_unset: bool = False,
response_model_exclude_defaults: bool = False,
response_model_exclude_none: bool = False,
include_in_schema: bool = True,
response_class: Union[Type[Response], DefaultPlaceholder] = Default(
JSONResponse
),
name: Optional[str] = None,
route_class_override: Optional[Type[APIRoute]] = None,
callbacks: Optional[List[BaseRoute]] = None,
openapi_extra: Optional[Dict[str, Any]] = None,
generate_unique_id_function: Union[
Callable[[APIRoute], str], DefaultPlaceholder
] = Default(generate_unique_id),
) -> None:
Since this is a public method defined in the APIRouter
class, we’re free to call it and register endpoints programmatically. For example, let’s look at items.py
and users.py
updated with this approach. This article won’t cover each parameter in detail; for now, we’ll focus only on the first parameter, which defines the URL path, and on the endpoint value, which must be a callable object (a function or a class method). In items.py (as users.py) we have one endpoint (/users/{id}) that can be registered as
# items.py
from fastapi import APIRouter
from typing import Union
def read_item(user_id: int):
return {
"item_id": user_id,
"source": "items"
}
router = APIRouter()
router.add_api_route(
"/item/{user_id}",
endpoint=read_item,
response_model={},
methods=["GET"]
)
# users.py
from fastapi import APIRouter
from typing import Union
def read_user(user_id: int):
return {
"user_id": user_id,
"source": "users"
}
router = APIRouter()
router.add_api_route(
"/users/{user_id}",
endpoint=read_user,
response_model={},
methods=["GET"]
)
Once saved, you’ll notice no changes in the application behavior; it will still serve the exact same endpoints and responses. To enhance this, we could declare a dictionary with all endpoints, associated methods, and callables, and loop through it for route bindings. This leads to the next version of our example: we’ll have a list of dictionaries in routes_configuration.py
, where each entry defines a route. The submodules will contain only the callables, and our main module will simply import the dictionary and assign the routes.
Our routes configuration file will look like
Final Version
routes_configuration = [
{
"url": "/users/{user_id}",
"callable": "read_user",
"module": "users",
"response_model": {},
"methods": ["GET"]
},
{
"url": "/items/{item_id}",
"callable": "read_item",
"module": "items",
"response_model": {},
"methods": ["GET"]
},
]
Our items.py and users.py will be instead like
items.py
##########
from typing import Union
def read_item(user_id: int):
return {
"item_id": user_id,
"source": "items"
}
users.py
##########
from typing import Union
def read_user(user_id: int):
return {
"user_id": user_id,
"source": "users"
}
Our main.py instead will be slightly different because we have to import the modules, the APIRoute and bind the routes. After all the routes have been bound to the APIRouter instance we need to register that into the main router that is attached to the application itself so that can be publicly accessible
from fastapi import FastAPI, APIRouter
from .routes import items, users
from .routes.routes_configuration import routes_configuration
app = FastAPI()
router = APIRouter()
for route in routes_configuration:
router.add_api_route(
route.get("url"),
endpoint=getattr(globals()["users"], 'read_user'),
methods=route.get("methods"),
response_model=route.get("response_model")
)
app.router = router
@app.get("/")
def read_root():
return {"Hello": "World2"}
And that’s it! In this small example, globals
is being used; however, this is not the correct way to do it—it’s just to demonstrate the potential power of this technique. The best approach would be to create a class and then dynamically call its methods. We might cover this in one of the upcoming articles, but this is giving you the general idea of how ti could work, as long the routes_configuration will be there, all the endpoints will be created and nothing is blocking the fact that the endpoint list can be generated (for example) by looking at a database table.
I hope you enjoyed this guide and if so share and let us grow
Share this content: