Dependency Injection
Dependency injection is a method to add arguments to a route handler based upon the defined function signature. Specifically, it looks at the type annotations of the arguments in the handler. This can be useful in a number of cases like:
- Fetching an object based upon request headers (like the current session user)
- Recasting certain objects into a specific type
- Using the request object to prefetch data
- Auto inject services
The Extend
instance has two basic methods on it used for dependency injection: a lower level add_dependency
, and a higher level dependency
.
Lower level: app.ext.add_dependency(...)
type: Type,
: some unique class that will be the type of the objectconstructor: Optional[Callable[..., Any]],
(OPTIONAL): a function that will return that type
Higher level: app.ext.dependency(...)
obj: Any
: any object that you would like injectedname: Optional[str]
: some name that could alternately be used as a reference
Let's explore some use cases here.
Warning
If you used dependency injection prior to v21.12, the lower level API method was called injection
. It has since been renamed to add_dependency
and starting in v21.12 injection
is an alias for add_dependency
. The injection
method has been deprecated for removal in v22.6.
Basic implementation#
The simplest use case would be simply to recast a value.
This could be useful if you have a model that you want to generate based upon the matched path parameters.
@dataclass
class IceCream:
flavor: str
def __str__(self) -> str:
return f"{self.flavor.title()} (Yum!)"
app.ext.add_dependency(IceCream)
@app.get("/<flavor:str>")
async def ice_cream(request, flavor: IceCream):
return text(f"You chose: {flavor}")
$ curl localhost:8000/chocolate
You chose Chocolate (Yum!)
This works by passing a keyword argument to the constructor of the type
argument. The previous example is equivalent to this.
flavor = IceCream(flavor="chocolate")
Additional constructors#
Sometimes you may need to also pass a constructor. This could be a function, or perhaps even a classmethod that acts as a constructor. In this example, we are creating an injection that will call Person.create
first.
Also important to note on this example, we are actually injecting two (2) objects! It of course does not need to be this way, but we will inject objects based upon the function signature.
@dataclass
class PersonID:
person_id: int
@dataclass
class Person:
person_id: PersonID
name: str
age: int
@classmethod
async def create(cls, request: Request, person_id: int):
return cls(person_id=PersonID(person_id), name="noname", age=111)
app.ext.add_dependency(Person, Person.create)
app.ext.add_dependency(PersonID)
@app.get("/person/<person_id:int>")
async def person_details(
request: Request, person_id: PersonID, person: Person
):
return text(f"{person_id}\n{person}")
$ curl localhost:8000/person/123
PersonID(person_id=123)
Person(person_id=PersonID(person_id=123), name='noname', age=111)
When a constructor
is passed to ext.add_dependency
(like in this example) that will be called. If not, then the object will be created by calling the type
. A couple of important things to note about passing a constructor
:
- A positional
request: Request
argument is usually expected. See thePerson.create
method above as an example using arequest
and arbitrary constructors for how to use a callable that does not require arequest
. - All matched path parameters are injected as keyword arguments.
- Dependencies can be chained and nested. Notice how in the previous example the
Person
dataclass has aPersonID
? That means thatPersonID
will be called first, and that value is added to the keyword arguments when callingPerson.create
.
Arbitrary constructors#
Sometimes you may want to construct your injectable without the Request
object. This is useful if you have arbitrary classes or functions that create your objects. If the callable does have any required arguments, then they should themselves be injectable objects.
This is very useful if you have services or other types of objects that should only exist for the lifetime of a single request. For example, you might use this pattern to pull a single connection from your database pool.
class Alpha:
...
class Beta:
def __init__(self, alpha: Alpha) -> None:
self.alpha = alpha
app.ext.add_dependency(Alpha)
app.ext.add_dependency(Beta)
@app.get("/beta")
async def handler(request: Request, beta: Beta):
assert isinstance(beta.alpha, Alpha)
Added in v22.9
Objects from the Request
#
Sometimes you may want to extract details from the request and preprocess them. You could, for example, cast the request JSON to a Python object, and then add some additional logic based upon DB queries.
Warning
If you plan to use this method, you should note that the injection actually happens before Sanic has had a chance to read the request body. The headers should already have been consumed. So, if you do want access to the body, you will need to manually consume as seen in this example.
await request.receive_body()
This could be used in cases where you otherwise might:
- use middleware to preprocess and add something to the
request.ctx
- use decorators to preprocess and inject arguments into the request handler
In this example, we are using the Request
object in the compile_profile
constructor to run a fake DB query to generate and return a UserProfile
object.
@dataclass
class User:
name: str
@dataclass
class UserProfile:
user: User
age: int = field(default=0)
email: str = field(default="")
def __json__(self):
return ujson.dumps(
{
"name": self.user.name,
"age": self.age,
"email": self.email,
}
)
async def fake_request_to_db(body):
today = date.today()
email = f'{body["name"]}@something.com'.lower()
difference = today - date.fromisoformat(body["birthday"])
age = int(difference.days / 365)
return UserProfile(
User(body["name"]),
age=age,
email=email,
)
async def compile_profile(request: Request):
await request.receive_body()
profile = await fake_request_to_db(request.json)
return profile
app.ext.add_dependency(UserProfile, compile_profile)
@app.patch("/profile")
async def update_profile(request, profile: UserProfile):
return json(profile)
$ curl localhost:8000/profile -X PATCH -d '{"name": "Alice", "birthday": "2000-01-01"}'
{
"name":"Alice",
"age":21,
"email":"[email protected]"
}
Injecting services#
It is a common pattern to create things like database connection pools and store them on the app.ctx
object. This makes them available throughout your application, which is certainly a convenience. One downside, however, is that you no longer have a typed object to work with. You can use dependency injections to fix this. First we will show the concept using the lower level add_dependency
like we have been using in the previous examples. But, there is a better way using the higher level dependency
method.
The lower level API using add_dependency
#
This works very similar to the last example where the goal is the extract something from the Request
object. In this example, a database object was created on the app.ctx
instance, and is being returned in the dependency injection constructor.
class FakeConnection:
async def execute(self, query: str, **arguments):
return "result"
@app.before_server_start
async def setup_db(app, _):
app.ctx.db_conn = FakeConnection()
app.ext.add_dependency(FakeConnection, get_db)
def get_db(request: Request):
return request.app.ctx.db_conn
@app.get("/")
async def handler(request, conn: FakeConnection):
response = await conn.execute("...")
return text(response)
$ curl localhost:8000/
result
The higher level API using dependency
#
Since we have an actual object that is available when adding the dependency injection, we can use the higher level dependency
method. This will make the pattern much easier to write.
This method should always be used when you want to inject something that exists throughout the lifetime of the application instance and is not request specific. It is very useful for services, third party clients, and connection pools since they are not request specific.
class FakeConnection:
async def execute(self, query: str, **arguments):
return "result"
@app.before_server_start
async def setup_db(app, _):
db_conn = FakeConnection()
app.ext.dependency(db_conn)
@app.get("/")
async def handler(request, conn: FakeConnection):
response = await conn.execute("...")
return text(response)
$ curl localhost:8000/
result
Generic types#
Be carefule when using a generic type. The way that Sanic's dependency injection works is by matching the entire type definition. Therefore, Foo
is not the same as Foo[str]
. This can be particularly tricky when trying to use the higher-level dependency
method since the type is inferred.
For example, this will NOT work as expected since there is no definition for Test[str]
.
import typing
from sanic import Sanic, text
T = typing.TypeVar("T")
class Test(typing.Generic[T]):
test: T
app = Sanic("testapp")
app.ext.dependency(Test())
@app.get("/")
def test(request, test: Test[str]):
...
To get this example to work, you will need to add an explicit definition for the type you intend to be injected.
import typing
from sanic import Sanic, text
T = typing.TypeVar("T")
class Test(typing.Generic[T]):
test: T
app = Sanic("testapp")
_singleton = Test()
app.ext.add_dependency(Test[str], lambda: _singleton)
@app.get("/")
def test(request, test: Test[str]):
...
Configuration#
By default, dependencies will be injected after the http.routing.after
signal. Starting in v22.9, you can change this to the http.handler.before
signal.
app.config.INJECTION_SIGNAL = "http.handler.before"
Added in v22.9