graphql-modules icon indicating copy to clipboard operation
graphql-modules copied to clipboard

Can't access context / injector inside schema directives

Open fridaystreet opened this issue 11 months ago • 3 comments
trafficstars

Describe the bug

Seems to be related to this issue https://github.com/Urigo/graphql-modules/issues/2460

If I setup a module application running through yoga the context in the schema directives doesn't contain the injector. Various hacks to fix this then cause any additonal data added to the context from the directive to not be populated into all the providers. (See the issue linked above).

While the workaround outlined in the above issue used to work, it no longer appears to work with latest version of graphql-modules and in fact just breaks the providers that are trying to use the injector.

The workaround above found that the context was being pulled from cache, so in the event that the context was being updated after a module had it's context, it would not be available in that modules providers. The workaround attempted to just repopulate every modules context with updates.

The current workaround is to force the injector into the context using the context: () =>.. function of the createYoga function. And use the controlled lifecycle to destroy after request. (not entirely sure this is the right approach, but only way could get the injector into the directive context)

const getContext = async (context) => {
  const controller = App.createOperationController({
    context: {
      ...context,
    },
    autoDestroy: false,
  })

  return {
     injector: controller.injector,
     destroy: controller.destroy,
      ...context
   }
}

const YogaApp = createYoga({
  plugins: [
    useGraphQLModules(App),
    {
      async onResponse({ serverContext }: any) {
        try {
          serverContext.destroy()
        } catch(e) {
          console.log('cleanup App controller failed', e)
        }
      }
    }
  ],
  context: getContext
 })

The probalem here is that the same problem as mentioned in the other issue is then present. Not all the modules/providers context get the updated context (for some currently unknown reason).

Now in the context of the directive there is a Symbol(GRAPHQL_MODULES) key which does in deed contain an injector property. So I thought, maybe this is just a dependancy injection problem and I need to reference this in the directive function properties like so

const auth = async function (source, args, context: GraphQLModules.GlobalContext, info) {

But that doesn't do anything either, so I tried the follwoing, before realising that the 'injector' in this Symbol referenced part of the context is not an injector but a reference to the modules App. See screenshots of the context object passed to the directive.

Screenshot 2024-11-29 at 7 54 32 am Screenshot 2024-11-29 at 9 32 24 am

Here is the directive, trying to pull the injector out of there. This works but feels very hacky.

const fieldMapper = (schema, name, mapperKind) => (fieldConfig, _fieldName, typeName) => {

  const IsAuthenticatedDirective =
    getDirective(schema, fieldConfig, name)?.[0] ??
    typeDirectiveArgumentMaps[typeName]

  if (IsAuthenticatedDirective) {
    const { resolve = defaultFieldResolver, subscribe = defaultFieldResolver} = fieldConfig

    const next = mapperKind === 'SUBSCRIPTION_ROOT_FIELD' ? subscribe : resolve
    const auth = async function (source, args, context, info) {

      const contextSymbol = Object.getOwnPropertySymbols(context).find(
        (s) => s.toString() === Symbol.for("GRAPHQL_MODULES").toString()
      )

      if (!contextSymbol) {
        throw new Error('Context Symbol is not available')
      }

      try {
        const result = await context[contextSymbol].injector.get(JWT).processAuthorizationHeader(context)
        context.user = result?.user
        context[contextSymbol].context.user = result?.user
      } catch(e) {
        throw e;
      }

      if (!context.user) {
        throw new AuthenticationError('You are not authenticated!')
      }

      return next(source, args, context, info)
    }

    fieldConfig.resolve = fieldConfig.resolve ? auth : fieldConfig.resolve;
    fieldConfig.subscribe = fieldConfig.subscribe ? auth : fieldConfig.subscribe;
    return fieldConfig
  }
}

Expected behavior I would expect that the injector is available in the directive context, I would also expect that amnything I add to the context in the directive is avilable to all modules/providers for the rest of the request execution

If anyone could give some advice on what we're doing wrong here, more than happy to approach it a different way and would be very grateful

BTW the middlewar context of all the providers seems to contain the proper context. And I'm half expecting someone to say, use the middleware, but I don't think that's ana acceptable solution. Directives should work and I don't want to have to go and manually define somewhere outsiode the schema that a particular query/mutation is auth or not

Environment:

  • OS: Mac graphql": "^16.9.0", "graphql-modules": "^2.4.0", "graphql-yoga": "^5.10.1",
  • NodeJS: 20

fridaystreet avatar Nov 29 '24 01:11 fridaystreet

UPDATE: -

some further testing and attempted to put the user object onto the context inside the symbol property context So doing this in the directive.

context[contextSymbol].context.user = result?.user

This works and now the user property is available in all the providers as it should be. But again this feels pretty hacky and reliant on pulling the symbol from the object. I haven't tested this with providers further down the tree, so not sure if the symbol name would change at all? (UPDATE: this doesn't entirely work, see next comment)

Effectively the final solution for doing this in directives is something along the lines of (for brevity)

const fieldMapper = (schema, name, mapperKind) => (fieldConfig, _fieldName, typeName) => {

  const IsAuthenticatedDirective =
    getDirective(schema, fieldConfig, name)?.[0] ??
    typeDirectiveArgumentMaps[typeName]

  if (IsAuthenticatedDirective) {
    const { resolve = defaultFieldResolver, subscribe = defaultFieldResolver} = fieldConfig

    const next = mapperKind === 'SUBSCRIPTION_ROOT_FIELD' ? subscribe : resolve
    const auth = async function (source, args, context, info) {

      const contextSymbol = Object.getOwnPropertySymbols(context).find(
        (s) => s.toString() === Symbol.for("GRAPHQL_MODULES").toString()
      )

      if (!contextSymbol) {
        throw new Error('Context Symbol is not available')
      }
     const inject = context[contextSymbol].injector
      try {
        const someDataToGoInContext = await injector.get(your provider).yourFunction()


        context.data = someDataToGoInContext
        context[contextSymbol].context.data = someDataToGoInContext
      } catch(e) {
        throw e;
      }

      return next(source, args, context, info)
    }

    fieldConfig.resolve = fieldConfig.resolve ? auth : fieldConfig.resolve;
    fieldConfig.subscribe = fieldConfig.subscribe ? auth : fieldConfig.subscribe;
    return fieldConfig
  }
}

This seems to be the only way we can find to use the injector in a directive and push data into the context for all providers

fridaystreet avatar Nov 29 '24 02:11 fridaystreet

nope not quite there it seems.

The 'auth' module which is home to the isAuthenticated directive and other things related to auth, none of it's providers context are updated with this context update in the directive.

So this is a query inside the auth module schema, it runs isAuthenticated (@auth) and then isAuthorised directives

//schema
type Query {
      getAuthGraphForContext(context: ItemContextInput!): AuthGraph! @isAuthorised(contextKey: "context") @auth
 }
  
 //resolver.ts   
export default {

  Query: {
    getAuthGraphForContext: async (root, args, context, info) => {
      //added these in to compare running context.ɵgetModuleContext
      
      const taskModuleContext = Object.values(context)[6]('task', context)
      const authModuleContext = Object.values(context)[6]('auth', context)
    
      return context.injector.get(AuthorisationProvider).getAuthGraphForContext({...args, fields: getRequestedFields(info)})
    }

  }
}

This is the authorisation provider

@Injectable({
  scope: Scope.Operation,
  global: true
})
export class AuthorisationProvider {

  private me: any

  constructor(
    @Inject(CONTEXT) private context: GraphQLModules.Context
  ) {
    this.me = context.user
  }
  
  async getAuthGraphForContext({}) {
    return this.context.user.authGraph
  }
}

comparing the contexts of the different modules at this point above. The taskModuleContext includes the 'user' object that was added by the directives, but the auth module context, does not

So back to square one a bit here. Any ideas what the solutuon is or is this indeed a bug?

Many thanks in advance

fridaystreet avatar Nov 29 '24 03:11 fridaystreet

Really feels like something is a miss somewhere.

Going back to modifying the the getContext function in graphql-modules so it updates the cached context. then we get a bit further. The context being passed into the resolver now has the user object in it, but then you call the context.injector in the resolver to load the provider, and the context inside the provider is still the old context with no user.

you can do this inside the provider function called by the resolver and it has the authModuleContext.user. but this.context.user inside the provider does not

const authModuleContext = Object.values(this.context)[6]('auth', this.context)

So the current workaround is to modify the graphql-modules getModuleContext and then override the context in the provider contructor as per below. This only seems to be required for providers in the same module as any directives that push extra data into the context

function getModuleContext(moduleId, ctx) {
      var _a;
      // Reuse a context or create if not available
      if (!contextCache[moduleId]) {
          // We're interested in operation-scoped providers only
          const providers = (_a = modulesMap.get(moduleId)) === null || _a === void 0 ? void 0 : _a.operationProviders;
          // Create module-level Operation-scoped Injector
          const operationModuleInjector = ReflectiveInjector.createFromResolved({
              name: `Module "${moduleId}" (Operation Scope)`,
              providers: providers.concat(ReflectiveInjector.resolve([
                  {
                      provide: CONTEXT,
                      useFactory() {
                      
                         //also tried merging here but this makes no difference either
                          return merge(contextCache[moduleId], ctx)
                      },
                  },
              ])),
              // This injector has a priority
              parent: modulesMap.get(moduleId).injector,
              // over this one
              fallbackParent: operationAppInjector,
          });
          // Same as on application level, we need to collect providers with OnDestroy hooks
          registerProvidersToDestroy(operationModuleInjector);
          contextCache[moduleId] = merge(ctx, {
              injector: operationModuleInjector,
              moduleId,
          });
      }
      
      //merge any context updates into the existing cached context
      contextCache[moduleId] = merge(contextCache[moduleId], ctx)

      return contextCache[moduleId];

Then in the provider need to do

@Injectable({
  scope: Scope.Operation,
  global: true,
})
export class AuthorisationProvider {

  private me: any

  constructor(
    @Inject(CONTEXT) private context: any
  ) {
    const getContext: any = Object.values(context)[6]
    context = getContext(context.moduleId, context)
    this.context = context
  }
  ....

fridaystreet avatar Nov 29 '24 04:11 fridaystreet

Is anyone able to look at this please?

It's still an issue wherby updating the context in a module the updated context doesn't get propergated to the module doing the update.

Scenario. I have an auth module that has a directive, that updates the context with the user deatisl once it resolves the jwt.

In that same module as part of another directive that is run after this first 'auth' directive, it is checking the 'authorisation' of the now authenticated user.

However inside the provider constructor where I am access context, the newly added 'user' key is not on the context.

If I trace ebverything through to the following function

function getModuleContext(moduleId, ctx)

It iterates through the providers I see my auth provider come up several times, the first few times in the loop it doesn't have the user key in the 'ctx' param that is passed to the function, then it does appears in the ctx in the iteration loop, then finally it doesn't and it returns the cached context with no 'user' key When I say appears, I am referring to the function being called with moduleId = 'auth', This happens several times when I call the query that invokes this process.

The issue I see is that this function just causes the provider to always get the initailly cached context and it is never updated, even if updated context comes through for that moduleId. It's never merging the new updated context for this provider

I don't know if this is correct but in order to get it to work this is what I need to do

  1. in that function make it merge the incoming context every time and save it to cache
function getModuleContext(moduleId, ctx) {
            var _a;
            // Reuse a context or create if not available
            if (!contextCache[moduleId]) {
                // We're interested in operation-scoped providers only
                const providers = (_a = modulesMap.get(moduleId)) === null || _a === void 0 ? void 0 : _a.operationProviders;
                //const providers = (_a = modulesMap.get(moduleId)) === null || _a === void 0 ? void 0 : Array.from(modulesMap.values()).flatMap(a => a.operationProviders);
                // Create module-level Operation-scoped Injector
                const operationModuleInjector = ReflectiveInjector.createFromResolved({
                    name: `Module "${moduleId}" (Operation Scope)`,
                    providers: providers.concat(ReflectiveInjector.resolve([
                        {
                            provide: CONTEXT,
                            useFactory() {
                              contextCache[moduleId] = merge(ctx, contextCache[moduleId]);
                              return contextCache[moduleId]
                            },
                        },
                    ])),
                    // This injector has a priority
                    parent: modulesMap.get(moduleId).injector,
                    // over this one
                    fallbackParent: operationAppInjector,
                });
                // Same as on application level, we need to collect providers with OnDestroy hooks
                registerProvidersToDestroy(operationModuleInjector);
                contextCache[moduleId] = merge(ctx, {
                    injector: operationModuleInjector,
                    moduleId,
                });
            }
            contextCache[moduleId] = merge(ctx, contextCache[moduleId]);
            return contextCache[moduleId]
        }

Then in all my providers that are having these issues I have to do this in the constrcutor in order to force it to get the latest context.

constructor(
    @Inject(CONTEXT) private context: any,
  
  ) {
    const getContext: any = Object.values(context).find((i: any) => i.name === 'getModuleContext')
    context = getContext(context.moduleId, context)
    this.context = context
  }

This feels really wrong , but I cannot see any way around it.

If anyone could lend some assistance it would be really appreciated

fridaystreet avatar Sep 10 '25 08:09 fridaystreet