graph-gophers / graphql-go

GraphQL server with a focus on ease of use
BSD 2-Clause "Simplified" License
4.64k stars 491 forks source link

The query resolver can't receive the context #610

Closed JoshCheek closed 1 year ago

JoshCheek commented 1 year ago

Hi, there may or may not be a current user saved in the context, I wanted to only need to grab it out of there in the first resolver so that subsequent resolvers didn't need to deal with the context. However, I can't do that, b/c I can't receive the context, I get this error.

It seems like I should be allowed to receive the context there so I can initialize state, since there doesn't seem to be any other way to initialize it for a given query (AFAICT, setting the user into the context is the only way to pass it to the resolvers, but the resolver that instantiates them can't access the context!)

My go.mod file contains this entry for graphql-go: github.com/graph-gophers/graphql-go v1.5.1-0.20230307005129-224841f523b3

Here is an example that illustrates approximately what I'm trying to do:

package main

import (
    "context"
    "encoding/json"
    "fmt"

    gql "github.com/graph-gophers/graphql-go"
    "github.com/graph-gophers/graphql-go/directives"
)

// Made up schema: you can ask for a course, and if you are logged in, then you
// can ask if you are enrolled in that course.
var s = `
    directive @isAuthenticated on FIELD_DEFINITION

    type Query {
        course(name: String!): Course
    }
    type Course {
        name: String!
        enrolled: Boolean! @isAuthenticated
    }
`

type RootResolver struct{}
type QueryResolver struct{ user *User }
type CourseResolver struct {
    user            *User
    requestedCourse string
}

type IsAuthenticatedDirective struct{}

func (h *IsAuthenticatedDirective) Resolve(ctx context.Context, args interface{}, next directives.Resolver) (output any, err error) {
    _, ok := ctx.Value("currentUser").(*User)
    if !ok {
        return nil, fmt.Errorf("Unauthorized")
    }
    return next.Resolve(ctx, args)
}
func (h *IsAuthenticatedDirective) ImplementsDirective() string {
    return "isAuthenticated"
}

// users will just have a "set" of courses they're enrolled in
type User struct{ courses map[string]bool }

// This doesn't work for some reason, I want to grab the user from the context in the root query resolver
// https://github.com/graph-gophers/graphql-go/blob/224841f523b3b94f1d75a0d428719a77b9bfc8a8/internal/exec/resolvable/resolvable.go#L198
//
//  func (r *RootResolver) Query(ctx context.Context) *QueryResolver {
//    user, _ := ctx.Value("currentUser").(*User)
//    return &QueryResolver{user: user}
//  }
func (r *RootResolver) Query() *QueryResolver {
    return &QueryResolver{user: nil}
}
func (r *QueryResolver) Course(args *struct{ Name string }) *CourseResolver {
    return &CourseResolver{user: r.user, requestedCourse: args.Name}
}
func (r *CourseResolver) Name() string {
    return r.requestedCourse
}

// It has to receive the context b/c the query resolver couldn't.
// So now every authenticated field will have to pull the current user out of the
// context instead of being able to do that once in the root query object.
func (r *CourseResolver) Enrolled(ctx context.Context) bool {
    // _, isEnrolled := r.user.courses[r.requestedCourse] // <-- what we want to do, we know the user is present b/c this field is authenticated
    user := ctx.Value("currentUser").(*User) // <-- what we have to do instead
    _, isEnrolled := user.courses[r.requestedCourse]
    return isEnrolled
}

func main() {
    opts := []gql.SchemaOpt{
        gql.UseFieldResolvers(),
        gql.Directives(&IsAuthenticatedDirective{}),
    }
    schema := gql.MustParseSchema(s, &RootResolver{}, opts...)

    run := func(user *User, query string) {
        ctx := context.Background()
        if user != nil {
            ctx = context.WithValue(ctx, "currentUser", user) // in the app, this will potentially be set by a middleware based on the authentication header
        }
        response := schema.Exec(ctx, query, "", map[string]any{})
        json, _ := json.Marshal(response)
        fmt.Printf("response: %s\n", json)
    }

    u := &User{courses: map[string]bool{"course2": true}}
    run(u, `query { course(name: "course1") { name enrolled } }`)
    run(u, `query { course(name: "course2") { name enrolled } }`)
    run(u, `query { course(name: "course3") { name enrolled } }`)
    run(nil, `query { course(name: "course4") { name enrolled } }`)
    run(nil, `query { course(name: "course5") { name } }`)
}

This is the output:

response: {"data":{"course":{"name":"course1","enrolled":false}}}
response: {"data":{"course":{"name":"course2","enrolled":true}}}
response: {"data":{"course":{"name":"course3","enrolled":false}}}
response: {"errors":[{"message":"Unauthorized","path":["course","enrolled"]}],"data":{"course":null}}
response: {"data":{"course":{"name":"course5"}}}
pavelnikolov commented 1 year ago

You can change your method signature like this:

func (r *QueryResolver) Course(ctx context.Context, args *struct{ Name string }) *CourseResolver {
        usr := user.FromContext(ctx)
    return &CourseResolver{user: usr, requestedCourse: args.Name}
}

Let me give some more info about this. The QueryResolver in your case is shared between all requests. There is only one instance of QueryResolver in the entire app. This means that if I allow context in that method I might allow people to shoot themselves in the foot and think that this is a separate instance of that struct. In your example the user filed would be shared between all requests and you might get undesired behavior. If you need to access the user information only once, I would suggest:

query {
  user(id: "<user_id_here>") {
    courses {
      id
      name
     }
   }
}

This will return only the courses the user is enrolled in.

JoshCheek commented 1 year ago

The QueryResolver in your case is shared between all requests. There is only one instance of QueryResolver in the entire app.

Not sure if that happens within the lib, but when I instantiate it, I do schema := gql.MustParseSchema(s, &RootResolver{}, opts...). So clearly the RootResolver is shared between all requests, but I was expecting that the QueryResolver here was resolved from the RootResolver on every request.

Perhaps I'm overlooking something, but it doesn't seem like I have a place to setup initial state.

JoshCheek commented 1 year ago

🤔 or like a way to take context from the request and set up initial state from that, and share state between the fields.

eg, say there was a query like:

query {
  c1: course(name: "course1") { name enrolled }
  c2: course(name: "course2") { name enrolled }
  c3: course(name: "course3") { name enrolled }
}

Or something similar to that where I've got different fields doing the same query, leading to something roughly equivalent to an N+1 problem. If they all depend on knowing what courses the user is enrolled in, then I can do that query once and share it between them. But I don't see any way to share that query result.

JoshCheek commented 1 year ago

If you need to access the user information only once, I would suggest:

query {
  user(id: "<user_id_here>") {
    courses {
      id
      name
     }
   }
}

I'm not super familiar with GraphQL, are you saying that the right way to do it would be for the frontend app to make one query for all courses, and then another query for all the user's courses, and then when displaying all the courses, check each one against the user's to figure out if the user is enrolled in it?

Also I don't really understand why the user field here receives an id, wouldn't that allow one user to ask about another user? Or is there a check in the user resolver to make sure the current user is authorized to view this information about another user? (eg seems like it could lead to privacy issues).

pavelnikolov commented 1 year ago

Well, I gave a silly example. The id argument is not needed indeed in my example. If your schema allows a query like this:

query {
  user {
    courses {
      id
      name
    }
  }
}

your front-end will still send one query and get one response back. The backend will resolve the current user in the user field resolver and then will pass that user to all the subfield resolvers. So I would change the example you gave to something like this:

query {
   user {
      courses {
        ...course
     }
  }
  c1: course(name: "course1") { ...course }
  c2: course(name: "course2") { ...course }
  c3: course(name: "course3") { ...course }
}

fragment course on Course {
  id
  name
}

The above would return all the courses the current user is enrolled in. If in addition to that you want to select more courses you can add them to the same query.

pavelnikolov commented 1 year ago

@JoshCheek you can also use the data loader to load the user and their courses only once https://github.com/graph-gophers/dataloader

pavelnikolov commented 1 year ago

Here are some examples of the dataloader in action https://github.com/graph-gophers/dataloader/tree/v7/example