dotansimha / graphql-yoga

🧘 Rewrite of a fully-featured GraphQL Server with focus on easy setup, performance & great developer experience. The core of Yoga implements WHATWG Fetch API and can run/deploy on any JS environment.
https://the-guild.dev/graphql/yoga-server
MIT License
8.13k stars 561 forks source link

[useResponseCache] Provide server context to define session Id #3282

Open santino opened 1 month ago

santino commented 1 month ago

Hello folks, I am struggling to properly implement session-based caching for useResponseCache Yoga plugin.

I don't have any usable header with a fully formed user id passed within the client request. Instead, I have cookies which are handled by my Fastify server instance through an onRequest hook. Ultimately if a valid user session can be derived from the cookies, a user/session id will be decorated within the request object of my server.

Here comes the problem with useResponseCache. The session function only gets passed the yoga request object which is a PonyfillRequest instance. Since this does not receive the req object from my server, I have no chance to read my decorated object containing information about the user session. This is effectively a blocker that makes this plugin unusable for me.

To work around this issue I had to create a custom useResponseCache plugin in order to provide the serverContext to the session function. In this case, I am duplicating the Yoga plugin and I will need to keep it up to date. Maybe you can consider passing a more complete request object to the session function or more arguments to include the server context in its entirety.

I am pasting the code I used for my custom implementation of the plugin. I literally added 3 lines of code to just pass the serverContext between onRequest and onParams so it can be provided to the sessions function.

export function useResponseCache (options) {
  const buildResponseCacheKey =
    options?.buildResponseCacheKey || defaultBuildResponseCacheKey
  const cache = options.cache ?? createInMemoryCache()
  const enabled = options.enabled ?? (() => true)
  let logger
  let servContext

  return {
    onYogaInit ({ yoga }) {
      logger = yoga.logger
    },
    onPluginInit ({ addPlugin }) {
      addPlugin(
        useEnvelopResponseCache({
          ...options,
          enabled ({ request }) {
            return enabled(request)
          },
          cache,
          getDocumentString: getDocumentStringForEnvelop,
          session: sessionFactoryForEnvelop,
          buildResponseCacheKey: cacheKeyFactoryForEnvelop,
          shouldCacheResult ({ cacheKey, result }) {
            let shouldCache
            if (options.shouldCacheResult) {
              shouldCache = options.shouldCacheResult({ cacheKey, result })
            } else {
              shouldCache = !result.errors?.length
              if (!shouldCache) {
                logger.debug(
                  '[useResponseCache] Decided not to cache the response because it contains errors'
                )
              }
            }
            if (shouldCache) {
              const extensions = (result.extensions ||= {})
              const httpExtensions = (extensions.http ||= {})
              const headers = (httpExtensions.headers ||= {})
              headers.ETag = cacheKey
              headers['Last-Modified'] = new Date().toUTCString()
            }
            return shouldCache
          }
        })
      )
    },
    async onRequest ({ request, fetchAPI, endResponse, serverContext }) {
      servContext = serverContext
      if (enabled(request)) {
        const operationId = request.headers.get('If-None-Match')
        if (operationId) {
          const cachedResponse = await cache.get(operationId)
          if (cachedResponse) {
            const lastModifiedFromClient = request.headers.get(
              'If-Modified-Since'
            )
            const lastModifiedFromCache =
              cachedResponse.extensions?.http?.headers?.['Last-Modified']
            if (
              // This should be in the extensions already but we check it here to make sure
              lastModifiedFromCache != null &&
              // If the client doesn't send If-Modified-Since header, we assume the cache is valid
              (lastModifiedFromClient == null ||
                new Date(lastModifiedFromClient).getTime() >=
                  new Date(lastModifiedFromCache).getTime())
            ) {
              const okResponse = new fetchAPI.Response(null, {
                status: 304,
                headers: {
                  ETag: operationId
                }
              })
              endResponse(okResponse)
            }
          }
        }
      }
    },
    async onParams ({ params, request, setResult }) {
      const sessionId = await options.session(servContext.req)
      const operationId = await buildResponseCacheKey({
        documentString: params.query || '',
        variableValues: params.variables,
        operationName: params.operationName,
        sessionId,
        request
      })
      operationIdByRequest.set(request, operationId)
      sessionByRequest.set(request, sessionId)
      if (enabled(request)) {
        const cachedResponse = await cache.get(operationId)
        if (cachedResponse) {
          const responseWithSymbol = {
            ...cachedResponse,
            [Symbol.for('servedFromResponseCache')]: true
          }
          if (options.includeExtensionMetadata) {
            setResult(resultWithMetadata(responseWithSymbol, { hit: true }))
          } else {
            setResult(responseWithSymbol)
          }
        }
      }
    }
  }
}
ardatan commented 1 month ago

We can change the sessionId factory to pass the server context as the second parameter but I am curious what do you need from the server context specifically?

santino commented 1 month ago

The request object from my server context has a number of decorators that can be useful to define the session. In my specific case, I add an authenticatedUser object containing information retrieved and validated from an authentication cookie.

Within the opaque Yoga request object I just have the parameters passed with the request. So I can see the plain cookie string which also contains my authentication cookie. But then I'd need to split it; extract the authentication cookie, parse the JWT, validate it, and finally extract the userId.

I am doing all this stuff within my Fastify decorator and I don't certainly want to process the cookie string manually and process the authentication cookie for the second time; considering all the data I need is available in the server context.

santino commented 1 month ago

Hello folks, what's your plan on #3285 can I help with anything to get the PR merged?