I’ve been dusting off my Python recently and building some APIs with FastAPI. I had a pretty simple use case - a backend API that uses API keys for authentication. However, I couldn’t find a straightfoward tutorial. The FastAPI docs are very good but a little too in-depth; for example they bundle the creation of OAuth bearer tokens into the same code as the backend itself. So I wanted to create a template for myself that simplifies the authentication, abstracts the database stuff, and also follows a reasonable best-practices approach to project layout, modules, packages and so on. So here it is! But be warned; like I said, I’m still rusty. If you spot anything obviously terrible in the code below, please let me know (nicely).
Project layout
We’ll start with a simple Python project layout for a FastAPI app.
.
├── app
│ ├── __init__.py
│ ├── auth.py
│ ├── db.py
│ ├── main.py
│ └── routers
│ ├── __init__.py
│ ├── public.py
│ ├── secure.py
├── venv
Hopefully this makes sense if you’re fairly comfortable with Python. app
is our Python package in this case, main
is our main module, while auth
and db
are utility modules in the package. routers
is a subpackage containing the public
and secure
submodules. There’s empty __init__.py
files aplenty to tell Python this is a proper package, and in my case I also have a venv
directory for my virtual environment, although that isn’t required. Now let’s go into what each of these files actually does.
main.py
Here’s our main module:
from fastapi import FastAPI, Depends
from routers import secure, public
from auth import get_user
app = FastAPI()
app.include_router(
public.router,
prefix="/api/v1/public"
)
app.include_router(
secure.router,
prefix="/api/v1/secure",
dependencies=[Depends(get_user)]
)
We start by importing the modules we’ll need from FastAPI, namely itself and the Depends
module. From our own auth
module we import a function called get_user
which we’ll explain in a moment. From our routers
subpackage we import secure
and public
. And we create our app with app = FastAPI()
.
So what are these routers? Well, in a simple FastAPI app we could just start defining paths and responses in our main program. Here’s a super simple example:
@app.get("/")
async def root():
return {"message": "Hello World"}
In 3 lines we can specify that we want to return “Hello World” for every GET
request to /
(asynchronously to boot!). But this doesn’t scale very well.
A router is a separate set of paths and functions grouped together. They’re useful for grouping features of a much larger API together into logical pieces. In this tutorial, we’ll create two groups of paths: secure
for paths that require an API key, and public
for those that don’t.
Let’s look at this piece again:
app.include_router(
public.router,
prefix="/api/v1/public"
)
Here we call the include_router
function on our app
, and pass in 2 paramters. The first, public.router
is the router
object from the public
module. The second prefix
, defines where these paths sit in the context of our overall API app. As you can see, routers make it easy to change URL prefixes and even introduce API versioning.
The next one is a bit more complex:
app.include_router(
secure.router,
prefix="/api/v1/secure",
dependencies=[Depends(get_user)]
)
Now we’re using the router
object from the secure
module, and we’ve added a dependency using FastAPI’s Depends
function. The dependency is on the get_user
function, that we’ve defined in the auth
module. As you can probably guess, that means that all paths in this router depend on that function. So let’s take a look at it.
auth.py
from fastapi import Security, HTTPException, status
from fastapi.security import APIKeyHeader
from db import check_api_key, get_user_from_api_key
api_key_header = APIKeyHeader(name="X-API-Key")
def get_user(api_key_header: str = Security(api_key_header)):
if check_api_key(api_key_header):
user = get_user_from_api_key(api_key_header)
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid API key"
)
First we import some more useful stuff from FastAPI, including methods for dealing with headers and HTTP exceptions. They’ll make more sense when we get to the part that actually uses them in a moment. And yes, I do appreciate that this is a bit circular, because next we’re importing functions from db
which we haven’t seen yet. But before we get into that file, just know that check_api_key
returns True
if an API key is valid, and get_user_from_api_key
returns a user object from a valid API key.
The main purpose of the auth module is to provide us with the get_user
function, which as we saw in the previous file, our secure routes depend on. This function accepts one parameter - the value of X-API-Key
that has been sent in the request. Using the APIKeyHeader
and Security
functions in FastAPI allows us to define the header name for our API key (and therefore populate this automatically in our OpenAPI documentation) and extract it from the header.
With the API key stored in api_key_header
, we next call our check_api_key
function (again, we’ll see how that works in a moment). If the response is True
, we proceed to get some user data by calling get_user_from_api_key
and then return it.
If check_api_key
returned False
, then the API key we checked wasn’t valid. So we use FastAPI’s HTTPException
to return a suitable response, in this case a 401 error.
Now we know what has to happen for every path that depends on get_user
. Before we look at those paths and routes, let’s quickly explore db.py
, which is where we defined check_api_key
and get_user_from_api_key
.
db.py
This file is basically a placeholder for whatever backend database integration you want to use. Right now, it implements the methods we needs, and contains a hard-coded set of API keys and users.
api_keys = {
"e54d4431-5dab-474e-b71a-0db1fcb9e659": "7oDYjo3d9r58EJKYi5x4E8",
"5f0c7127-3be9-4488-b801-c7b6415b45e9": "mUP7PpTHmFAkxcQLWKMY8t"
}
users = {
"7oDYjo3d9r58EJKYi5x4E8": {
"name": "Bob"
},
"mUP7PpTHmFAkxcQLWKMY8t": {
"name": "Alice"
},
}
def check_api_key(api_key: str):
return api_key in api_keys
def get_user_from_api_key(api_key: str):
return users[api_keys[api_key]]
I’m sure I don’t need to tell you - don’t do this in production! Instead, you should incorporate whatever database connections and query methods you need to to acquire API keys and user details from your database (how you actually create those keys is another discussion). The actual data structures may be very different as well.
For now, we have a simple dict
of key:value pairs for api_keys
where the key is the actual API key, and the value is the user ID. I’m using long UUIDs for API keys, and short UUIDs for user IDs, but again this could be something very different. For the users we have another dict
where the key is the user ID, and the value is another dict
to hold all the important user data. Right now we’re just storing a name.
check_api_key
simply checks that the API key exists in the list of keys it knows about. Again, you’d probably need to change this for some sort of DB lookup in production.
get_user_from_api_key
then uses the API key to get a corresponding user ID, and returns the nested user dict
.
So far we have all of our basic functionality in terms of API authentication. Now we just need some routes!
routers/public.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/")
async def get_testroute():
return "OK"
This is about as simple as it gets. In this module, all we do is define a router
object, with a single path. When a request is made to /
. it will return "OK"
. Don’t forget, the actual path is relative, and if you refer back to main.py
you’ll see we set up this router to run under /api/v1/public
.
routers/secure.py
from fastapi import APIRouter, Depends
from auth import get_user
router = APIRouter()
@router.get("/")
async def get_testroute(user: dict = Depends(get_user)):
return user
The secure routes are only slightly more complex. Just like in main.py
we’re using FastAPI’s Depends
to specify that we are dependent on a function, in this case: get_user
. So we know this route is guaranteed secure, and can only be accessed by someone with a valid API key, because without one you won’t get through the get_user
function without triggering the HTTP Not Authorized error we set up earlier. If you have a valid key, for now we just return the user object.
Setup
This may vary based on your own development preferences, but the quickest way I find to set up a FastAPI app is to create a virtualenv
, install the FastAPI package, and then use Uvicorn to run the app. You may have noticed the venv
directory earlier - I created this with:
python -mvenv venv
I then activated the virtual environment and installed FastAPI:
source venv/bin/active
pip install "fastapi[standard]"
Finally, to run my FastAPI app, I can now run this command from the app
directory:
uvicorn main:app --reload
The --reload
option will reload my code automatically if I make any changes. If everything has worked for you, you should be able to access your app at http://127.0.0.1:8000/
Testing
First we sent a GET request with no API key to the public endpoint /api/v1/public/
:
Now let’s try the secure endpoint at /api/v1/secure/
:
The API returns a 403 error. Note, this isn’t the 401 error we coded for when an API key isn’t found in the database. FastAPI can’t even find an API key, because we haven’t specified one, so immediately returns the 403 Forbidden error.
Let’s try a random API key that is not in the database:
This time, we’ve supplied an X-API-Key
header, but the key we’re supplying isn’t in the database. So we return a 401 Unauthorized error (technically, we’re unauthenticated but these error codes were written a long time ago!)
Finally, let’s try a working API key, for the user Bob:
Success! The API key is found, checked and proven valid, and the user details are retrieved and used in the response.
What’s next?
This is hopefully some helpful project boilerplate to get you started on your own FastAPI app. Don’t forget, we’ve hardcoded a dummy database in db.py
, so connecting the authentication up to a real database should be the next thing we do. I’ll try to tackle this in the next post!
From there, we could continue to implement different routes and paths, which brings us onto the topics that come after authentication - authorisation and/or admission control.
Let me know what you think in the comments!