juniper
juniper copied to clipboard
Proposal: extensible Context design
Background
Currently, Context
for execution GraphQL request is just a type parameter. Anything other is up to the library user:
- the structure of the
Context
and the data it provides; - the way it's injected into execution.
These raises number of issues and has downsides:
- Using integration crates doesn't allow to access request-specific data in resolvers (#632, https://github.com/graphql-rust/juniper/pull/433#issuecomment-553824423), as they're usually accept finalized
Context
into HTTP handler closure and just reuse it from there. - Extending
Context
with custom stuff (dataloader, query depth checker, authorization provider, etc) is often non-trivial and makesContext
a kind of god-object, which unites almost everything of the app in one place. - It's hard to cleanly separate schema definition and schema implamntation, as resolvers require a concrete
Context
type (at least for now, proc macros doesn't support specifying generics). - Sometimes, type errors and confusion happens, when different parts of schema implicitly imply different
Context
types and then cannot be merged together.
Proposed solution
Instead of being a type parameter, a Context
can be a following type (simplified):
struct Context(HashMap<TypeId, Box<dyn Any>>);
impl Context {
pub async fn get<T: FromContext>(&self) -> Result<&T, Error> {
let id = TypeId::of::<T>();
if !self.0.contains_key(&id) {
let val = E::from_context(self).await?;
self.0.entry(id).or_insert_with(|| Box::new(val));
}
Ok(self.0.get(&id).and_then(|boxed| (&**boxed).downcast_ref()).unwrap())
}
}
The idea is to be a small in-place DI container, where any requested type can be built up from other values, already contained in Context
:
#[async_trait]
pub trait FromContext {
type Error: IntoFieldError;
pub async fn from_context(ctx: &Context) -> Result<Self, Self::Error>;
}
#[async_trait]
impl FromContext for HttpHost {
type Error = Infallible;
pub async fn from_context(ctx: &Context) -> Result<Self, Self::Error> {
let req = ctx.get::<HttpRequest>().await
.expect("Context must be seeded with HttpRequest");
Ok(HttpHost(req.host()))
}
}
Of course, to work properly it should be seeded before the execution:
fn post_request_handler(schema: Schema) -> HandleFn {
move |req| async move {
let gql = serde_json::from_str(&req.body())?;
let ctx = Context::builder().put(req.clone).build();
let resp = req.execute(schema, ctx).await?;
resp.into()
}
}
Benefits
-
Ability to decouple everything contained in
Context
. We can mix there anything we want without introducing god-knowledge toContext
. A proper seeding is required at application initialization time. -
Ability to use both global data (seeded) and request-contexted (initialized lazily during execution).
-
Ability to get arbitrary types from
Context
. You need only to specifyFromContext
implementation for it. Implementation crates can provide the full access to any upstream information for downstream users. -
Removing type parameter for
Context
will simplify all the schema and types.
Usage in resolvers
Dataloading
async fn user(
&self,
id: schema::UserId,
context: &Context,
) -> Result<Option<schema::User>, Error> {
context.get::<UserLoader>().await?.load(id.into()).await
}
Auth and repository
async fn updateName(
&self,
name: schema::UserName,
context: &Context,
) -> Result<schema::User, Error> {
let auth = context.get::<AuthProvider>().await?;
let my = auth.current_subject().await?;
let repo = context.get::<UserRepo>().await?;
repo.update_user_name(my.id, name.into()).await?;
Ok(my)
}
Costs and alternatives
This will make a schema not zero-cost. Because for each type contained in Context
we will require at least on allocation (to put into context). And each time we get something from Context
a downcasting is performed.
If such cost is not acceptable, the Context
descriped above may be just a common implementation provided by a crate and be a default type for type parameter. This will fully preserve backward compatibility and will allow to use any other Context
s if the one is not good enough to use in some case.
Let me think about this a bit.
This looks really similar to how Context
data is retrieved in async_graphql
: in their book.
Hola @LegNeato! 👋 Did you have time to consider this proposal? 🙂