Skip to content

Commit ea907c6

Browse files
feat(response-cache): support @cacheControl(maxAge: Int) (#1603)
Co-authored-by: Valentin Cocaud <[email protected]>
1 parent efad8ad commit ea907c6

File tree

4 files changed

+181
-1
lines changed

4 files changed

+181
-1
lines changed

.changeset/nasty-beers-lie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@envelop/response-cache': minor
3+
---
4+
5+
Support for `directive @cacheControl(maxAge: Int) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION`

packages/plugins/response-cache/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,36 @@ const getEnveloped = envelop({
205205
})
206206
```
207207

208+
It is also possible to define the TTL by using the `@cacheControl` directive in your schema.
209+
210+
```ts
211+
import { execute, parse, subscribe, validate, buildSchema } from 'graphql'
212+
import { envelop } from '@envelop/core'
213+
import { useResponseCache, cacheControlDirective } from '@envelop/response-cache'
214+
215+
const schema = buildSchema(/* GraphQL */ `
216+
${cacheControlDirective}
217+
218+
type Stock @cacheControl(maxAge: 500) {
219+
# ... stock fields ...
220+
}
221+
222+
# ... rest of the schema ...
223+
`)
224+
225+
const getEnveloped = envelop({
226+
parse,
227+
validate,
228+
execute,
229+
subscribe,
230+
plugins: [
231+
useSchema(schema)
232+
// ... other plugins ...
233+
useResponseCache({ ttl: 2000 })
234+
]
235+
})
236+
```
237+
208238
### Cache with custom TTL per schema coordinate
209239

210240
```ts
@@ -230,6 +260,36 @@ const getEnveloped = envelop({
230260
})
231261
```
232262

263+
It is also possible to define the TTL by using the `@cacheControl` directive in your schema.
264+
265+
```ts
266+
import { buildSchema, execute, parse, subscribe, validate } from 'graphql'
267+
import { envelop } from '@envelop/core'
268+
import { cacheControlDirective, useResponseCache } from '@envelop/response-cache'
269+
270+
const schema = buildSchema(/* GraphQL */ `
271+
${cacheControlDirective}
272+
273+
type Query {
274+
rocketCoordinates: Coordinates @cacheControl(maxAge: 100)
275+
}
276+
277+
# ... rest of the schema ...
278+
`)
279+
280+
const getEnveloped = envelop({
281+
parse,
282+
validate,
283+
execute,
284+
subscribe,
285+
plugins: [
286+
useSchema(schema)
287+
// ... other plugins ...
288+
useResponseCache({ ttl: 2000 })
289+
]
290+
})
291+
```
292+
233293
### Disable cache based on session/user
234294

235295
```ts

packages/plugins/response-cache/src/plugin.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
ObjMap,
1919
Plugin,
2020
} from '@envelop/core';
21-
import { memoize1, visitResult } from '@graphql-tools/utils';
21+
import { getDirective, MapperKind, mapSchema, memoize1, visitResult } from '@graphql-tools/utils';
2222
import type { Cache, CacheEntityRecord } from './cache.js';
2323
import { hashSHA256 } from './hash-sha256.js';
2424
import { createInMemoryCache } from './in-memory-cache.js';
@@ -245,6 +245,7 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
245245
: false,
246246
}: UseResponseCacheParameter<PluginContext>): Plugin<PluginContext> {
247247
const ignoredTypesMap = new Set<string>(ignoredTypes);
248+
const processedSchemas = new WeakSet();
248249

249250
// never cache Introspections
250251
ttlPerSchemaCoordinate = { 'Query.__schema': 0, ...ttlPerSchemaCoordinate };
@@ -257,6 +258,38 @@ export function useResponseCache<PluginContext extends Record<string, any> = {}>
257258
}
258259
};
259260
},
261+
onSchemaChange({ schema }) {
262+
if (processedSchemas.has(schema)) {
263+
return;
264+
}
265+
// Check if the schema has @cacheControl directive
266+
const cacheControlDirective = schema.getDirective('cacheControl');
267+
if (cacheControlDirective) {
268+
mapSchema(schema, {
269+
[MapperKind.COMPOSITE_TYPE]: type => {
270+
const cacheControlAnnotations = getDirective(schema, type, 'cacheControl');
271+
cacheControlAnnotations?.forEach(cacheControl => {
272+
const ttl = cacheControl.maxAge * 1000;
273+
if (ttl != null) {
274+
ttlPerType[type.name] = ttl;
275+
}
276+
});
277+
return type;
278+
},
279+
[MapperKind.FIELD]: (fieldConfig, fieldName, typeName) => {
280+
const cacheControlAnnotations = getDirective(schema, fieldConfig, 'cacheControl');
281+
cacheControlAnnotations?.forEach(cacheControl => {
282+
const ttl = cacheControl.maxAge * 1000;
283+
if (ttl != null) {
284+
ttlPerSchemaCoordinate[`${typeName}.${fieldName}`] = ttl;
285+
}
286+
});
287+
return fieldConfig;
288+
},
289+
});
290+
}
291+
processedSchemas.add(schema);
292+
},
260293
async onExecute(onExecuteParams) {
261294
const identifier = new Map<string, CacheEntityRecord>();
262295
const types = new Set<string>();
@@ -487,3 +520,7 @@ function calculateTtl(typeTtl: number, currentTtl: number | undefined): number {
487520
}
488521
return typeTtl;
489522
}
523+
524+
export const cacheControlDirective = /* GraphQL */ `
525+
directive @cacheControl(maxAge: Int) on FIELD_DEFINITION | OBJECT
526+
`;

packages/plugins/response-cache/test/response-cache.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2099,6 +2099,84 @@ describe('useResponseCache', () => {
20992099
});
21002100
});
21012101
});
2102+
it('supports @cacheControl directive with maxAge on a field', async () => {
2103+
const schema = makeExecutableSchema({
2104+
typeDefs: /* GraphQL */ `
2105+
directive @cacheControl(maxAge: Int) on FIELD_DEFINITION
2106+
type Query {
2107+
foo: String @cacheControl(maxAge: 10)
2108+
}
2109+
`,
2110+
resolvers: {
2111+
Query: {
2112+
foo: () => 'bar',
2113+
},
2114+
},
2115+
});
2116+
const testkit = createTestkit(
2117+
[
2118+
useResponseCache({
2119+
session: () => null,
2120+
includeExtensionMetadata: true,
2121+
}),
2122+
],
2123+
schema,
2124+
);
2125+
const operation = /* GraphQL */ `
2126+
{
2127+
foo
2128+
}
2129+
`;
2130+
const result = await testkit.execute(operation);
2131+
assertSingleExecutionValue(result);
2132+
expect(result.extensions?.['responseCache']).toEqual({
2133+
didCache: true,
2134+
hit: false,
2135+
ttl: 10000,
2136+
});
2137+
});
2138+
2139+
it('supports @cacheControl directive with maxAge on a type', async () => {
2140+
const schema = makeExecutableSchema({
2141+
typeDefs: /* GraphQL */ `
2142+
directive @cacheControl(maxAge: Int) on OBJECT
2143+
type Query {
2144+
foo: Foo
2145+
}
2146+
type Foo @cacheControl(maxAge: 10) {
2147+
id: String
2148+
}
2149+
`,
2150+
resolvers: {
2151+
Query: {
2152+
foo: () => ({ id: 'baz' }),
2153+
},
2154+
},
2155+
});
2156+
const testkit = createTestkit(
2157+
[
2158+
useResponseCache({
2159+
session: () => null,
2160+
includeExtensionMetadata: true,
2161+
}),
2162+
],
2163+
schema,
2164+
);
2165+
const operation = /* GraphQL */ `
2166+
{
2167+
foo {
2168+
id
2169+
}
2170+
}
2171+
`;
2172+
const result = await testkit.execute(operation);
2173+
assertSingleExecutionValue(result);
2174+
expect(result.extensions?.['responseCache']).toEqual({
2175+
didCache: true,
2176+
hit: false,
2177+
ttl: 10000,
2178+
});
2179+
});
21022180

21032181
describe('ignoring and ttl per type for types without id field', () => {
21042182
let spyWithId: jest.Mock<{ id: Number; field: String }, []>;

0 commit comments

Comments
 (0)