FastAPI by A Python Beginner (3)
Routing and Directory Structure & Param Basics
This is a multiparter journey. I don’t know where it’s going to ends. You’re always welcome if you’re interested in this topic or want to join this step-by-step journey.
Last time, I was thinking about writing some introduction about OpenAPI. But I feel like to do more coding. So, here we go.
Routing and Directory Structure
Until last time, I have written something inside my main.py
file. While everything works great, but I can’t just keep adding more lines of code into the file… There’s definitely a better way to organize my code into different files, right?
At the first day I started to learn Python. I’ve learnt that in Python, every directory (or folder depends on which camp you are) is a package, and every file is a module. So, if I want to create a create a new package, all I need to do is create a new directory.
This is what I left last time…
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Track(BaseModel):
title: str
artist: str
album: str | None = None
year: int
label: str | None = None
tracks: list[Track] = []
@app.get("/")
async def root():
return {"Hello": "World!"}
@app.get("/tracks")
async def get_tracks():
return tracks
@app.post("/tracks")
async def create_track(track: Track):
tracks.append(track)
return track
I’d like to make a new tracks
module, which embed all tracks
related routes. So, my main.py
won’t blow up with all the codes. The idea is to create a new package called routers
that I can put all the modules inside including my tracks
module. If everything works correctly, I can use from routers import tracks, SOME_OTHER_MODULE, …
in my main.py
.
app
├── __init__.py
├── main.py
├── routers
├── __init__.py
├── tracks.py
dockerfile
docker-compose.yml
requirements.txt
A directory called routers
and a tracks.py
file, that’s it! But I also create an empty __init__.py
file under the routers
directory. Again, this is not mandatory, but I figure there’s maybe one day I’ll need it. So, let’s just leave it there.
Now it’s time to move all the tracks
related code into my new file. But before that, I’ll need to figure out how to make the make app
works in the new file, because so far, I’m using @app.get(...)
to create new routes. So, I should import app
to my new track
file? That’s like a circular import, right? I don’t know about Python, but this doesn’t feel like a right thing to do and will definitely cause trouble.
The answer is, inside fastapi
there’s a module called APIRouter
module, that allows us to create a new route that can be consumed by our app
. OK knowing is half the battle. Time to implement my tracks
module.
from fastapi import APIRouter
router = APIRouter(
prefix="/tracks",
tags=["Tracks"],
response=({404: {"description": "Not Found"}})
)
@router.post("")
async def create_track(track: Track):
tracks.append(track)
return track
I created a router
variable with APIRouter
. We can initialize the APIRouter
with multiple properties, currently I only care about some of them.
prefix
— This means the URL of this endpoint.tags
— This gives us a nice tag in our documentation.response
— This is the default response. So, if someone hit some unexpected URL, it will fall back to this response.
There’s something interesting about them. First, the tag
property is a list
, means we can put this route under multiple tagged section just like this…
As the picture above. I have two tags Track
and Track2
on my track
route. That’s cool, but not too useful right now.
And the second one is the name of prefix
property. What “prefix” pretty much indicates everything under this route will automatically apply this prefix in front of all the paths. That’s why I left the @router.post("/")
as it is. If someone want to call this endpoint, it’ll be localhost/tracks
. So, as my route grows, I can skip the manual labour to put /tracks
on each endpoint.
The next step will be wire up my new APIRouter
to the app
. Inside the FastAPI
instance, there’s a method called include_router
, which allows me to put the newly created route into my app
.
from fastapi import FastAPI
from routers import tracks
app = FastAPI()
app.include_router(tracks.router)
Easy enough. All I need to do is import my tracks
module and call the include_router
method with it.
But I don’t quite like it… I’d like to have a glance of the prefix
of all the routers. That will be a great help when I need to change the route prefix, then I don’t need to go through file to change it (or maybe I record a macro to do it… but it could actually become a disaster due to my clumsiness).
And FastAPI does have solution for my need.
from fastapi import FastAPI
from routers import tracks
app = FastAPI()
fallback = {404: {"description": "NotFound"}}
app.include_router(tracks.router, prefix="/tracks", tags=["Tracks"], response=fallback)
@app.get("/")
async def root():
return {"Hello": "World!"}
After the first argument, the include_router
has basically all the parameters as APIRouter
. Now I can see all the router prefix in one place. I prefer this way more than the other. After this modification, I can just leave the APIRouter
inside my tracks
module empty.
Now, I’ve learnt how to structure my project, and how to add new route to my app
. Next, I want to explore a little bit about how to use param
, all kind of param
.
Param Basics
There are two kinds of params, Path and Query.
The path param is part of the endpoint’s path. If we inspect the classic RESTful API for request a blog post by certain ID, it will look something like this /posts/1
. The path param is /1
. The reason why it’s a param is because it’s interchangeable. If we want another post with the ID 2, it will be /posts/2
. But the endpoint stays the same, it’s always /posts
. The path param enable a cleaner way to create our API.
The query param is a more traditional way, it belongs to the search(query) parameter part of the URL. It’s definitely not the cleanest, but its flexible and much needed for more sophisticated usage. The query param is whatever behind the ?
in the URL. For example, if we want to find all the posts made by someone called “John”, it might probably look like this /posts?author=john
.
We can definitely go with query param only, because everything that path query can do can also be done with query param. If we use something like /posts?id=1&author=john
. But it’s not just look bad on the client-side, we’ll also end up put all the code inside one route. It’s not the best idea. Apparently, we’ll need both of them.
Let’s create some fake data that we can use later.
class Track(BaseModel):
id: int
title: str
artist: str
album: str | None = None
year: int
label: str | None = None
tracks: list[Track] = [
{
"id": 1,
"title": "Here comes the sun",
"artist": "Beatles",
"album": "Abby Road",
"year": 1969,
"label": "Apple Records",
},
{
"id": 2,
"title": "Song 2",
"artist": "Blur",
"album": "Blur",
"year": 1997,
"label": "Parlophone",
},
{
"id": 3,
"title": "High & Dry",
"artist": "Radiohead",
"album": "The Bends",
"year": 1995,
"label": "EMI",
},
{
"id": 4,
"title": "The Rain Song",
"artist": "Le Zeppelin",
"album": "Houses of The Holy",
"year": 1973,
"label": "Atlantic Records",
},
]
Noticed I add an id
attribute to my Track
class. Which can help me to find it with the newly created path param later. And I populated some songs in the tracks
variable.
Next, I create a new route with it’s path as /{track_id}
.
@router.get("/{track_id}")
async def get_track_by_id(track_id: int):
for track in tracks:
if track["id"] == track_id:
return track
Noticed that the track_id
inside curly bracket of the route path has the same name as the argument of get_track_by_id
. That is basically how to use the path param. So, theoretically we can have multiple path queries like /{track_id}/{SOME_OTHER_PARAM}
. But I don’t think I need it for now.
And then I use a for in
loop to go through the tracks
list, if the track id equals to the track_id
param just return the track.
This works fine as long as the track with give id does exist. If the id doesn’t exist, it will just return null
. There’s nothing wrong to just return a null
, but I think we can do it better.
from fastapi import APIRouter, HTTPException
@router.get("/{track_id}")
async def get_track_by_id(track_id: int):
for track in tracks:
if track["id"] == track_id:
return track
raise HTTPException(status_code=404, detail="Track not found")
There’s a built-in HTTPException
class that we can use to create a proper HTTP exception for us. Now if someone try to request a song that doesn’t exist, they will get a nice 404
not found response.
What if someone send a string instead of integer as track ID? Luckly the Pydantic can handle this for us. In the Swagger UI, it will not execute the request, and show a clear warning about the param should be integer. But if we send a request directly with the Swagger UI, it will response with the following error.
{
"detail": [
{
"type": "int_parsing",
"loc": [
"path",
"track_id"
],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "HELLO",
"url": "https://errors.pydantic.dev/2.3/v/int_parsing"
}
]
}
This is when I try to access the endpoint with /tracks/HELLO
. And it also gets a 422
Unprocessable Entity status. Which is great.
I think this route works OK. Now I want to make the /tracks
route accept some query params that allow us to do pagination and maybe search for artist.
@router.get("")
async def get_tracks(skip: int = 0, limit: int = 1, q: str | None = None):
matched: [Track] = []
if q:
for track in tracks:
if track["artist"].lower() == q.lower():
matched.append(track)
else:
return tracks[skip : skip + limit]
return matched[skip : skip + limit]
To define the query params is simple, just add the query name as the arguments of get_tracks
function. For pagination purposes, I add a skip
and limit
to make it an offset-based pagination, that user can choose how many tracks they want in one request with limit
and where they want to start the list with skip
. And a q
param for searching artist.
I’m not going to make it too complicated right now by allowing the q
to search multiple attributes. Because it’s going to be a major overhead to implement this in the code and will soon be deleted once I start integrating a database.
So, in the code I start declaring a new matched
variable that can store all the tracks that matches the search criteria. And then check if q
has any value?
If q
is None
, I’ll just return the tracks
with selected range. In Python, we can access list
with [START_INDEX : END_INDEX]
to retrieve the items in the certain range. So, the tracks[skip : skip + limit]
basically means… I want the tracks item from skip
to skip + limit
. Which I found pretty convenient.
If q
does have value. I then go through the tracks
list to find if there’s any tracks that is made by the artist q
and append them into the matched
variable. And return the matched
after applying the same logic to select the certain range of the matched
list.
Yes, it’s not a very optimal code. I can break the loop once it has enough tracks appended into it. But again, I’ll let SQL do the job.
And now I can go to the Swagger and see my /tracks
has all the query params in the parameters section. And I can search the tracks by artists, I can set how many tracks I want from one request. Everything works!
Unlike the /tracks/{track_id}
route, I’m not going to throw an error if no track has been found, this endpoint will always return a list of tracks even if it’s an empty list. This will be easier to work with when I start working on the frontend code (I’m not sure if I’m going to though).
That all of it today. I think there’s only a couple of really important components about making an API. One is authentication and authorization, which could be quite tricky to do. After those major topics, I’ll probably use more spaces for codes, and less for explaining things and ideas.