Integration of a Drupal GraphQL API with nuxt-multi-cache
I'm working on a project using nuxt-graphql-middleware with a Drupal GraphQL API. I'm currently thinking about how I could integrate nuxt-multi-cache. This could be solved on a quite generic way.
Those are my requirements:
- Handle Drupal cache tags
- Abstract the caching logic (in a new composable?)
- Support CDNs (Fastly in my case)
Here are some thoughts:
- Does anyone have a working setup?
- Probably this should be implemented using
useDataCache. The alternative isuseRouteCache, but that looks rather hacky and I don't see how we could implement cache tags there. - This could be implemented in doGraphqlRequest
- The cache keys need to be built automatically, so we need a way to stringify the query name and variables.
This could also live in it's own project or added to nuxt-graphql-middleware, whatever makes most sense.
@dulnan What do you think about this?
So there‘s quite a few approaches here and they all depend on what exactly needs to be cached.
If you only want to cache the response of the GraphQL middleware part publicly on Fastly you would have to use the "CDN" feature of nuxt-multi-cache. In this case you could define a custom "onServerResponse" method in ~/app/graphqlMiddleware.serverOptions.ts where you would extract the cacheability headers from Drupal and pass them to useCDNHeaders(). This will of course only cache it on the CDN, so SSR requests will still always hit Drupal.
To also have some parts of your SSR benefit from caching you could use the "data cache" feature. This works best for rarely changing requests. Let‘s say you want to cache the menu/footer that way. You‘d then have a useAsyncData() somewhere in a component in which you use the useDataCache() composable to load it from cache and if available, return it. If not, it is followed by a useGraphqlQuery() whose result you put into cache. You can leverage the onServerResponse config option from before to "pass along" the cacheability data (e.g. cache tags). The way I did it in the past is to just add a "__cacheTags" property to the response body, which I can then acces in the result of useGraphqlQuery(). This approach is arguably the easiest one and it‘s also very clear what‘s happening, except maybe the "magic" how cache tags end up in there). It‘s also the one that should give the most performance gains during SSR, because the data cache (especially when using the memory backend) has minimal overhead (compared to caching with useRouteCache which is indeed quite hacky due to Nuxt/Nitro limitations).
Now, in regards to making it generic and work for all GraphQL requests across the entire app, what you suggested with using doGraphqlRequest would indeed be the cleanest way because everything related to "caching GraphQL responses" would reside in a single place. You‘d again use useDataCache() in there, derive a cache key from the operation name + variables, as you suggested.
If you also want to cache SSR-rendered pages on Fastly, then again this should be quite straightforward: Assuming you have implemented a onServerResponse method and you pass cache tags "magically" via the __cacheTags property in the middleware response, you can simply add an interceptor via a Nuxt plugin that is executed everytime the Nuxt app get‘s a response from /api/graphql: https://nuxt-graphql-middleware.dulnan.net/configuration/composable.html#example-alter-the-request-using-interceptors
Intercepting via onResponse() you‘d extract the cache tags from the response and add them as cache tags via useCDNHeaders(). That way, as long as you always fetch your data via GraphQL, your SSR rendered page response will contain the accumulated cache tags of all GraphQL queries in the Surrogate-Key header.
@dulnan thank you so much for your quick response.
As for the CDN caching of the middleware requests, that was quite easy without even using nuxt-multi-cache. The code isn't tested yet, but that should do the job:
import { defineGraphqlServerOptions } from 'nuxt-graphql-middleware/dist/runtime/serverOptions'
export default defineGraphqlServerOptions({
/**
* Alter response from GraphQL server.
*/
onServerResponse (event, graphqlResponse) {
// Set cache headers
event.node.res.setHeader(
'Cache-Control',
'max-age=900, public, s-maxage=2592000, stale-if-error=3600, stale-while-revalidate=3600'
)
event.node.res.setHeader('Surrogate-Control', 'max-age=900, public')
const surrogateKey = graphqlResponse.headers.get('Surrogate-Key')
if (surrogateKey) {
event.node.res.setHeader('Surrogate-Key', surrogateKey)
}
return graphqlResponse._data
}
})
I'm going to refactor that to use useCDNHeaders() later.
Regarding the generic solution I think that I'm going to try the useDataCache() inside doGraphqlRequest. Let's see how that goes.
But I'm having some trouble to understand the docs of doGraphqlRequest. It would be very useful to have the default implementation as an example. The one in the docs does something very different: https://nuxt-graphql-middleware.dulnan.net/configuration/server-options.html#dographqlrequest
Ah, the interceptor approach sounds interesting. I'll try that once I solved the generic doGraphqlRequest implementation.
In case you have some code to share, that would be very helpful!
I found a hacky workaround to get the custom implementation of doGraphqlRequest running. These functions aren't exported by the package, but they should be to get the needed functions to do what I just did: getFetchOptions, onServerError, onServerResponse, getEndpoint
Here is the default implementation. This should probably go in the docs:
import { defineGraphqlServerOptions } from 'nuxt-graphql-middleware/dist/runtime/serverOptions'
import { GraphqlMiddlewareOperation } from 'nuxt-graphql-middleware/dist/runtime/settings'
import type { GraphqlMiddlewareRuntimeConfig } from 'nuxt-graphql-middleware/dist/runtime/types'
// Mocking the missing functions from nuxt-graphql-middleware
import { getFetchOptions, onServerError, onServerResponse, getEndpoint } from '../utils/middlewareFunctions'
import serverOptions from '#graphql-middleware-server-options-build'
export default defineGraphqlServerOptions({
async doGraphqlRequest ({
event,
operationName,
operationDocument,
variables
}) {
// The operation (either "query" or "mutation").
const operation = event.context?.params
?.operation as GraphqlMiddlewareOperation
// Determine the endpoint of the GraphQL server.
// Get the fetch options for this request.
const fetchOptions = await getFetchOptions(
serverOptions,
event,
operation,
operationName
)
// Get the runtime config.
const runtimeConfig = useRuntimeConfig()
.graphqlMiddleware as GraphqlMiddlewareRuntimeConfig
// Determine the endpoint of the GraphQL server.
const endpoint = await getEndpoint(
runtimeConfig,
serverOptions,
event,
operation,
operationName
)
return $fetch
.raw(endpoint, {
// @todo: Remove any once https://github.com/unjs/nitro/pull/883 is released.
method: 'POST' as any,
body: {
query: operationDocument,
variables,
operationName
},
...fetchOptions
})
.then((response) => {
return onServerResponse(
serverOptions,
event,
response,
operation,
operationName
)
})
.catch((error) => {
return onServerError(
serverOptions,
event,
error,
operation,
operationName
)
})
}
})
Ok, so here is a first working version. Getting insanely fast response times now 🥳
import { defineGraphqlServerOptions } from 'nuxt-graphql-middleware/dist/runtime/serverOptions'
import { GraphqlMiddlewareOperation } from 'nuxt-graphql-middleware/dist/runtime/settings'
import type { GraphqlMiddlewareRuntimeConfig } from 'nuxt-graphql-middleware/dist/runtime/types'
// @todo: Mocking the missing functions from nuxt-graphql-middleware
import { getFetchOptions, onServerError, onServerResponse, getEndpoint } from '../utils/middlewareFunctions'
import serverOptions from '#graphql-middleware-server-options-build'
import { useDataCache } from '#nuxt-multi-cache/composables'
export default defineGraphqlServerOptions({
async doGraphqlRequest ({
event,
operationName,
operationDocument,
variables
}) {
// The operation (either "query" or "mutation").
const operation = event.context?.params
?.operation as GraphqlMiddlewareOperation
// Determine the endpoint of the GraphQL server.
// Get the fetch options for this request.
const fetchOptions = await getFetchOptions(
serverOptions,
event,
operation,
operationName
)
// Get the runtime config.
const runtimeConfig = useRuntimeConfig()
.graphqlMiddleware as GraphqlMiddlewareRuntimeConfig
// Determine the endpoint of the GraphQL server.
const endpoint = await getEndpoint(
runtimeConfig,
serverOptions,
event,
operation,
operationName
)
const cacheKey = `${operationName}|${JSON.stringify(variables)}`
const { value, addToCache } = await useDataCache(cacheKey, event)
// If the data is already in cache, return it
if (value) {
return value
}
// The data isn't available in cache. We have to fetch it.
const response = await $fetch
.raw(endpoint, {
// @todo: Remove any once https://github.com/unjs/nitro/pull/883 is released.
method: 'POST' as any,
body: {
query: operationDocument,
variables,
operationName
},
...fetchOptions
})
.then((response) => {
return onServerResponse(
serverOptions,
event,
response,
operation,
operationName
)
})
.catch((error) => {
return onServerError(
serverOptions,
event,
error,
operation,
operationName
)
})
// Save data to the cache and return it.
addToCache(response)
return response
}
})
Ok, adding the cache tags to the response of the middleware is more difficult than expected... The approach described in https://github.com/dulnan/nuxt-graphql-middleware/issues/25#issuecomment-1968808769 doesn't work, since onServerResponse() is only being called on the first uncached request. The function isn't being called in the subsequent cached requests. Reading your last comment again, you didn't elaborate on that issue. Do you have suggestions regarding that?
Additionally I don't think that calling useCDNHeaders() has any effect in neither doGraphqlRequest(), onServerResponse nor in the plugin as you suggested. Am I doing something wrong? Putting the same code in a page or a component works fine...
plugins/graphqlConfig.ts
export default defineNuxtPlugin((NuxtApp) => {
const state = useGraphqlState()
if (!state) {
return
}
state.fetchOptions = {
onResponse ({ request, response, options }) {
if (response._data.__cacheTags) {
const cacheTags = response._data.__cacheTags.split(' ')
useCDNHeaders((helper) => {
helper
.public()
.setNumeric('maxAge', 3600)
.set('staleIfError', 24000)
.set('staleWhileRevalidate', 60000)
.set('mustRevalidate', true)
.addTags(cacheTags)
})
}
}
}
})
app/graphqlMiddleware.serverOptions.ts
import { defineGraphqlServerOptions } from 'nuxt-graphql-middleware/dist/runtime/serverOptions'
import { GraphqlMiddlewareOperation } from 'nuxt-graphql-middleware/dist/runtime/settings'
import type { GraphqlMiddlewareRuntimeConfig } from 'nuxt-graphql-middleware/dist/runtime/types'
// @todo: Mocking the missing functions from nuxt-graphql-middleware
import { getFetchOptions, onServerError, onServerResponse, getEndpoint } from '../utils/middlewareFunctions'
import serverOptions from '#graphql-middleware-server-options-build'
import { useDataCache, useCDNHeaders } from '#nuxt-multi-cache/composables'
export default defineGraphqlServerOptions({
/**
* Alter response from GraphQL server.
*/
onServerResponse (event, graphqlResponse) {
const surrogateKey = graphqlResponse.headers.get('Surrogate-Key')
useCDNHeaders((helper) => {
helper
.public()
.setNumeric('maxAge', 3600)
.set('staleIfError', 24000)
.set('staleWhileRevalidate', 60000)
.set('mustRevalidate', true)
.addTags(surrogateKey)
})
return { ...graphqlResponse._data, __cacheTags: surrogateKey }
},
/**
* Cache requests in nuxt-multi-cache
*/
async doGraphqlRequest ({
event,
operationName,
operationDocument,
variables
}) {
// The operation (either "query" or "mutation").
const operation = event.context?.params
?.operation as GraphqlMiddlewareOperation
// Determine the endpoint of the GraphQL server.
// Get the fetch options for this request.
const fetchOptions = await getFetchOptions(
serverOptions,
event,
operation,
operationName
)
// Get the runtime config.
const runtimeConfig = useRuntimeConfig()
.graphqlMiddleware as GraphqlMiddlewareRuntimeConfig
// Determine the endpoint of the GraphQL server.
const endpoint = await getEndpoint(
runtimeConfig,
serverOptions,
event,
operation,
operationName
)
const cacheKey = `${operationName}|${JSON.stringify(variables)}`
const { value, addToCache } = await useDataCache(cacheKey, event)
// If the data is already in cache, return it
if (value) {
useCDNHeaders((helper) => {
helper
.public()
.setNumeric('maxAge', 3600)
.set('staleIfError', 24000)
.set('staleWhileRevalidate', 60000)
.set('mustRevalidate', true)
.addTags(value.__cacheTags)
})
return value
}
// The data isn't available in cache. We have to fetch it.
const response = await $fetch
.raw(endpoint, {
// @todo: Remove any once https://github.com/unjs/nitro/pull/883 is released.
method: 'POST' as any,
body: {
query: operationDocument,
variables,
operationName
},
...fetchOptions
})
.then((response) => {
return onServerResponse(
serverOptions,
event,
response,
operation,
operationName
)
})
.catch((error) => {
return onServerError(
serverOptions,
event,
error,
operation,
operationName
)
})
// Save data to the cache and return it.
addToCache(response)
useCDNHeaders((helper) => {
helper
.public()
.setNumeric('maxAge', 3600)
.set('staleIfError', 24000)
.set('staleWhileRevalidate', 60000)
.set('mustRevalidate', true)
.addTags(response.__cacheTags)
})
return response
}
})
Ok, you can skip my last few comments. I have a working version of all the functionality. I'm trying to figure out how I could share this in a useful way. Currently I'm setting up a gist.
There you go: https://gist.github.com/luksak/def035c8ed37892d967a05186efa180a
Sorry it took a bit longer, but thanks a lot for your examples! I have added a first (simplified) example on how to do simple server-side caching using doGraphqlRequest. I plan to add a section to the docs specifically about integration with nuxt-multi-cache using your example.
In the upcoming release there will be some changes which you might like:
You can now provide a generic when defining the server options to better type custom properties on the response when passing around things like the cacheability:
type GraphqlResponseWithCustomProperty = GraphqlResponse<any> & {
__customProperty?: string[]
}
export default defineGraphqlServerOptions<GraphqlResponseWithCustomProperty>({
onServerResponse(event, graphqlResponse) {
// Set a static header.
event.node.res.setHeader('x-nuxt-custom-header', 'A custom header value')
// Pass the set-cookie header from the GraphQL server to the client.
const setCookie = graphqlResponse.headers.get('set-cookie')
if (setCookie) {
event.node.res.setHeader('set-cookie', setCookie)
}
// Return the GraphQL response as is.
return {
data: graphqlResponse._data?.data || null,
errors: graphqlResponse._data?.errors || [],
__customProperty: ['one', 'two'],
}
},
})
I'd love to be able to reuse that type further so it would also apply for the entire code base, e.g. in useGraphqlQuery or when setting fetchOptions in useGraphqlState. Currently exploring the possibilities.
There is also a new composable useAsyncGraphqlQuery that is basically a simple wrapper around useAsyncData to do a single query. It also implements a new opt-in client-side cache, ideal when fetching data that does not need to be refetched for the duration of a browser session.