juniper icon indicating copy to clipboard operation
juniper copied to clipboard

Proposal: extensible Context design

Open tyranron opened this issue 4 years ago • 3 comments

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 makes Context 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 to Context. 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 specify FromContext 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 Contexts if the one is not good enough to use in some case.

tyranron avatar May 08 '20 18:05 tyranron

Let me think about this a bit.

LegNeato avatar May 20 '20 05:05 LegNeato

This looks really similar to how Context data is retrieved in async_graphql: in their book.

loafofpiecrust avatar Sep 22 '20 17:09 loafofpiecrust

Hola @LegNeato! 👋 Did you have time to consider this proposal? 🙂

mrtnzlml avatar Jan 27 '21 14:01 mrtnzlml