Before I had my first real job, most projects I worked on were just for me, like the chat “app” I built that was only ever used by me and my family.
There weren’t other developers that I was working with, and no one cared how maintainable my code was. However, in the “real world”, you learn pretty quickly how important it is to write easily testable code.
One of the first concepts I learned at my first job was dependency injection. In this post we’ll go over what it is and how to effectively use it, with FastAPI’s depends.
What is dependency injection?
Dependency injection is a fancy way of saying “functions/objects should have the variables they depend on passed into them, instead of constructing it themselves.” To best understand it, let’s look at an example:
from datetime import date
def get_formatted_date():
return date.today().strftime("%b %d, %Y")
That function seems simple enough, but what if we wanted to test it? It’s actually a little tricky to test because the date keeps changing. We could get the date ourselves and use that to compare, but then we are basically rewriting the function.
What if we rewrote our function like this:
from datetime import date
class Clock:
def today(self):
return date.today()
def get_formatted_date(clock):
return clock.today().strftime("%b %d, %Y")
It looks very similar, but the function we are testing now takes in a clock. The main advantage here is that we can create our own Clock to test with (commonly called a mock, or in this case a MockClock)
class MockClock:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
def today(self):
return date(self.year, self.month, self.day)
def test_get_formatted_date():
assert "Jan 01, 2021" == get_formatted_date(MockClock(2021, 1, 1))
assert "Apr 18, 2021" == get_formatted_date(MockClock(2021, 4, 18))
assert "May 19, 1999" == get_formatted_date(MockClock(1999, 5, 19))
And now we can easily test our function. In this case, the Clock was a dependency that was injected into our function get_formatted_date.
Wait… doesn’t this just push the problem to somewhere else?
Yes. You still need to construct a Clock and pass that in to your functions.
Typically, however, there are frameworks that will manage all that for you. If you’ve used pytest, you may be familiar with their fixtures, which are another form of dependency injection. Here’s a code snippet from the fixtures documentation:
import pytest
@pytest.fixture
def first_entry():
return "a"
@pytest.fixture
def order(first_entry):
return [first_entry]
def test_string(order):
order.append("b")
assert order == ["a", "b"]
You can see that just by adding an argument that matches a fixture’s name (order and first_entry), pytest automatically injects the return value of that method into our function. Good dependency injection frameworks won’t make you write too much “glue everything together” code.
FastAPI Depends
FastAPI has its own dependency injection built in to the framework. Let’s look at the first example from their docs:
async def common_parameters(
q: Union[str, None] = None, skip: int = 0, limit: int = 100
):
return {"q": q, "skip": skip, "limit": limit}
@app.get("/items/")
async def read_items(commons: dict = Depends(common_parameters)):
return commons
@app.get("/users/")
async def read_users(commons: dict = Depends(common_parameters)):
return commons
Seems straightforward, you create functions that can be injected into any route. Those two routes will now take in q, skip, and limit as query parameters.
Bundling together some query parameters is fine, but we can do a lot more. We’ll start with this example route:
from typing import Union
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
@app.get("/whoami")
async def who_am_i(x_api_key: Union[str, None] = Header(default=None), ):
if x_api_key is None:
raise HTTPException(status_code=401)
# See https://fastapi.tiangolo.com/tutorial/sql-databases/ for details on SessionLocal
db = SessionLocal()
try:
api_key = lookup_api_key(db, x_api_key)
if api_key is None:
raise HTTPException(status_code=401)
return {"user": api_key.user_id}
finally:
db.close()
This route takes in an API key via the X-Api-Key header, does a database lookup to see if it’s a valid key, and then returns the user who that key belongs to. If the key is invalid or missing, a 401 is returned.
Let’s clean this up with dependencies:
# This is recommended from FastAPI's docs
# By yielding, the request continues and uses the DB
# It'll only be close when the request is finished
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Dependencies can take in other dependencies, so we take in the DB
# We also take in the header, and do the same lookups as before
def user_id_from_api_key(db: Session = Depends(get_db),
x_api_key: Union[str, None] = Header(default=None)):
if x_api_key is None:
raise HTTPException(status_code=401)
api_key = lookup_api_key(db, x_api_key)
if api_key is None:
raise HTTPException(status_code=401)
return api_key.user_id
Now that we’ve defined our dependency, our route becomes really straightforward:
@app.get("/whoami")
async def who_am_i(user_id: str = Depends(user_id_from_api_key)):
return {"user": user_id}
Under the hood, this route is checking to make sure only users with valid API keys are able to use this endpoint. Any other routes that we want to protect, we can do so by just adding that one argument.
Because FastAPI is well designed, it knows that this request needs an X-Api-Key header and it adds that to it’s OpenAPI spec for that route:
Testing FastAPI dependencies
When we talked about dependency injection at the beginning, we talked about how it both made our code clean and easier to test. So far, we’ve only talked about how to make our FastAPI code cleaner.
It turns out, FastAPI has support for overriding dependencies when testing. This allows us to specify a mock version of dependencies that will be used across all our entire project. For the API key example, in tests we can just inject a specific user_id and not have to worry about setting up the database each time.
Summary
Dependency injection lets us structure our code in a way that’s both easy to maintain and easy to test.
Used along with a framework like FastAPI, you can do things like extracting and validating a user in one line of code. And it can be reused for any route and easily mocked in tests.
At PropelAuth, as an example, we used dependencies in our FastAPI library to encapsulate all our auth logic so a developer just needs to add one line of code and validating users/organizations are all taken care of for you.
Top comments (0)