Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(response-cache): use ETag to use HTTP caching strategy #2252

Merged
merged 17 commits into from
Feb 17, 2023
7 changes: 7 additions & 0 deletions .changeset/tame-frogs-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@graphql-yoga/plugin-response-cache': minor
---

Use ETag for HTTP Caching to improve the performance on the client side, also reduce the load on the server.
ardatan marked this conversation as resolved.
Show resolved Hide resolved

[See here to learn how it works](https://the-guild.dev/graphql/yoga-server/docs/features/response-caching)
ardatan marked this conversation as resolved.
Show resolved Hide resolved
27 changes: 27 additions & 0 deletions examples/response-cache/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "example-response-cache",
"version": "0.1.11",
"private": true,
"description": "",
"scripts": {
"dev": "cross-env NODE_ENV=development ts-node-dev --exit-child --respawn src/main.ts",
"start": "ts-node src/main.ts",
"check": "tsc --pretty --noEmit"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/node": "18.11.11",
"cross-env": "7.0.3",
"ts-node": "10.9.1",
"ts-node-dev": "2.0.0",
"typescript": "4.9.4"
},
"dependencies": {
"graphql-yoga": "3.1.2",
"@graphql-yoga/plugin-response-cache": "1.1.2",
"graphql": "16.6.0"
},
"module": "commonjs"
}
42 changes: 42 additions & 0 deletions examples/response-cache/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { createServer } from 'http'
import { createYoga, createSchema } from 'graphql-yoga'
import { useResponseCache } from '@graphql-yoga/plugin-response-cache'

const schema = createSchema({
typeDefs: `
ardatan marked this conversation as resolved.
Show resolved Hide resolved
type Query {
me: User
}
type User {
id: ID!
name: String!
}
`,
resolvers: {
Query: {
me: () => {
console.count('Query.me')
return {
id: '1',
name: 'Bob',
}
},
},
},
})

const yoga = createYoga({
schema,
plugins: [
useResponseCache({
session: () => null,
includeExtensionMetadata: true,
}),
],
})

const server = createServer(yoga)

server.listen(4000, () => {
console.log('Server is running on http://localhost:4000')
})
13 changes: 13 additions & 0 deletions examples/response-cache/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"outDir": "./dist",
"module": "commonjs",
"target": "esnext",
"lib": ["esnext"],
"moduleResolution": "node",
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
97 changes: 97 additions & 0 deletions packages/plugins/response-cache/__tests__/etag.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createYoga, createSchema } from 'graphql-yoga'
import { useResponseCache } from '@graphql-yoga/plugin-response-cache'
import { GraphQLError } from 'graphql'

describe('Response Caching via ETag', () => {
let resolverCalledCount = 0
afterEach(() => {
resolverCalledCount = 0
})
const yoga = createYoga({
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query {
me(throwError: Boolean): User
}
type User {
id: ID!
name: String!
}
`,
resolvers: {
Query: {
me: (_, { throwError }) => {
if (throwError) throw new GraphQLError('Error')
resolverCalledCount++
return {
id: '1',
name: 'Bob',
}
},
},
},
}),
logging: false,
plugins: [
useResponseCache({
session: () => null,
buildResponseCacheKey: async ({ documentString }) => documentString,
}),
],
})
it('should return an ETag header with the cache key', async () => {
const query = '{me{id,name}}'
const response = await yoga.fetch(
'http://localhost:4000/graphql?query=' + query,
)
expect(response.headers.get('ETag')).toEqual(query)
const lastModified = response.headers.get('Last-Modified')
expect(lastModified).toBeTruthy()
const lastModifiedDate = new Date(
lastModified || 'Expected Last-Modified to be a valid date',
)
expect(lastModifiedDate).toBeInstanceOf(Date)
expect(lastModifiedDate.toString()).not.toEqual('Invalid Date')
expect(lastModifiedDate.getDate()).toEqual(new Date().getDate())
})
it('should respond 304 when the ETag and Last-Modified matches', async () => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
const query = '{me{id,name}}'
const response = await yoga.fetch(
'http://localhost:4000/graphql?query=' + query,
{
headers: {
'If-None-Match': query,
'If-Modified-Since': tomorrow.toString(),
},
},
)
expect(response.status).toEqual(304)
expect(resolverCalledCount).toEqual(0)
})
it('should not send ETag or Last-Modified if the result is not cached', async () => {
const response = await yoga.fetch(
'http://localhost:4000/graphql?query={me(throwError:true){id,name}}',
)
expect(response.headers.get('ETag')).toBeFalsy()
expect(response.headers.get('Last-Modified')).toBeFalsy()
})
it('should not response 304 if ETag matches but Last-Modified does not', async () => {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const query = '{me{id,name}}'
const response = await yoga.fetch(
'http://localhost:4000/graphql?query=' + query,
{
headers: {
'If-None-Match': query,
'If-Modified-Since': yesterday.toString(),
},
},
)
expect(response.status).toEqual(200)
// It should still hit the cache
expect(resolverCalledCount).toEqual(0)
})
})
70 changes: 66 additions & 4 deletions packages/plugins/response-cache/__tests__/response-cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,68 @@ const schema = createSchema({
_: String
}
`,
resolvers: {
Query: {
_: () => 'DUMMY',
},
},
})

it('should not hit GraphQL pipeline if cached', async () => {
const onEnveloped = jest.fn()
const yoga = createYoga({
schema,
plugins: [
useResponseCache({
session: () => null,
includeExtensionMetadata: true,
}),
{
onEnveloped,
},
],
})
const response = await yoga.fetch('http://localhost:3000/graphql', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ query: '{ _ }' }),
})

expect(response.status).toEqual(200)
const body = await response.json()
expect(body).toEqual({
data: {
_: 'DUMMY',
},
extensions: {
responseCache: {
didCache: true,
hit: false,
ttl: null,
},
},
})
const response2 = await yoga.fetch('http://localhost:3000/graphql', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ query: '{ _ }' }),
})
const body2 = await response2.json()
expect(body2).toMatchObject({
data: {
_: 'DUMMY',
},
extensions: {
responseCache: {
hit: true,
},
},
})
expect(onEnveloped).toHaveBeenCalledTimes(1)
})

it('cache a query operation', async () => {
Expand Down Expand Up @@ -49,7 +111,7 @@ it('cache a query operation', async () => {
response = await fetch()
expect(response.status).toEqual(200)
body = await response.json()
expect(body).toEqual({
expect(body).toMatchObject({
data: {
__typename: 'Query',
},
Expand Down Expand Up @@ -86,7 +148,7 @@ it('cache a query operation per session', async () => {

expect(response.status).toEqual(200)
let body = await response.json()
expect(body).toEqual({
expect(body).toMatchObject({
data: {
__typename: 'Query',
},
Expand All @@ -102,7 +164,7 @@ it('cache a query operation per session', async () => {
response = await fetch('1')
expect(response.status).toEqual(200)
body = await response.json()
expect(body).toEqual({
expect(body).toMatchObject({
data: {
__typename: 'Query',
},
Expand All @@ -117,7 +179,7 @@ it('cache a query operation per session', async () => {

expect(response.status).toEqual(200)
body = await response.json()
expect(body).toEqual({
expect(body).toMatchObject({
data: {
__typename: 'Query',
},
Expand Down
Loading