Skip to content

Commit

Permalink
Merge pull request #6 from thuoe/feature/thu-47-new-directive-cache
Browse files Browse the repository at this point in the history
[THU-47]: @cache directive
  • Loading branch information
thuoe authored Feb 21, 2024
2 parents 219f086 + de11ee7 commit d9ab34b
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 1 deletion.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Simple utlity library for custom GraphQL schema directives
- [Directives](#directives)
- [@encode](#encode)
- [@regex](#regex)
- [@cache](#cache)
- [Overriding in-memory cache](#overriding-in-memory-cache)

# Get started

Expand Down Expand Up @@ -65,3 +67,48 @@ const typeDefs = String.raw`
}
`;
```

## @cache

You can use `@cache` directive to take advantage of a in-memory cache for a field value

```graphql
type Book {
name: String
price: String @cache(key: "book_price", ttl: 3000)
}
```

`key` - represents the unique key for field value you wish to cache

`ttl` - time-to-live argument for how long the field value should exist within the cache before expiring (in milliseconds)

### Overriding in-memory cache

If you wish to take leverage something more powerful (for example [Redis](https://redis.io/)), you can override the in-memory solution with your own implementation.

Example:

```typescript
import Redis from 'ioredis'

const redis = new Redis()
....
const cache = {
has: (key: string) => redis.exists(key),
get: (key: string) => redis.get(key),
delete:(key: string) => redis.delete(key),
set: async (key: string, value: string) => {
await redis.set(key, value)
},
}
...
const { cacheDirectiveTypeDefs, cacheDirectiveTransformer } = cacheDirective('cache', cache)
```

You must confirm to this set of function signatures to make this work:

- `has: (key: string) => Promise<boolean>` Checks if a key exists in the cache.
- `get: (key: string) => Promise<string>` Retrieves the value associated with a key from the cache.
- `set: (key: string, value: string) => Promise<void>` Sets a key-value pair in the cache.
- `delete: (key: string) => Promise<boolean>` Deletes a key and its associated value from the cache.
6 changes: 5 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { startStandaloneServer } from '@apollo/server/standalone';
import { makeExecutableSchema } from '@graphql-tools/schema';
import encodingDirective from '@src/directives/encode';
import regexDirective from '@src/directives/regex';
import cacheDirective from '@src/directives/cache';

const typeDefs = String.raw`#graphql
type User {
firstName: String @regex(pattern: "(Eddie|Sam)")
lastName: String @regex(pattern: "\\b[A-Z]\\w+\\b")
age: Int
age: Int @cache(key: "user_age", ttl: 3000)
}
type Query {
Expand All @@ -28,16 +29,19 @@ const resolvers = {

const { encodingDirectiveTypeDefs, encodingDirectiveTransformer } = encodingDirective('encode')
const { regexDirectiveTypeDefs, regexDirectiveTransformer } = regexDirective('regex')
const { cacheDirectiveTypeDefs, cacheDirectiveTransformer } = cacheDirective('cache')

const transformers = [
encodingDirectiveTransformer,
regexDirectiveTransformer,
cacheDirectiveTransformer,
]

let schema = makeExecutableSchema(({
typeDefs: [
encodingDirectiveTypeDefs,
regexDirectiveTypeDefs,
cacheDirectiveTypeDefs,
typeDefs
],
resolvers
Expand Down
69 changes: 69 additions & 0 deletions src/directives/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { MapperKind, getDirective, mapSchema } from '@graphql-tools/utils'
import { GraphQLError, GraphQLSchema, defaultFieldResolver } from 'graphql'

interface CachingImpl {
has: (key: string) => Promise<boolean>
get: (key: string) => Promise<string>
set: (key: string, value: string) => Promise<void>
delete: (key: string) => Promise<boolean>
}

const map = new Map<string, string>()

const inMemoryCache: CachingImpl = {
has: (key: string) => Promise.resolve(map.has(key)),
get: (key: string) => Promise.resolve(map.get(key)),
delete: (key: string) => Promise.resolve(map.delete(key)),
set: async (key: string, value: string) => {
Promise.resolve(map.set(key, value))
},
}

const cacheDirective = (directiveName: string, cache: CachingImpl = inMemoryCache) => {
return {
cacheDirectiveTypeDefs: `directive @${directiveName}(key: String, ttl: Int) on FIELD_DEFINITION`,
cacheDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: fieldConfig => {
const { resolve = defaultFieldResolver } = fieldConfig
const cacheDirective = getDirective(schema, fieldConfig, directiveName)?.[0]
if (cacheDirective) {
const { ttl, key } = cacheDirective
return {
...fieldConfig,
resolve: async (source, args, context, info) => {
const { returnType } = info
const exists = await cache.has(key)
if (exists) {
const value = await cache.get(key)
if (returnType.toString() === 'String') {
return value
}
if (returnType.toString() === 'Boolean') {
const boolValue = (/true/).test(value);
return boolValue
}
if (returnType.toString() === 'Int') {
return Number(value)
}
try {
return JSON.parse(value)
} catch (error) {
throw new GraphQLError(`Error parsing field value: ${returnType.toString()}`)
}
}
const result = await resolve(source, args, context, info)
cache.set(key, JSON.stringify(result))
setTimeout(async () => {
await cache.delete(key)
}, ttl)
return result
}
}
}
}
}
)
}
}

export default cacheDirective
2 changes: 2 additions & 0 deletions src/directives/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import cacheDirective from '@src/directives/cache'
import encodingDirective from '@src/directives/encode'
import regexDirective from '@src/directives/regex'

const directives = Object.freeze({
cacheDirective,
encodingDirective,
regexDirective,
})
Expand Down
116 changes: 116 additions & 0 deletions test/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { ApolloServer } from '@apollo/server';
import cacheDirective from '@src/directives/cache';
import { buildSchema } from './util';
import assert from 'assert';

jest.useFakeTimers();
jest.spyOn(global, 'setTimeout');

const cache = new Map<string, string>()

const cacheCallback = {
has: jest.fn((key: string) => Promise.resolve(cache.has(key))),
get: jest.fn((key: string) => Promise.resolve(cache.get(key))),
delete: jest.fn((key: string) => Promise.resolve(cache.delete(key))),
set: jest.fn(async (key: string, value: string) => {
Promise.resolve(cache.set(key, value))
}),
}

const { cacheDirectiveTypeDefs, cacheDirectiveTransformer } = cacheDirective('cache', cacheCallback)

describe('@cache directive', () => {
let testServer: ApolloServer;

const resolvers = {
Query: {
user: () => ({
age: 28
})
},
};

const testQuery = `
query ExampleQuery {
user {
age
}
}
`

afterEach(async () => {
if (testServer) {
await testServer.stop()
}
})

it('will cache a field value before returning a response with correct ttl', async () => {
const ttl = 8000
const schema = buildSchema({
typeDefs: [
`type User {
age: Int @cache(key: "user_age", ttl: ${ttl})
}
type Query {
user: User
}
`,
cacheDirectiveTypeDefs,
],
resolvers,
transformers: [cacheDirectiveTransformer],
})

testServer = new ApolloServer({ schema })

const response = await testServer.executeOperation<{ user: { age: number } }>({
query: testQuery
})

assert(response.body.kind === 'single');
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data.user.age).toEqual(28);
expect(setTimeout).toHaveBeenCalledTimes(1);
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), ttl);
expect(cacheCallback.set).toHaveBeenCalled()
})

it('will delete a cache field value if the ttl has long expired', async () => {
const ttl = 3000
const key = 'user_age'
const schema = buildSchema({
typeDefs: [
`type User {
age: Int @cache(key: "${key}", ttl: ${ttl})
}
type Query {
user: User
}
`,
cacheDirectiveTypeDefs,
],
resolvers,
transformers: [cacheDirectiveTransformer],
})

testServer = new ApolloServer({ schema })

const response = await testServer.executeOperation<{ user: { age: number } }>({
query: testQuery
})

assert(response.body.kind === 'single');
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data.user.age).toEqual(28);

expect(cacheCallback.set).toHaveBeenCalled()
expect(cacheCallback.set).toHaveBeenCalledWith(key, JSON.stringify(response.body.singleResult.data.user.age))

jest.advanceTimersByTime(ttl + 5000)

expect(cacheCallback.delete).toHaveBeenCalled()
expect(cacheCallback.delete).toHaveBeenCalledWith(key)
})
})

0 comments on commit d9ab34b

Please sign in to comment.