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
Contextand 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
Contextinto HTTP handler closure and just reuse it from there. - Extending
Contextwith custom stuff (dataloader, query depth checker, authorization provider, etc) is often non-trivial and makesContexta 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
Contexttype (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
Contexttypes 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 specifyFromContextimplementation for it. Implementation crates can provide the full access to any upstream information for downstream users. -
Removing type parameter for
Contextwill 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 Contexts 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? 🙂