Constructors
To make a type injectable, you need to register a constructor for it.
A constructor must satisfy a few requirements:
- It must be a function, a method or a trait method.
- It must be public (1), importable from outside the crate it is defined in.
- It must return, as output, the type you want to make injectable.
Constructors can be fallible: a constructor for a typeT
can returnResult<T, E>
, whereE
is an error type.
- Constructors must be invoked in the generated code. The generated code lives in a separate crate, the [server SDK crate], hence the requirement.
Going back to our User
example, this would be a valid signature for a constructor:
Warning
Constructors can be either sync or async.
Check out
the "Sync or async" section in the guide on request handlers
to learn when to use one or the other.
Registration
Once you have defined a constructor, you need to register it with the application Blueprint
:
use pavex::blueprint::constructor::Lifecycle;
use pavex::blueprint::{router::GET, Blueprint};
use pavex::f;
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
bp.constructor(f!(crate::User::extract), Lifecycle::RequestScoped);
// [...]
}
Blueprint::constructor
takes two arguments:
- An unambiguous path to the constructor, wrapped in the
f!
macro. - The constructor's lifecycle.
Alternatively, you could use Blueprint::request_scoped
as
a shorthand to perform the same registration:
use pavex::blueprint::constructor::Lifecycle;
use pavex::blueprint::{router::GET, Blueprint};
use pavex::f;
pub fn blueprint() -> Blueprint {
let mut bp = Blueprint::new();
bp.request_scoped(f!(crate::User::extract));
// [...]
}
There is a shorthand for each lifecycle: Blueprint::singleton
,
Blueprint::request_scoped
, Blueprint::transient
.
Lifecycles
Pavex supports three different lifecycles for constructors:
Singleton
. The constructor is invoked at most once, before the application starts.
The same instance is injected every time the type is needed.RequestScoped
. The constructor is invoked at most once per request.
The same instance is injected every time the type is needed when handling the same request.Transient
. The constructor is invoked every time the type is needed.
The injected instance is always newly created.
Let's look at a few common scenarios to build some intuition around lifecycles:
Scenario | Lifecycle | Why? |
---|---|---|
Database connection pool | Singleton | The entire application should use the same pool. Each request will fetch a connection from the pool when needed. |
HTTP client | Singleton | Most HTTP clients keep, under the hood, a connection pool. You want to reuse those connections across requests to minimise latency and the number of open file descriptors. |
Path parameters | RequestScoped | Path parameters are extracted from the incoming request. They must not be shared across requests, therefore they can't be a Singleton .They could be Transient , but re-parsing the parameters before every use would be expensive.RequestScoped is the optimal choice. |
Database connection | Transient | The connection is retrieved from a shared pool. It could be RequestScoped , but you might end up keeping the connection booked (i.e. outside of the pool) for longer than it's strictly necessary.Transient is the optimal choice: you only remove the connection from the pool when it's needed, put it back when idle. |
Recursive dependencies
Dependency injection wouldn't be very useful if all constructors were required to take no input parameters.
The dependency injection framework is recursive: constructors can take advantage of dependency injection
to request the data they need to do their job.
Going back to our User
example: it's unlikely that you'll be able to build a User
instance without
taking a look at the incoming request, or some data extracted from it.
Let's say you want to build a User
instance based on the value of the Authorization
header
of the incoming request.
You could define a constructor like this:
use pavex::request::RequestHead;
// [...]
impl User {
pub fn extract(request_head: &RequestHead) -> Self {
todo!() // Business logic goes here
}
}
RequestHead
represents the incoming request data, minus the body.
When Pavex examines your application Blueprint
, the following happens:
- The
reject_anonymous
middleware must be invoked. Doesreject_anonymous
have any input parameters?- Yes, it needs a
User
instance. Do we have a constructor forUser
?- Yes, we do:
User::extract
. DoesUser::extract
have any input parameters?- Yes, it needs a reference to a
RequestHead
. Do we have a constructor forRequestHead
?- Etc.
- Yes, it needs a reference to a
- Yes, we do:
- Yes, it needs a
The recursion continues until Pavex finds a constructor that doesn't have any input parameters or
a type that doesn't need to be constructed.
If a type needs to be constructed, but Pavex can't find a constructor for it,
it will report an error.
Constructors can fail
Constructors can be fallible: they can return a Result<T, E>
, where E
is an error type.
If a constructor is fallible, you must specify an error handler when registering
it with the application Blueprint
.
Check out the error handling guide for more details.
Invocation order
Pavex provides no guarantees on the relative invocation order of constructors.
Consider the following request handler:
use super::{A, B};
use pavex::response::Response;
pub fn handler(a: A, b: B) -> Response {
// Handler logic
// [...]
}
It injects two different types as input parameters, A
and B
.
The way input parameters are ordered in handler
's definition does not influence the invocation order
of the respective constructors. Pavex may invoke A
's constructor before B
's constructor,
or vice versa.
The final invocation order will be primarily determined based on:
- Dependency constraints.
IfA
's constructor takesC
as input andC
's constructor takes&B
as input,B
's constructor will certainly be invoked beforeA
's. There's no other way! - Borrow-checking constraints.
IfA
's constructor takes a reference toC
as input, whileB
's constructor takesC
by value, Pavex will invokeA
's constructor first to avoid a.clone()
.
No mutations
Constructors are not allowed to take mutable references (i.e. &mut T
) as inputs.
It'd be quite difficult to reason about mutations since you can't control the
invocation order of constructors.
On the other hand, invocation order is well-defined for other types of components: request handlers, pre-processing middlewares and post-processing middlewares. That's why Pavex allows them to inject mutable references as input parameters.
Wrapping middlewares
Invocation order is well-defined for wrapping middlewares, but Pavex
doesn't let them manipulate mutable references.
Check their guide
to learn more about the rationale for this exception.