├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.ts ├── package.json ├── src ├── CacheWhole.ts ├── ResolverCache.ts ├── SchemaWithCache.ts ├── SplacheCache.ts ├── __test__ │ ├── CacheWhole.test.ts │ ├── RedisConnection.test.ts │ ├── Resolver.test.ts │ ├── SplacheCache.test.ts │ ├── SplacheCacheHelpers.test.ts │ └── docker-compose-test.yml ├── index-test.ts ├── schemaTest.ts ├── starWarsData.ts └── starWarsDataResolver.ts └── ts.config.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "overrides": [ 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaVersion": "latest" 17 | }, 18 | "plugins": [ 19 | "react", 20 | "@typescript-eslint" 21 | ], 22 | "rules": { 23 | "no-var-requires": "off" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #ignore node_modules 2 | node_modules 3 | #ignore json packages 4 | package-lock.json 5 | #ignore output file 'dist' of complier 6 | dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 |

A Lightweight NPM package for GraphQL Caching with Redis

6 | 7 | 8 | ![GraphQL](https://img.shields.io/badge/-GraphQL-E10098?style=for-the-badge&logo=graphql&logoColor=white) 9 | ![Redis](https://img.shields.io/badge/redis-%23DD0031.svg?style=for-the-badge&logo=redis&logoColor=white) 10 | [![License](https://img.shields.io/github/license/Ileriayo/markdown-badges?style=for-the-badge)](public/LICENSE) 11 | 12 | 13 | Introducing Splache (/splæʃ/), an agile, user-friendly, and lightweight JavaScript library that efficiently caches GraphQL queries using the power of Redis. The Splache library is designed for improved performance and reduced load on your GraphQL server. Through leveraging the speed and scalability of Redis, Splache is able to provide an efficient and unique solution for caching GraphQL queries! 14 | 15 | 16 |
17 | 18 | 19 |
20 |
21 | 22 | [Launch Page](www.splachejs.com) : Learn More about Splache & Demo our package via an interactive sandbox pre-installation 23 | 24 | [Install our NPM Package](https://www.npmjs.com/package/splache) 25 | 26 | [Documentation](https://medium.com/@zhangn356/exploring-caching-solutions-for-graphql-an-introduction-to-splache-4a497bdb597f) 27 | 28 |
29 | 30 | ## Get Started and Installation 31 | Run ```npm install splache``` to install the package to import ```SplacheCacheWhole```, ```ResolverCache``` and ```SplacheCache```. 32 | 33 | ### Quick Start with Redis 34 | If you are new to Redis, here is the [documentation](https://redis.io/docs/getting-started/) to install redis and start a redis instance locally. 35 | 36 | ### Import splache 37 | 38 | ```node 39 | import splache from 'splache' 40 | 41 | ``` 42 | 43 |
44 | 45 | ## Key Features 46 | 47 | 1. The Caching of Whole Queries 48 | 49 | 50 | Simply provide SpacheCacheWhole your schema, redis host, redis port, and password (Only provide the password if your external redis instance is password protected. If not, omit the password. Additionally, omit host, port, and password arguments if connecting to local redis instance) and then direct your queries through the middleware as seen in the example below. 51 | 52 | 53 | ```node 54 | import { SplacheCacheWhole } 55 | 56 | const cache = new SplacheCacheWhole(schema, host, post, password); 57 | 58 | app.use('/graphql', cache.wholeCache, (req, res) => { 59 | res.send(res.locals.queryResponse) 60 | }) 61 | ``` 62 | 63 | 64 | 2. The Caching of Resolvers 65 | 66 | ```node 67 | import { ResolverCache } from 'Splache' 68 | 69 | const cache = new ResolverCache(host, port, password) 70 | ``` 71 | 72 | Upon importing ResolverCache from our package, create a new instance of ResolverCache to access the ‘checkCache’ method. From there, simply wrap your resolver functions with our pre-built functionality. 73 | 74 | Here is an example: 75 | 76 | 77 | ```node 78 | planet : { 79 | type: Planet, 80 | args: { 81 | id : { 82 | description: 'id of the planet' 83 | type: new GraphQLNonNull(GraphQLString) 84 | } 85 | }, 86 | resolve: ((parent, args, context, info) => cache.checkCache(parents, args, context, info, getPlanet)) 87 | } 88 | ``` 89 | 90 | 91 | 3. The Caching of Normalized Query Strings & Breakdown of Complex Nested Queries 92 | 93 | Create a new instance of SplacheCache, passing in your schema, host, port, and password (omit host, port, and password if just connecting to local redis instance). By passing your query through our GQLquery method, it’ll generalize and split your query string and check the cache for these individual split queries. This reduces redundancy in your cache if existing cached queries are nested into a complex nested query. 94 | 95 | 96 | ```node 97 | import { SplacheCache } from 'splache' 98 | const cache = newSplacheCache(schema, host, port, password); 99 | 100 | app.use('/graphql', cacheGQLquery, (req, res) => { 101 | res.send(res.locals.queryResponse) 102 | }) 103 | ``` 104 | 105 | ## Currently Under Development 106 | 107 | 108 | ## Connect with the Team! 109 | | Nicholas Cathcart | Nicolas Jackson | Jessica Wang | Nancy Zhang | 110 | | :---: | :---: | :---: | :---: | 111 | | [![GitHub](https://skillicons.dev/icons?i=github)](https://github.com/nhcathcart) [![LinkedIn](https://skillicons.dev/icons?i=linkedin)](https://www.linkedin.com/in/nicholas-cathcart-4b3834267/)| [![GitHub](https://skillicons.dev/icons?i=github)](https://github.com/NicJax) [![LinkedIn](https://skillicons.dev/icons?i=linkedin)](www.linkedin.com/in/NicJax) | [![GitHub](https://skillicons.dev/icons?i=github)](https://github.com/jesswang-dev) [![LinkedIn](https://skillicons.dev/icons?i=linkedin)](https://www.linkedin.com/in/jessica-xuecen-wang) | [![GitHub](https://skillicons.dev/icons?i=github)](https://github.com/zhangn356 ) [![LinkedIn](https://skillicons.dev/icons?i=linkedin)](https://www.linkedin.com/in/zhangn356) | 112 | 113 | ## Want to Contribute? 114 | 115 | Splache is an open-source product that is open to input and contributions from the community. After trying out the product, feel free to raise issues or submit a PR request. 116 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', {targets: {node: 'current'}}], 3 | '@babel/preset-typescript' 4 | ], 5 | }; 6 | 7 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@jest/types" 2 | 3 | const config: Config.InitialOptions = { 4 | preset: "ts-jest", 5 | testEnvironment: "node", 6 | verbose: true, 7 | automock: false, 8 | } 9 | export default config; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "splache", 3 | "version": "1.0.0", 4 | "description": "A lightweight caching solution for GraphQL using Redis", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "build": "tsc --noEmit false", 9 | "lint": "eslint ./src --fix" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/oslabs-beta/CoolQL.git" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/oslabs-beta/CoolQL/issues" 19 | }, 20 | "homepage": "https://github.com/oslabs-beta/CoolQL#readme", 21 | "devDependencies": { 22 | "@babel/preset-typescript": "^7.18.6", 23 | "@types/express": "^4.17.17", 24 | "@types/jest": "^29.4.0", 25 | "@typescript-eslint/eslint-plugin": "^5.50.0", 26 | "@typescript-eslint/parser": "^5.50.0", 27 | "child_process": "^1.0.2", 28 | "eslint": "^8.33.0", 29 | "eslint-plugin-react": "^7.32.2", 30 | "jest": "^29.4.3", 31 | "redis-mock": "^0.56.3", 32 | "ts-jest": "^29.0.5", 33 | "typescript": "^4.9.5", 34 | "ts-node": "^10.9.1", 35 | "express": "^4.18.2" 36 | }, 37 | "dependencies": { 38 | "graphql": "^16.6.0", 39 | "redis": "^4.6.4" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/CacheWhole.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis'; 2 | import { graphql, GraphQLSchema } from 'graphql'; 3 | import { RedisClientType } from '@redis/client'; 4 | import { Request, Response, NextFunction } from 'express'; 5 | 6 | //creates an importable SplacheCacheWhole Class that accepts a graphQL schema and connects to the user's local Redis client or provided Redis client 7 | export class SplacheCacheWhole { 8 | 9 | schema: GraphQLSchema 10 | client: RedisClientType 11 | 12 | constructor(schema: GraphQLSchema, host?: string, port?: number, password?: string ){ 13 | 14 | this.schema = schema; 15 | if(host && port && password){ 16 | this.client = createClient({ 17 | socket: { 18 | host, port 19 | }, 20 | password 21 | }) 22 | } else if(host && port){ 23 | this.client = createClient({ 24 | socket: { 25 | host, port 26 | }}) 27 | } else{ 28 | this.client = createClient() 29 | } 30 | this.wholeCache = this.wholeCache.bind(this) 31 | this.client.connect() 32 | .then(() => console.log('connected to redis instance')) 33 | .catch((err) => console.log(`there was a problem connecting to the redis instance: ${err}`)); 34 | } 35 | 36 | //The method wholeCache is an express middleware function, it examines if queries coming from the request body already exists in the cache 37 | async wholeCache (req: Request, res: Response, next: NextFunction) { 38 | const queryString : string = req.body.query; 39 | const isInCache = await this.client.EXISTS(queryString); 40 | if (isInCache){ 41 | const returnObj = await this.client.GET(queryString); 42 | if (typeof returnObj === 'string'){ 43 | const returnObjParsed = JSON.parse(returnObj); 44 | res.locals.queryResult = returnObjParsed; 45 | return next(); 46 | } 47 | else { 48 | return next({err: 'There was a redis error'}) 49 | } 50 | } else { 51 | graphql({ schema: this.schema, source: queryString}) 52 | .then((response) => { 53 | this.client.SET(queryString, JSON.stringify(response)) 54 | res.locals.queryResult = response; 55 | return next(); 56 | }) 57 | .catch((err) => next({err})); 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/ResolverCache.ts: -------------------------------------------------------------------------------- 1 | import {createClient} from 'redis' 2 | import { RedisClientType } from '@redis/client'; 3 | 4 | 5 | //creates an importable ResolverCache Class that connects to the user's local Redis client or provided Redis client 6 | export class ResolverCache { 7 | client: RedisClientType 8 | constructor(host?:string, port?: number, password?: string){ 9 | if(host && port && password){ 10 | this.client = createClient({ 11 | socket: { 12 | host,port 13 | }, 14 | password 15 | }) 16 | }else if(host && port){ 17 | this.client = createClient({ 18 | socket: { 19 | host,port 20 | }}) 21 | }else{ 22 | this.client = createClient() 23 | } 24 | this.checkCache = this.checkCache.bind(this) 25 | this.client.connect() 26 | .then(() => console.log('connected to redis instance')) 27 | .catch((err) => console.log(`there was a problem connecting to the redis instance: ${err}`)) 28 | 29 | } 30 | 31 | //all instances of ResolverCache have access to the checkCache method which checks the user's cache 32 | //if the key already exists in the cache, the result is returned from the user's cache 33 | //if not, it is added to the cache with the corresponding result 34 | async checkCache (parent: any, args: any, context: any, info: {returnType: any}, callback:any){ 35 | const key = makeKey(info, args) 36 | const isInCache = await this.client.EXISTS(key) 37 | if (isInCache){ 38 | const returnObj = await this.client.GET(key); 39 | if (typeof returnObj === 'string'){ 40 | const returnObjParsed = JSON.parse(returnObj); 41 | return returnObjParsed 42 | } 43 | }else{ 44 | const returnObj = callback(args) 45 | await this.client.SET(key, JSON.stringify(returnObj)); 46 | return returnObj 47 | } 48 | } 49 | 50 | //used with mutations that need to update existing information in cache 51 | async updateCache (parent: any, args: any, context, info: {returnType: any}, callback:any) { 52 | const key = makeKey(info, args) 53 | const returnObj = callback(args) 54 | await this.client.SET(key, JSON.stringify(returnObj)); 55 | return returnObj 56 | } 57 | } 58 | 59 | //creates a key that will be the fieldName concatenated with the argument id 60 | export function makeKey (info:any, args:any){ 61 | let key = ''; 62 | if (Object.keys(args).length === 0) key = info.returnType; 63 | else key = String(info.returnType) + String(args.id); 64 | return key 65 | } 66 | 67 | -------------------------------------------------------------------------------- /src/SchemaWithCache.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLEnumType, 3 | GraphQLInterfaceType, 4 | GraphQLList, 5 | GraphQLNonNull, 6 | GraphQLObjectType, 7 | } from 'graphql'; 8 | import { GraphQLString } from 'graphql'; 9 | import { GraphQLSchema } from 'graphql'; 10 | 11 | import { getDroid, getFriends, getHero, getHuman, updateHuman } from './starWarsDataResolver'; 12 | 13 | import { ResolverCache } from './ResolverCache'; 14 | 15 | const resolverCache = new ResolverCache(); 16 | /** 17 | * This is designed to be an end-to-end test, demonstrating 18 | * the full GraphQL stack. 19 | * 20 | * We will create a GraphQL schema that describes the major 21 | * characters in the original Star Wars trilogy. 22 | * 23 | * NOTE: This may contain spoilers for the original Star 24 | * Wars trilogy. 25 | */ 26 | 27 | /** 28 | * Using our shorthand to describe type systems, the type system for our 29 | * Star Wars example is: 30 | * 31 | * ```graphql 32 | * enum Episode { NEW_HOPE, EMPIRE, JEDI } 33 | * 34 | * interface Character { 35 | * id: String! 36 | * name: String 37 | * friends: [Character] 38 | * appearsIn: [Episode] 39 | * } 40 | * 41 | * type Human implements Character { 42 | * id: String! 43 | * name: String 44 | * friends: [Character] 45 | * appearsIn: [Episode] 46 | * homePlanet: String 47 | * } 48 | * 49 | * type Droid implements Character { 50 | * id: String! 51 | * name: String 52 | * friends: [Character] 53 | * appearsIn: [Episode] 54 | * primaryFunction: String 55 | * } 56 | * 57 | * type Query { 58 | * hero(episode: Episode): Character 59 | * human(id: String!): Human 60 | * droid(id: String!): Droid 61 | * } 62 | * ``` 63 | * 64 | * We begin by setting up our schema. 65 | */ 66 | 67 | /** 68 | * The original trilogy consists of three movies. 69 | * 70 | * This implements the following type system shorthand: 71 | * ```graphql 72 | * enum Episode { NEW_HOPE, EMPIRE, JEDI } 73 | * ``` 74 | */ 75 | const episodeEnum = new GraphQLEnumType({ 76 | name: 'Episode', 77 | description: 'One of the films in the Star Wars Trilogy', 78 | values: { 79 | NEW_HOPE: { 80 | value: 4, 81 | description: 'Released in 1977.', 82 | }, 83 | EMPIRE: { 84 | value: 5, 85 | description: 'Released in 1980.', 86 | }, 87 | JEDI: { 88 | value: 6, 89 | description: 'Released in 1983.', 90 | }, 91 | }, 92 | }); 93 | 94 | /** 95 | * Characters in the Star Wars trilogy are either humans or droids. 96 | * 97 | * This implements the following type system shorthand: 98 | * ```graphql 99 | * interface Character { 100 | * id: String! 101 | * name: String 102 | * friends: [Character] 103 | * appearsIn: [Episode] 104 | * secretBackstory: String 105 | * } 106 | * ``` 107 | */ 108 | const characterInterface: GraphQLInterfaceType = new GraphQLInterfaceType({ 109 | name: 'Character', 110 | description: 'A character in the Star Wars Trilogy', 111 | fields: () => ({ 112 | id: { 113 | type: new GraphQLNonNull(GraphQLString), 114 | description: 'The id of the character.', 115 | }, 116 | name: { 117 | type: GraphQLString, 118 | description: 'The name of the character.', 119 | }, 120 | friends: { 121 | type: new GraphQLList(characterInterface), 122 | description: 123 | 'The friends of the character, or an empty list if they have none.', 124 | }, 125 | appearsIn: { 126 | type: new GraphQLList(episodeEnum), 127 | description: 'Which movies they appear in.', 128 | }, 129 | secretBackstory: { 130 | type: GraphQLString, 131 | description: 'All secrets about their past.', 132 | }, 133 | }), 134 | resolveType(character) { 135 | switch (character.type) { 136 | case 'Human': 137 | return humanType.name; 138 | case 'Droid': 139 | return droidType.name; 140 | } 141 | }, 142 | }); 143 | 144 | /** 145 | * We define our human type, which implements the character interface. 146 | * 147 | * This implements the following type system shorthand: 148 | * ```graphql 149 | * type Human : Character { 150 | * id: String! 151 | * name: String 152 | * friends: [Character] 153 | * appearsIn: [Episode] 154 | * secretBackstory: String 155 | * } 156 | * ``` 157 | */ 158 | const humanType = new GraphQLObjectType({ 159 | name: 'Human', 160 | description: 'A humanoid creature in the Star Wars universe.', 161 | fields: () => ({ 162 | id: { 163 | type: new GraphQLNonNull(GraphQLString), 164 | description: 'The id of the human.', 165 | }, 166 | name: { 167 | type: GraphQLString, 168 | description: 'The name of the human.', 169 | }, 170 | friends: { 171 | type: new GraphQLList(characterInterface), 172 | description: 173 | 'The friends of the human, or an empty list if they have none.', 174 | resolve: (human) => getFriends(human), 175 | }, 176 | appearsIn: { 177 | type: new GraphQLList(episodeEnum), 178 | description: 'Which movies they appear in.', 179 | }, 180 | homePlanet: { 181 | type: GraphQLString, 182 | description: 'The home planet of the human, or null if unknown.', 183 | }, 184 | secretBackstory: { 185 | type: GraphQLString, 186 | description: 'Where are they from and how they came to be who they are.', 187 | resolve() { 188 | throw new Error('secretBackstory is secret.'); 189 | }, 190 | }, 191 | }), 192 | interfaces: [characterInterface], 193 | }); 194 | 195 | /** 196 | * The other type of character in Star Wars is a droid. 197 | * 198 | * This implements the following type system shorthand: 199 | * ```graphql 200 | * type Droid : Character { 201 | * id: String! 202 | * name: String 203 | * friends: [Character] 204 | * appearsIn: [Episode] 205 | * secretBackstory: String 206 | * primaryFunction: String 207 | * } 208 | * ``` 209 | */ 210 | const droidType = new GraphQLObjectType({ 211 | name: 'Droid', 212 | description: 'A mechanical creature in the Star Wars universe.', 213 | fields: () => ({ 214 | id: { 215 | type: new GraphQLNonNull(GraphQLString), 216 | description: 'The id of the droid.', 217 | }, 218 | name: { 219 | type: GraphQLString, 220 | description: 'The name of the droid.', 221 | }, 222 | friends: { 223 | type: new GraphQLList(characterInterface), 224 | description: 225 | 'The friends of the droid, or an empty list if they have none.', 226 | resolve: (droid) => getFriends(droid), 227 | }, 228 | appearsIn: { 229 | type: new GraphQLList(episodeEnum), 230 | description: 'Which movies they appear in.', 231 | }, 232 | secretBackstory: { 233 | type: GraphQLString, 234 | description: 'Construction date and the name of the designer.', 235 | resolve() { 236 | throw new Error('secretBackstory is secret.'); 237 | }, 238 | }, 239 | primaryFunction: { 240 | type: GraphQLString, 241 | description: 'The primary function of the droid.', 242 | }, 243 | }), 244 | interfaces: [characterInterface], 245 | }); 246 | 247 | /** 248 | * This is the type that will be the root of our query, and the 249 | * entry point into our schema. It gives us the ability to fetch 250 | * objects by their IDs, as well as to fetch the undisputed hero 251 | * of the Star Wars trilogy, R2-D2, directly. 252 | * 253 | * This implements the following type system shorthand: 254 | * ```graphql 255 | * type Query { 256 | * hero(episode: Episode): Character 257 | * human(id: String!): Human 258 | * droid(id: String!): Droid 259 | * } 260 | * ``` 261 | */ 262 | const queryType = new GraphQLObjectType({ 263 | name: 'Query', 264 | fields: () => ({ 265 | hero: { 266 | type: characterInterface, 267 | args: { 268 | episode: { 269 | description: 270 | 'If omitted, returns the hero of the whole saga. If provided, returns the hero of that particular episode.', 271 | type: episodeEnum, 272 | }, 273 | }, 274 | resolve: (_source, { episode }) => getHero(episode), 275 | }, 276 | human: { 277 | type: humanType, 278 | args: { 279 | id: { 280 | description: 'id of the human', 281 | type: new GraphQLNonNull(GraphQLString), 282 | }, 283 | }, 284 | resolve: (_source, args, info, context) => resolverCache.checkCache(_source, args, info, context, getHuman), 285 | }, 286 | droid: { 287 | type: droidType, 288 | args: { 289 | id: { 290 | description: 'id of the droid', 291 | type: new GraphQLNonNull(GraphQLString), 292 | }, 293 | }, 294 | resolve: (_source, { id }) => getDroid(id), 295 | }, 296 | }), 297 | }); 298 | 299 | const mutationType = new GraphQLObjectType({ 300 | name: 'Mutation', 301 | fields: () => ({ 302 | updatehuman: { 303 | type: humanType, 304 | args: { 305 | id: { 306 | description: 'id of the human', 307 | type: new GraphQLNonNull(GraphQLString), 308 | }, 309 | prop: { 310 | description: 'property to be updated', 311 | type: new GraphQLNonNull(GraphQLString), 312 | }, 313 | update: { 314 | description: 'the new property value', 315 | type: new GraphQLNonNull(GraphQLString) 316 | }, 317 | }, 318 | resolve: (_source, args, info, context) => resolverCache.updateCache(_source, args, info, context, updateHuman), 319 | }, 320 | 321 | }), 322 | }); 323 | /** 324 | * Finally, we construct our schema (whose starting query type is the query 325 | * type we defined above) and export it. 326 | */ 327 | export const StarWarsSchema: GraphQLSchema = new GraphQLSchema({ 328 | query: queryType, 329 | mutation: mutationType, 330 | types: [humanType, droidType], 331 | }); -------------------------------------------------------------------------------- /src/SplacheCache.ts: -------------------------------------------------------------------------------- 1 | import { createClient, RedisClientType } from 'redis'; 2 | import { parse, visit, GraphQLSchema } from 'graphql'; 3 | import { graphql } from 'graphql'; 4 | 5 | /* Creates an importable SplacheCache Class that accepts a GraphQL schema 6 | and connects to the user's local Redis client or provided Redis client*/ 7 | export class SplacheCache { 8 | schema: GraphQLSchema; 9 | typeToFields: object; 10 | queryToReturnType: object; 11 | client: RedisClientType; 12 | constructor( 13 | schema: GraphQLSchema, 14 | host?: string, 15 | port?: number, 16 | password?: string 17 | ) { 18 | this.schema = schema; 19 | this.GQLquery = this.GQLquery.bind(this); 20 | this.typeToFields = typeToFields(this.schema); 21 | this.queryToReturnType = queryToReturnedType(this.schema); 22 | if (host && port && password) { 23 | this.client = createClient({ 24 | socket: { 25 | host, 26 | port, 27 | }, 28 | password, 29 | }); 30 | } else if (host && port) { 31 | this.client = createClient({ 32 | socket: { 33 | host, 34 | port, 35 | }, 36 | }); 37 | } else { 38 | this.client = createClient(); 39 | } 40 | this.client.connect() 41 | .then(() => console.log('connected to redis')) 42 | .catch((err) => console.log(`there was a problem connecting to the redis instance: ${err}`)) 43 | } 44 | //Partial normalization of the input query through traversing the GraphQL AST to build valid root queries. 45 | //The root queries are checked against the Redis cache as opposed to the original query 46 | async GQLquery(req: any, res: any, next: any) { 47 | const queryString: string = req.body.query; 48 | const ast = parse(queryString); 49 | const [template, fieldArgs] = await makeTemplate(ast); 50 | const splitQuery = qlStrGen(template, fieldArgs); 51 | const compiledObj = { data: {} }; 52 | for (const query of splitQuery) { 53 | const isInCache = await this.client.EXISTS(query); 54 | if (isInCache) { 55 | const returnObj = await this.client.GET(query); 56 | if (typeof returnObj === 'string') { 57 | const returnObjParsed = JSON.parse(returnObj); 58 | compiledObj.data[Object.keys(returnObjParsed.data)[0]] = 59 | returnObjParsed.data[Object.keys(returnObjParsed.data)[0]]; 60 | } else { 61 | return next({ err: 'There was a redis error' }); 62 | } 63 | } else { 64 | await graphql({ schema: this.schema, source: query }) 65 | .then((response: any) => { 66 | compiledObj.data[Object.keys(response.data)[0]] = 67 | response.data[Object.keys(response.data)[0]]; 68 | this.client.SET(query, JSON.stringify(response)); 69 | }) 70 | .catch((err) => next({ err })); 71 | } 72 | } 73 | res.locals.queryResult = compiledObj; 74 | return next(); 75 | } 76 | } 77 | 78 | // makeTemplate uses the AST from the GraphQL query as an input 79 | // The visit function is provided by graphql, check documentation here https://graphql.org/graphql-js/language/#visit 80 | export async function makeTemplate(ast: any) { 81 | const template: any = {}; 82 | const path: any = []; 83 | const fieldInfo: any = {}; 84 | visit(ast, { 85 | Field: { 86 | enter(node: any) { 87 | if (node.arguments) { 88 | const args: any = {}; 89 | for (let i = 0; i < node.arguments.length; i++) { 90 | const key = node.arguments[i].name.value; 91 | args[key] = node.arguments[i].value.value; 92 | } 93 | fieldInfo[node.name.value] = { ...args }; 94 | } 95 | 96 | path.push(node.name.value); 97 | }, 98 | leave() { 99 | path.pop(); 100 | }, 101 | }, 102 | SelectionSet: { 103 | enter(node: any, key, parent: any) { 104 | if (parent.kind === 'Field') { 105 | const fields = {}; 106 | for (let i = 0; i < node.selections.length; i++) { 107 | if (!node.selections[i].selectionSet) { 108 | fields[node.selections[i].name.value] = true; 109 | } 110 | } 111 | const fieldsObj = { 112 | ...fields, 113 | ...fieldInfo[path[path.length - 1]], 114 | }; 115 | let curr = template; 116 | for (let i = 0; i < path.length; i++) { 117 | if (i + 1 === path.length) curr[path[i]] = { ...fieldsObj }; 118 | curr = curr[path[i]]; 119 | } 120 | } 121 | }, 122 | leave() { 123 | path.pop(); 124 | }, 125 | }, 126 | }); 127 | return [template, fieldInfo]; 128 | } 129 | 130 | export function typeToFields(schema: GraphQLSchema) { 131 | const builtInTypes = { 132 | String: 'String', 133 | Int: 'Int', 134 | Float: 'Float', 135 | Boolean: 'Boolean', 136 | ID: 'ID', 137 | Query: 'Query', 138 | __Type: '__Type', 139 | __Field: '__Field', 140 | __EnumValue: '__EnumValue', 141 | __DirectiveLocation: '__DirectiveLocation', 142 | __Schema: '__Schema', 143 | __TypeKind: '__TypeKind', 144 | __InputValue: '__InputValue', 145 | __Directive: '__Directive', 146 | }; 147 | const typeMap: any = schema.getTypeMap(); 148 | const typesToFields = {}; 149 | for (const type in typeMap) { 150 | if (type in builtInTypes === false) { 151 | const tempObj = {}; 152 | const fields = typeMap[type]._fields; 153 | for (const field in fields) { 154 | const key = fields[field].name.toLowerCase(); 155 | let value: any; 156 | if (fields[field].type.ofType) 157 | value = fields[field].type.ofType.name.toLowerCase(); 158 | else value = fields[field].type.name.toLowerCase(); 159 | tempObj[key] = value; 160 | } 161 | typesToFields[type.toLowerCase()] = tempObj; 162 | } 163 | } 164 | return typesToFields; 165 | } 166 | 167 | export function queryToReturnedType(schema: GraphQLSchema) { 168 | const queryTypeFields: any = schema.getQueryType()?.getFields(); 169 | const map: any = {}; 170 | for (const key in queryTypeFields) { 171 | if (queryTypeFields[key].type._interfaces.length > 0) 172 | map[key] = queryTypeFields[key].type._interfaces[0].name.toLowerCase(); 173 | else map[key] = queryTypeFields[key].type.name.toLowerCase(); 174 | } 175 | return map; 176 | } 177 | 178 | //GQLquery helper functions below 179 | export function qlStrGen(template, fieldArgs) { 180 | const queryStrs: string[] = []; 181 | for (const prop in template) { 182 | let queryStr = `query {${prop} ${genArgStr(fieldArgs[prop])} {`; 183 | queryStr += genfields(template[prop]); 184 | queryStrs.push((queryStr += '} }')); 185 | } 186 | return queryStrs; 187 | } 188 | 189 | export function genArgStr(args) { 190 | if (Object.keys(args)[0] === undefined) return ''; 191 | let argStr = '('; 192 | for (const arg in args) { 193 | argStr += `${arg}: "${args[arg]}" `; 194 | } 195 | return (argStr += ')'); 196 | } 197 | 198 | export function genfields(fields) { 199 | let fieldStr = ''; 200 | for (const field in fields) { 201 | if (typeof fields[field] === 'object') { 202 | fieldStr += `${field} {${genfields(fields[field])}} `; 203 | } else { 204 | if (field !== 'id') fieldStr += `${field} `; 205 | } 206 | } 207 | return fieldStr; 208 | } 209 | -------------------------------------------------------------------------------- /src/__test__/CacheWhole.test.ts: -------------------------------------------------------------------------------- 1 | import { SplacheCacheWhole } from "../CacheWhole"; 2 | import { Request, Response } from "express"; 3 | import {StarWarsSchema} from '../schemaTest' 4 | describe("SplacheCacheWhole", () => { 5 | 6 | let mockRequest: Partial; 7 | let mockResponse: Partial; 8 | 9 | beforeEach(()=>{ 10 | mockRequest = {}; 11 | mockResponse = {locals: {}}; 12 | }) 13 | 14 | const testSplacheCacheWhole = new SplacheCacheWhole(StarWarsSchema); 15 | afterAll(()=>{ 16 | testSplacheCacheWhole.client.QUIT(); 17 | }) 18 | it('.wholeCache should return next', async () => { 19 | mockRequest = {body:{query: 20 | `query FetchLukeAndC3POQuery { 21 | human(id: "1000") { 22 | id 23 | name 24 | friends{ 25 | id 26 | name 27 | } 28 | } 29 | droid(id: "2000") { 30 | id 31 | name 32 | }` 33 | 34 | } } as Request; 35 | const next = jest.fn(); 36 | await testSplacheCacheWhole.wholeCache(mockRequest, mockResponse, next); 37 | expect(next).toHaveBeenCalled(); 38 | }) 39 | it('.wholeCache should call client.set if the key does not exist', async () => { 40 | await testSplacheCacheWhole.client.FLUSHALL(); 41 | const spy = jest.spyOn(testSplacheCacheWhole.client, "SET"); 42 | mockRequest = {body:{query: 43 | `query FetchLukeAndC3POQuery { 44 | human(id: "1000") { 45 | id 46 | name 47 | friends{ 48 | id 49 | name 50 | } 51 | } 52 | ` 53 | 54 | } } as Request; 55 | const next = jest.fn(); 56 | await testSplacheCacheWhole.wholeCache(mockRequest, mockResponse, next); 57 | expect(spy).toHaveBeenCalled(); 58 | }) 59 | it('.wholeCache should call client.get if the key already exists', async () => { 60 | const spy = jest.spyOn(testSplacheCacheWhole.client, "SET"); 61 | mockRequest = {body:{query: 62 | `query FetchLukeAndC3POQuery { 63 | human(id: "1000") { 64 | id 65 | name 66 | friends{ 67 | id 68 | name 69 | } 70 | } 71 | ` 72 | 73 | } } as Request; 74 | const next = jest.fn(); 75 | await testSplacheCacheWhole.wholeCache(mockRequest, mockResponse, next); 76 | expect(spy).toHaveBeenCalled(); 77 | }) 78 | }) 79 | 80 | -------------------------------------------------------------------------------- /src/__test__/RedisConnection.test.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis' 2 | import { ResolverCache } from "../ResolverCache"; 3 | 4 | 5 | interface MockRedisClient { 6 | exists: jest.Mock, [string]>; 7 | get: jest.Mock, [string]>; 8 | set: jest.Mock, [string, string]>; 9 | connect: jest.Mock, []>; 10 | } 11 | 12 | const createMockRedisClient = (): MockRedisClient => ({ 13 | exists: jest.fn(), 14 | get: jest.fn(), 15 | set: jest.fn(), 16 | connect: jest.fn(), 17 | }); 18 | 19 | jest.mock('redis'); 20 | 21 | 22 | const mockCreateClient = createClient as jest.MockedFunction; 23 | 24 | describe ('ResolverCache Redis Connection', () => { 25 | let mockClient: MockRedisClient; 26 | let resolverCache: ResolverCache; 27 | 28 | beforeEach(() => { 29 | mockClient = createMockRedisClient(); 30 | resolverCache = new ResolverCache('localhost', 6379, 'password'); 31 | resolverCache.client = mockClient as any; 32 | }); 33 | 34 | afterEach(() => { 35 | jest.clearAllMocks(); 36 | }); 37 | 38 | describe('constructor', () => { 39 | it('should create a new Redis client with default options if no arguments are provided', () => { 40 | resolverCache = new ResolverCache(); 41 | expect(mockCreateClient).toHaveBeenCalledTimes(1); 42 | }); 43 | 44 | it('should create a new Redis client with host and port if provided', () => { 45 | resolverCache = new ResolverCache('localhost', 6379); 46 | expect(mockCreateClient).toHaveBeenCalledWith({ socket: { host: 'localhost', port: 6379 } }); 47 | }); 48 | 49 | it('should create a new Redis client with host, port and password if provided', () => { 50 | resolverCache = new ResolverCache('localhost', 6379, 'password'); 51 | expect(mockCreateClient).toHaveBeenCalledWith({ socket: { host: 'localhost', port: 6379 }, password: 'password' }); 52 | }); 53 | 54 | it('should call the connect method of the Redis client', async () => { 55 | await resolverCache.client.connect(); 56 | expect(resolverCache.client.connect).toHaveBeenCalledTimes(1); 57 | }); 58 | }); 59 | }) -------------------------------------------------------------------------------- /src/__test__/Resolver.test.ts: -------------------------------------------------------------------------------- 1 | import { ResolverCache } from "../ResolverCache"; 2 | 3 | //must have redis instance running and flushed 4 | 5 | 6 | describe("ResolverCache", () => { 7 | 8 | function testFunc (args: any){ 9 | return 0 10 | } 11 | const testResolverCache = new ResolverCache() 12 | afterAll(()=>{ 13 | testResolverCache.client.QUIT(); 14 | }) 15 | 16 | it('checkCache should be a function', async ()=> { 17 | expect(typeof testResolverCache.checkCache).toEqual('function') 18 | }) 19 | it('checkCache should return the result of the callback', async ()=> { 20 | const result = await testResolverCache.checkCache({}, {}, {}, {returnType:'test'}, testFunc) 21 | expect(result).toEqual(0) 22 | }) 23 | it('checkCache should call client.set if the key doesnt exist', async()=>{ 24 | await testResolverCache.client.FLUSHALL(); 25 | const spy = jest.spyOn(testResolverCache.client, "SET"); 26 | await testResolverCache.checkCache({}, {id:'1'}, {}, {returnType:'test'}, testFunc) 27 | expect(spy).toBeCalled(); 28 | }) 29 | it('checkCache should call client.get if the key exists', async()=>{ 30 | const spy = jest.spyOn(testResolverCache.client, "GET"); 31 | await testResolverCache.checkCache({}, {id:'1'}, {}, {returnType:'test'}, testFunc) 32 | expect(spy).toBeCalled(); 33 | }) 34 | it('updateCache should return the result of the callback', async ()=> { 35 | const result = await testResolverCache.updateCache({}, {}, {}, {returnType:'test'}, testFunc) 36 | expect(result).toEqual(0) 37 | }) 38 | it('updateCache should call client.set', async()=>{ 39 | const spy = jest.spyOn(testResolverCache.client, "SET"); 40 | await testResolverCache.checkCache({}, {id:'1'}, {}, {returnType:'test'}, testFunc) 41 | expect(spy).toBeCalled 42 | }) 43 | }) 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/__test__/SplacheCache.test.ts: -------------------------------------------------------------------------------- 1 | import { SplacheCache } from "../SplacheCache"; 2 | import {StarWarsSchema} from '../schemaTest'; 3 | import { Request, Response } from "express"; 4 | 5 | describe ("SplacheCache", ()=>{ 6 | 7 | const testSplacheCache = new SplacheCache(StarWarsSchema); 8 | 9 | let mockRequest: Partial; 10 | let mockResponse: any; 11 | 12 | beforeEach(()=>{ 13 | mockRequest = {}; 14 | mockResponse = {locals: {}}; 15 | }) 16 | afterEach(() => { 17 | jest.clearAllMocks(); 18 | }); 19 | afterAll(()=>{ 20 | testSplacheCache.client.QUIT(); 21 | }) 22 | 23 | it(".GQLquery should attach the correct response to res.locals for one root query", async ()=>{ 24 | const expectedResponse = { 25 | "data": { 26 | "human": { 27 | "id": "1000", 28 | "name": "Luke Skywalker" 29 | } 30 | } 31 | } 32 | mockRequest = { 33 | body:{ 34 | query: `{ 35 | human (id:"1000") { 36 | name 37 | } 38 | }` 39 | } 40 | } 41 | const next = jest.fn(); 42 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next) 43 | expect(mockResponse.locals.queryResult).toEqual(expectedResponse); 44 | }) 45 | 46 | it(".GQLquery should attach the correct response to res.locals for multiple root queries", async ()=>{ 47 | const expectedResponse = { 48 | "data": { 49 | "human": { 50 | "id": "1000", 51 | "name": "Luke Skywalker" 52 | }, 53 | "droid": { 54 | "name": "C-3PO", 55 | "id": "2000" 56 | }, 57 | "hero": { 58 | "name": "R2-D2" 59 | } 60 | } 61 | } 62 | mockRequest = { 63 | body:{ 64 | query: `{ 65 | human (id:"1000") { 66 | name 67 | } 68 | droid (id:"2000") { 69 | name 70 | } 71 | hero { 72 | name 73 | } 74 | }` 75 | } 76 | } 77 | const next = jest.fn(); 78 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next) 79 | expect(mockResponse.locals.queryResult).toEqual(expectedResponse); 80 | }) 81 | it('.GQLquery should handle queries where there are nested fields', async () => { 82 | const expectedResponse = { 83 | "data": { 84 | "human": { 85 | "id": "1000", 86 | "name": "Luke Skywalker", 87 | "friends": [ 88 | { 89 | "id": "1002", 90 | "name": "Han Solo", 91 | "friends": [ 92 | { 93 | "name": "Luke Skywalker" 94 | }, 95 | { 96 | "name": "Leia Organa" 97 | }, 98 | { 99 | "name": "R2-D2" 100 | } 101 | ] 102 | }, 103 | { 104 | "id": "1003", 105 | "name": "Leia Organa", 106 | "friends": [ 107 | { 108 | "name": "Luke Skywalker" 109 | }, 110 | { 111 | "name": "Han Solo" 112 | }, 113 | { 114 | "name": "C-3PO" 115 | }, 116 | { 117 | "name": "R2-D2" 118 | } 119 | ] 120 | }, 121 | { 122 | "id": "2000", 123 | "name": "C-3PO", 124 | "friends": [ 125 | { 126 | "name": "Luke Skywalker" 127 | }, 128 | { 129 | "name": "Han Solo" 130 | }, 131 | { 132 | "name": "Leia Organa" 133 | }, 134 | { 135 | "name": "R2-D2" 136 | } 137 | ] 138 | }, 139 | { 140 | "id": "2001", 141 | "name": "R2-D2", 142 | "friends": [ 143 | { 144 | "name": "Luke Skywalker" 145 | }, 146 | { 147 | "name": "Han Solo" 148 | }, 149 | { 150 | "name": "Leia Organa" 151 | } 152 | ] 153 | } 154 | ] 155 | }, 156 | "droid": { 157 | "name": "C-3PO", 158 | "id": "2000" 159 | }, 160 | "hero": { 161 | "name": "R2-D2" 162 | } 163 | } 164 | } 165 | mockRequest = { 166 | body:{ 167 | query: `query { 168 | human (id:"1000") { 169 | id 170 | name 171 | friends { 172 | id 173 | name 174 | friends { 175 | name 176 | } 177 | } 178 | } 179 | droid (id: "2000") { 180 | name 181 | } 182 | hero { 183 | name 184 | } 185 | }` 186 | } 187 | } 188 | const next = jest.fn(); 189 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next) 190 | expect(mockResponse.locals.queryResult).toEqual(expectedResponse); 191 | }); 192 | it(".GQLquery should check the cache for the key on every invocation", async ()=>{ 193 | const spy = jest.spyOn(testSplacheCache.client, "EXISTS") 194 | const next = jest.fn(); 195 | mockRequest = { 196 | body:{ 197 | query: `{ 198 | human (id:"1000") { 199 | name 200 | } 201 | }` 202 | } 203 | } 204 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next) 205 | expect(spy).toBeCalled(); 206 | }) 207 | it(".GQLquery should call client.set if the key doesn't exist in the cache", async ()=>{ 208 | await testSplacheCache.client.FLUSHALL() 209 | const spy = jest.spyOn(testSplacheCache.client, "SET") 210 | const next = jest.fn(); 211 | mockRequest = { 212 | body:{ 213 | query: `{ 214 | human (id:"1000") { 215 | name 216 | } 217 | }` 218 | } 219 | } 220 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next) 221 | expect(spy).toBeCalled(); 222 | }) 223 | it(".GQLquery should call client.get if the key is found in the cache", async ()=> { 224 | const spy = jest.spyOn(testSplacheCache.client, "GET") 225 | const next = jest.fn(); 226 | mockRequest = { 227 | body:{ 228 | query: `{ 229 | human (id:"1000") { 230 | name 231 | } 232 | }` 233 | } 234 | } 235 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next) 236 | expect(spy).toBeCalled(); 237 | 238 | }) 239 | it(".GQLquery should retrieve query results from both cache and graphQL query when partial caching has occured", async () => { 240 | const spySET = jest.spyOn(testSplacheCache.client, "SET") 241 | const spyGET = jest.spyOn(testSplacheCache.client, "GET") 242 | const next = jest.fn(); 243 | mockRequest = { 244 | body:{ 245 | query: `{ 246 | human (id:"1000") { 247 | name 248 | } 249 | droid (id: "2000") { 250 | name 251 | } 252 | }` 253 | } 254 | } 255 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next) 256 | expect(spySET).toBeCalled(); 257 | expect(spyGET).toBeCalled(); 258 | }) 259 | it(".GQLquery should retrieve root queries that have been stored in the cache as partials", async () => { 260 | const spySet = jest.spyOn(testSplacheCache.client, "SET") 261 | mockRequest = { 262 | body:{ 263 | query: `{ 264 | human (id:"1000") { 265 | name 266 | } 267 | }` 268 | } 269 | } 270 | const next = jest.fn(); 271 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next) 272 | mockRequest = { 273 | body:{ 274 | query: `{ 275 | droid (id: "2000") { 276 | name 277 | } 278 | }` 279 | } 280 | } 281 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next) 282 | expect(spySet).toHaveBeenCalledTimes(0); 283 | 284 | }) 285 | it(".GQLquery should produce complex queries from cached root queries", async () => { 286 | const spySet = jest.spyOn(testSplacheCache.client, "SET") 287 | const next = jest.fn(); 288 | mockRequest = { 289 | body:{ 290 | query: `{ 291 | human (id:"1000") { 292 | name 293 | } 294 | droid (id: "2000") { 295 | name 296 | } 297 | }` 298 | } 299 | } 300 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next) 301 | expect(spySet).toHaveBeenCalledTimes(0); 302 | }) 303 | it(".GQLquery should return next", async ()=>{ 304 | mockRequest = {body:{query: 305 | `query FetchLukeAndC3POQuery { 306 | human(id: "1000") { 307 | id 308 | name 309 | friends{ 310 | id 311 | name 312 | } 313 | } 314 | droid(id: "2000") { 315 | id 316 | name 317 | } 318 | }` 319 | 320 | } } as Request; 321 | const next = jest.fn(); 322 | await testSplacheCache.GQLquery(mockRequest, mockResponse, next); 323 | expect(next).toHaveBeenCalled(); 324 | }) 325 | }) -------------------------------------------------------------------------------- /src/__test__/SplacheCacheHelpers.test.ts: -------------------------------------------------------------------------------- 1 | import { SplacheCache, makeTemplate, typeToFields, qlStrGen, genArgStr, genfields, queryToReturnedType} from "../SplacheCache"; 2 | import { Request, Response } from "express"; 3 | import {StarWarsSchema} from '../schemaTest' 4 | import {parse} from 'graphql' 5 | //must have redis instance running and flushed 6 | 7 | 8 | 9 | describe("SplacheCache Helpers", () => { 10 | 11 | it('makeTemplate should output a valid template, and fieldArgs', async ()=> { 12 | const queryString = `query FetchLukeAndC3POQuery { 13 | human(id: "1000") { 14 | name 15 | } 16 | droid(id: "2000") { 17 | name 18 | } 19 | }` 20 | const ast = parse(queryString) 21 | const output = await makeTemplate(ast) 22 | const [template, fieldArgs] = output; 23 | expect(template).toEqual({ 24 | human: { name: true, id: '1000' }, 25 | droid: { name: true, id: '2000' } 26 | }) 27 | expect(fieldArgs).toEqual({ human: { id: '1000' }, name: {}, droid: { id: '2000' } }) 28 | }) 29 | 30 | it('typeToFields should output a valid type map (object)', () => { 31 | const expectedTypeMap = { 32 | human: { 33 | id: 'string', 34 | name: 'string', 35 | friends: 'character', 36 | appearsin: 'episode', 37 | homeplanet: 'string', 38 | secretbackstory: 'string' 39 | }, 40 | character: { 41 | id: 'string', 42 | name: 'string', 43 | friends: 'character', 44 | appearsin: 'episode', 45 | secretbackstory: 'string' 46 | }, 47 | episode: {}, 48 | droid: { 49 | id: 'string', 50 | name: 'string', 51 | friends: 'character', 52 | appearsin: 'episode', 53 | secretbackstory: 'string', 54 | primaryfunction: 'string' 55 | } 56 | } 57 | 58 | const output = typeToFields(StarWarsSchema) 59 | expect(output).toEqual(expectedTypeMap); 60 | 61 | }); 62 | it('queryToReturned type should return a valid query to type map', async ()=> { 63 | const expectedMap = { hero: 'character', human: 'character', droid: 'character' } 64 | const output = queryToReturnedType(StarWarsSchema); 65 | expect(output).toEqual(expectedMap); 66 | }); 67 | it('qlStrGen should return an array of single root graphQL queries', async () => { 68 | const expectedQueryStrings = [ 69 | 'query {human (id: "1000" ) {id friends {id name friends {name } } } }', 70 | 'query {droid (id: "2000" ) {name id } }', 71 | 'query {hero {name } }' 72 | ]; 73 | const queryStr = `query { 74 | human (id:"1000") { 75 | friends { 76 | id 77 | name 78 | friends { 79 | name 80 | } 81 | } 82 | } 83 | droid (id: "2000") { 84 | name 85 | } 86 | hero { 87 | name 88 | } 89 | }` 90 | const ast = parse(queryStr) 91 | const mkTemplateOut = await makeTemplate(ast) 92 | const [template, fieldArgs] = mkTemplateOut; 93 | const outputStrs = qlStrGen(template, fieldArgs); 94 | expect(outputStrs).toEqual(expectedQueryStrings); 95 | }); 96 | it('genArgStr should return a string of the query arguments', ()=> { 97 | const qArgs = { id: '1000' , name: 'test'} 98 | const expectedQArgStr = '(id: "1000" name: "test" )' 99 | const outputArgStr = genArgStr(qArgs); 100 | expect(outputArgStr).toEqual(expectedQArgStr); 101 | 102 | }); 103 | it('genfields should return a string of all query fields', () => { 104 | const qFields = {id: '1000',friends: { id: true, name: true, friends: { name: true } }} 105 | const expectQStr = 'id friends {id name friends {name } } ' 106 | const outputQfield = genfields(qFields); 107 | expect(outputQfield).toEqual(expectQStr); 108 | 109 | 110 | }) 111 | 112 | }) -------------------------------------------------------------------------------- /src/__test__/docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | redis: 5 | image: redis 6 | ports: 7 | - "6379:6379" -------------------------------------------------------------------------------- /src/index-test.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { SplacheCache } from './SplacheCache'; 3 | import {SplacheCacheWhole} from './CacheWhole' 4 | import {StarWarsSchema} from './schemaTest' 5 | // import {StarWarsSchema} from './SchemaWithCache' 6 | import {graphql, GraphQLSchema} from 'graphql' 7 | 8 | const app = express() 9 | 10 | const cache = new SplacheCacheWhole(StarWarsSchema); 11 | // const cache = new SplacheCache(StarWarsSchema) 12 | app.use(express.json()) 13 | app.use('/graphql', cache.wholeCache, (req, res) => { 14 | res.send(res.locals.queryResult) 15 | }) 16 | 17 | // app.use('/graphql', cache.GQLquery, (req, res) => { 18 | // res.send(res.locals.queryResult) 19 | // }) 20 | // app.use('/graphql', (req, res) => { 21 | // graphql({ schema: StarWarsSchema, source: req.body.query}) 22 | // .then((response) => { 23 | // res.send(response) 24 | // }) 25 | // }) 26 | app.use('/graphql', (req, res) => { 27 | graphql({ schema: StarWarsSchema, source: req.body.query}) 28 | .then((response) => { 29 | res.send(response) 30 | }) 31 | }) 32 | 33 | app.listen(4000) -------------------------------------------------------------------------------- /src/schemaTest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLEnumType, 3 | GraphQLInterfaceType, 4 | GraphQLList, 5 | GraphQLNonNull, 6 | GraphQLObjectType, 7 | } from 'graphql'; 8 | import { GraphQLString } from 'graphql'; 9 | import { GraphQLSchema } from 'graphql'; 10 | 11 | import { getDroid, getFriends, getHero, getHuman } from './starWarsData'; 12 | 13 | /** 14 | * This is designed to be an end-to-end test, demonstrating 15 | * the full GraphQL stack. 16 | * 17 | * We will create a GraphQL schema that describes the major 18 | * characters in the original Star Wars trilogy. 19 | * 20 | * NOTE: This may contain spoilers for the original Star 21 | * Wars trilogy. 22 | */ 23 | 24 | /** 25 | * Using our shorthand to describe type systems, the type system for our 26 | * Star Wars example is: 27 | * 28 | * ```graphql 29 | * enum Episode { NEW_HOPE, EMPIRE, JEDI } 30 | * 31 | * interface Character { 32 | * id: String! 33 | * name: String 34 | * friends: [Character] 35 | * appearsIn: [Episode] 36 | * } 37 | * 38 | * type Human implements Character { 39 | * id: String! 40 | * name: String 41 | * friends: [Character] 42 | * appearsIn: [Episode] 43 | * homePlanet: String 44 | * } 45 | * 46 | * type Droid implements Character { 47 | * id: String! 48 | * name: String 49 | * friends: [Character] 50 | * appearsIn: [Episode] 51 | * primaryFunction: String 52 | * } 53 | * 54 | * type Query { 55 | * hero(episode: Episode): Character 56 | * human(id: String!): Human 57 | * droid(id: String!): Droid 58 | * } 59 | * ``` 60 | * 61 | * We begin by setting up our schema. 62 | */ 63 | 64 | /** 65 | * The original trilogy consists of three movies. 66 | * 67 | * This implements the following type system shorthand: 68 | * ```graphql 69 | * enum Episode { NEW_HOPE, EMPIRE, JEDI } 70 | * ``` 71 | */ 72 | const episodeEnum = new GraphQLEnumType({ 73 | name: 'Episode', 74 | description: 'One of the films in the Star Wars Trilogy', 75 | values: { 76 | NEW_HOPE: { 77 | value: 4, 78 | description: 'Released in 1977.', 79 | }, 80 | EMPIRE: { 81 | value: 5, 82 | description: 'Released in 1980.', 83 | }, 84 | JEDI: { 85 | value: 6, 86 | description: 'Released in 1983.', 87 | }, 88 | }, 89 | }); 90 | 91 | /** 92 | * Characters in the Star Wars trilogy are either humans or droids. 93 | * 94 | * This implements the following type system shorthand: 95 | * ```graphql 96 | * interface Character { 97 | * id: String! 98 | * name: String 99 | * friends: [Character] 100 | * appearsIn: [Episode] 101 | * secretBackstory: String 102 | * } 103 | * ``` 104 | */ 105 | const characterInterface: GraphQLInterfaceType = new GraphQLInterfaceType({ 106 | name: 'Character', 107 | description: 'A character in the Star Wars Trilogy', 108 | fields: () => ({ 109 | id: { 110 | type: new GraphQLNonNull(GraphQLString), 111 | description: 'The id of the character.', 112 | }, 113 | name: { 114 | type: GraphQLString, 115 | description: 'The name of the character.', 116 | }, 117 | friends: { 118 | type: new GraphQLList(characterInterface), 119 | description: 120 | 'The friends of the character, or an empty list if they have none.', 121 | }, 122 | appearsIn: { 123 | type: new GraphQLList(episodeEnum), 124 | description: 'Which movies they appear in.', 125 | }, 126 | secretBackstory: { 127 | type: GraphQLString, 128 | description: 'All secrets about their past.', 129 | }, 130 | }), 131 | resolveType(character) { 132 | switch (character.type) { 133 | case 'Human': 134 | return humanType.name; 135 | case 'Droid': 136 | return droidType.name; 137 | } 138 | }, 139 | }); 140 | 141 | /** 142 | * We define our human type, which implements the character interface. 143 | * 144 | * This implements the following type system shorthand: 145 | * ```graphql 146 | * type Human : Character { 147 | * id: String! 148 | * name: String 149 | * friends: [Character] 150 | * appearsIn: [Episode] 151 | * secretBackstory: String 152 | * } 153 | * ``` 154 | */ 155 | const humanType = new GraphQLObjectType({ 156 | name: 'Human', 157 | description: 'A humanoid creature in the Star Wars universe.', 158 | fields: () => ({ 159 | id: { 160 | type: new GraphQLNonNull(GraphQLString), 161 | description: 'The id of the human.', 162 | }, 163 | name: { 164 | type: GraphQLString, 165 | description: 'The name of the human.', 166 | }, 167 | friends: { 168 | type: new GraphQLList(characterInterface), 169 | description: 170 | 'The friends of the human, or an empty list if they have none.', 171 | resolve: (human) => getFriends(human), 172 | }, 173 | appearsIn: { 174 | type: new GraphQLList(episodeEnum), 175 | description: 'Which movies they appear in.', 176 | }, 177 | homePlanet: { 178 | type: GraphQLString, 179 | description: 'The home planet of the human, or null if unknown.', 180 | }, 181 | secretBackstory: { 182 | type: GraphQLString, 183 | description: 'Where are they from and how they came to be who they are.', 184 | resolve() { 185 | throw new Error('secretBackstory is secret.'); 186 | }, 187 | }, 188 | }), 189 | interfaces: [characterInterface], 190 | }); 191 | 192 | /** 193 | * The other type of character in Star Wars is a droid. 194 | * 195 | * This implements the following type system shorthand: 196 | * ```graphql 197 | * type Droid : Character { 198 | * id: String! 199 | * name: String 200 | * friends: [Character] 201 | * appearsIn: [Episode] 202 | * secretBackstory: String 203 | * primaryFunction: String 204 | * } 205 | * ``` 206 | */ 207 | const droidType = new GraphQLObjectType({ 208 | name: 'Droid', 209 | description: 'A mechanical creature in the Star Wars universe.', 210 | fields: () => ({ 211 | id: { 212 | type: new GraphQLNonNull(GraphQLString), 213 | description: 'The id of the droid.', 214 | }, 215 | name: { 216 | type: GraphQLString, 217 | description: 'The name of the droid.', 218 | }, 219 | friends: { 220 | type: new GraphQLList(characterInterface), 221 | description: 222 | 'The friends of the droid, or an empty list if they have none.', 223 | resolve: (droid) => getFriends(droid), 224 | }, 225 | appearsIn: { 226 | type: new GraphQLList(episodeEnum), 227 | description: 'Which movies they appear in.', 228 | }, 229 | secretBackstory: { 230 | type: GraphQLString, 231 | description: 'Construction date and the name of the designer.', 232 | resolve() { 233 | throw new Error('secretBackstory is secret.'); 234 | }, 235 | }, 236 | primaryFunction: { 237 | type: GraphQLString, 238 | description: 'The primary function of the droid.', 239 | }, 240 | }), 241 | interfaces: [characterInterface], 242 | }); 243 | 244 | /** 245 | * This is the type that will be the root of our query, and the 246 | * entry point into our schema. It gives us the ability to fetch 247 | * objects by their IDs, as well as to fetch the undisputed hero 248 | * of the Star Wars trilogy, R2-D2, directly. 249 | * 250 | * This implements the following type system shorthand: 251 | * ```graphql 252 | * type Query { 253 | * hero(episode: Episode): Character 254 | * human(id: String!): Human 255 | * droid(id: String!): Droid 256 | * } 257 | * ``` 258 | */ 259 | const queryType = new GraphQLObjectType({ 260 | name: 'Query', 261 | fields: () => ({ 262 | hero: { 263 | type: characterInterface, 264 | args: { 265 | episode: { 266 | description: 267 | 'If omitted, returns the hero of the whole saga. If provided, returns the hero of that particular episode.', 268 | type: episodeEnum, 269 | }, 270 | }, 271 | resolve: (_source, { episode }) => getHero(episode), 272 | }, 273 | human: { 274 | type: humanType, 275 | args: { 276 | id: { 277 | description: 'id of the human', 278 | type: new GraphQLNonNull(GraphQLString), 279 | }, 280 | }, 281 | resolve: (_source, { id }) => getHuman(id), 282 | }, 283 | droid: { 284 | type: droidType, 285 | args: { 286 | id: { 287 | description: 'id of the droid', 288 | type: new GraphQLNonNull(GraphQLString), 289 | }, 290 | }, 291 | resolve: (_source, { id }) => getDroid(id), 292 | }, 293 | }), 294 | }); 295 | 296 | /** 297 | * Finally, we construct our schema (whose starting query type is the query 298 | * type we defined above) and export it. 299 | */ 300 | export const StarWarsSchema: GraphQLSchema = new GraphQLSchema({ 301 | query: queryType, 302 | types: [humanType, droidType], 303 | }); -------------------------------------------------------------------------------- /src/starWarsData.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * These are types which correspond to the schema. 3 | * They represent the shape of the data visited during field resolution. 4 | */ 5 | export interface Character { 6 | id: string; 7 | name: string; 8 | friends: ReadonlyArray; 9 | appearsIn: ReadonlyArray; 10 | } 11 | 12 | export interface Human { 13 | type: 'Human'; 14 | id: string; 15 | name: string; 16 | friends: ReadonlyArray; 17 | appearsIn: ReadonlyArray; 18 | homePlanet?: string; 19 | } 20 | 21 | export interface Droid { 22 | type: 'Droid'; 23 | id: string; 24 | name: string; 25 | friends: ReadonlyArray; 26 | appearsIn: ReadonlyArray; 27 | primaryFunction: string; 28 | } 29 | 30 | /** 31 | * This defines a basic set of data for our Star Wars Schema. 32 | * 33 | * This data is hard coded for the sake of the demo, but you could imagine 34 | * fetching this data from a backend service rather than from hardcoded 35 | * JSON objects in a more complex demo. 36 | */ 37 | 38 | const luke: Human = { 39 | type: 'Human', 40 | id: '1000', 41 | name: 'Luke Skywalker', 42 | friends: ['1002', '1003', '2000', '2001'], 43 | appearsIn: [4, 5, 6], 44 | homePlanet: 'Tatooine', 45 | }; 46 | 47 | const vader: Human = { 48 | type: 'Human', 49 | id: '1001', 50 | name: 'Darth Vader', 51 | friends: ['1004'], 52 | appearsIn: [4, 5, 6], 53 | homePlanet: 'Tatooine', 54 | }; 55 | 56 | const han: Human = { 57 | type: 'Human', 58 | id: '1002', 59 | name: 'Han Solo', 60 | friends: ['1000', '1003', '2001'], 61 | appearsIn: [4, 5, 6], 62 | }; 63 | 64 | const leia: Human = { 65 | type: 'Human', 66 | id: '1003', 67 | name: 'Leia Organa', 68 | friends: ['1000', '1002', '2000', '2001'], 69 | appearsIn: [4, 5, 6], 70 | homePlanet: 'Alderaan', 71 | }; 72 | 73 | const tarkin: Human = { 74 | type: 'Human', 75 | id: '1004', 76 | name: 'Wilhuff Tarkin', 77 | friends: ['1001'], 78 | appearsIn: [4], 79 | }; 80 | 81 | const humanData: { [id: string]: Human } = { 82 | [luke.id]: luke, 83 | [vader.id]: vader, 84 | [han.id]: han, 85 | [leia.id]: leia, 86 | [tarkin.id]: tarkin, 87 | }; 88 | 89 | const threepio: Droid = { 90 | type: 'Droid', 91 | id: '2000', 92 | name: 'C-3PO', 93 | friends: ['1000', '1002', '1003', '2001'], 94 | appearsIn: [4, 5, 6], 95 | primaryFunction: 'Protocol', 96 | }; 97 | 98 | const artoo: Droid = { 99 | type: 'Droid', 100 | id: '2001', 101 | name: 'R2-D2', 102 | friends: ['1000', '1002', '1003'], 103 | appearsIn: [4, 5, 6], 104 | primaryFunction: 'Astromech', 105 | }; 106 | 107 | const droidData: { [id: string]: Droid } = { 108 | [threepio.id]: threepio, 109 | [artoo.id]: artoo, 110 | }; 111 | 112 | /** 113 | * Helper function to get a character by ID. 114 | */ 115 | function getCharacter(id: string): Promise { 116 | // Returning a promise just to illustrate that GraphQL.js supports it. 117 | return Promise.resolve(humanData[id] ?? droidData[id]); 118 | } 119 | 120 | /** 121 | * Allows us to query for a character's friends. 122 | */ 123 | export function getFriends( 124 | character: Character, 125 | ): Array> { 126 | // Notice that GraphQL accepts Arrays of Promises. 127 | return character.friends.map((id) => getCharacter(id)); 128 | } 129 | 130 | /** 131 | * Allows us to fetch the undisputed hero of the Star Wars trilogy, R2-D2. 132 | */ 133 | export function getHero(episode: number): Character { 134 | if (episode === 5) { 135 | // Luke is the hero of Episode V. 136 | return luke; 137 | } 138 | // Artoo is the hero otherwise. 139 | return artoo; 140 | } 141 | 142 | /** 143 | * Allows us to query for the human with the given id. 144 | */ 145 | export function getHuman(id: string): Human | null { 146 | return humanData[id]; 147 | } 148 | 149 | /** 150 | * Allows us to query for the droid with the given id. 151 | */ 152 | export function getDroid(id: string): Droid | null { 153 | return droidData[id]; 154 | } 155 | 156 | -------------------------------------------------------------------------------- /src/starWarsDataResolver.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * These are types which correspond to the schema. 3 | * They represent the shape of the data visited during field resolution. 4 | */ 5 | export interface Character { 6 | id: string; 7 | name: string; 8 | friends: ReadonlyArray; 9 | appearsIn: ReadonlyArray; 10 | } 11 | 12 | export interface Human { 13 | type: 'Human'; 14 | id: string; 15 | name: string; 16 | friends: ReadonlyArray; 17 | appearsIn: ReadonlyArray; 18 | homePlanet?: string; 19 | } 20 | 21 | export interface Droid { 22 | type: 'Droid'; 23 | id: string; 24 | name: string; 25 | friends: ReadonlyArray; 26 | appearsIn: ReadonlyArray; 27 | primaryFunction: string; 28 | } 29 | 30 | /** 31 | * This defines a basic set of data for our Star Wars Schema. 32 | * 33 | * This data is hard coded for the sake of the demo, but you could imagine 34 | * fetching this data from a backend service rather than from hardcoded 35 | * JSON objects in a more complex demo. 36 | */ 37 | 38 | const luke: Human = { 39 | type: 'Human', 40 | id: '1000', 41 | name: 'Luke Skywalker', 42 | friends: ['1002', '1003', '2000', '2001'], 43 | appearsIn: [4, 5, 6], 44 | homePlanet: 'Tatooine', 45 | }; 46 | 47 | const vader: Human = { 48 | type: 'Human', 49 | id: '1001', 50 | name: 'Darth Vader', 51 | friends: ['1004'], 52 | appearsIn: [4, 5, 6], 53 | homePlanet: 'Tatooine', 54 | }; 55 | 56 | const han: Human = { 57 | type: 'Human', 58 | id: '1002', 59 | name: 'Han Solo', 60 | friends: ['1000', '1003', '2001'], 61 | appearsIn: [4, 5, 6], 62 | }; 63 | 64 | const leia: Human = { 65 | type: 'Human', 66 | id: '1003', 67 | name: 'Leia Organa', 68 | friends: ['1000', '1002', '2000', '2001'], 69 | appearsIn: [4, 5, 6], 70 | homePlanet: 'Alderaan', 71 | }; 72 | 73 | const tarkin: Human = { 74 | type: 'Human', 75 | id: '1004', 76 | name: 'Wilhuff Tarkin', 77 | friends: ['1001'], 78 | appearsIn: [4], 79 | }; 80 | 81 | const humanData: { [id: string]: Human } = { 82 | [luke.id]: luke, 83 | [vader.id]: vader, 84 | [han.id]: han, 85 | [leia.id]: leia, 86 | [tarkin.id]: tarkin, 87 | }; 88 | 89 | const threepio: Droid = { 90 | type: 'Droid', 91 | id: '2000', 92 | name: 'C-3PO', 93 | friends: ['1000', '1002', '1003', '2001'], 94 | appearsIn: [4, 5, 6], 95 | primaryFunction: 'Protocol', 96 | }; 97 | 98 | const artoo: Droid = { 99 | type: 'Droid', 100 | id: '2001', 101 | name: 'R2-D2', 102 | friends: ['1000', '1002', '1003'], 103 | appearsIn: [4, 5, 6], 104 | primaryFunction: 'Astromech', 105 | }; 106 | 107 | const droidData: { [id: string]: Droid } = { 108 | [threepio.id]: threepio, 109 | [artoo.id]: artoo, 110 | }; 111 | 112 | /** 113 | * Helper function to get a character by ID. 114 | */ 115 | function getCharacter(id: string): Promise { 116 | // Returning a promise just to illustrate that GraphQL.js supports it. 117 | return Promise.resolve(humanData[id] ?? droidData[id]); 118 | } 119 | 120 | /** 121 | * Allows us to query for a character's friends. 122 | */ 123 | export function getFriends( 124 | character: Character, 125 | ): Array> { 126 | // Notice that GraphQL accepts Arrays of Promises. 127 | return character.friends.map((id) => getCharacter(id)); 128 | } 129 | 130 | /** 131 | * Allows us to fetch the undisputed hero of the Star Wars trilogy, R2-D2. 132 | */ 133 | export function getHero(episode: number): Character { 134 | if (episode === 5) { 135 | // Luke is the hero of Episode V. 136 | return luke; 137 | } 138 | // Artoo is the hero otherwise. 139 | return artoo; 140 | } 141 | 142 | /** 143 | * Allows us to query for the human with the given id. 144 | */ 145 | export function getHuman(args: any): Human | null { 146 | const id = args.id 147 | return humanData[id]; 148 | } 149 | 150 | /** 151 | * Allows us to query for the droid with the given id. 152 | */ 153 | export function getDroid(id: string): Droid | null { 154 | return droidData[id]; 155 | } 156 | 157 | export function updateHuman(args: any): Human | null { 158 | const id = args.id 159 | humanData[id][args.prop] = args.update; 160 | return humanData[id] 161 | } -------------------------------------------------------------------------------- /ts.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions" : { 3 | "target" : "es5", 4 | "type" : ["node", "jest"], 5 | "module": "commonjs", 6 | "declaration" : "true", 7 | "strict": true, 8 | "moduleResolution" : "node" 9 | }, 10 | "include": ["src"], 11 | "exclude": ["node_modules", "**/__tests__/*"] 12 | } --------------------------------------------------------------------------------