├── .babelrc
├── .env
├── .gitignore
├── LICENSE
├── README.md
├── dist
├── bundle.js
├── bundle.js.LICENSE.txt
└── index.html
├── index.js
└── package.json
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-react"
5 | ]
6 | }
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | NODE_ENV = 'development'
2 | PORT = 4000
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | package-lock.json
3 | .npm
4 | .DS-Store
5 |
--------------------------------------------------------------------------------
/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 | # QLutch
2 |
3 | 
4 |
5 |
6 |
7 | A lightweight caching solution for graphQL APIs that interfaces with Redis for high-speed data retrieval, combined with performance visualization.
8 |
9 | 
10 | 
11 | 
12 | 
13 | 
14 | 
15 | 
16 | 
17 | 
18 | ____
19 | # Features
20 | - Redis cache integration for graphQL queries and *Create* mutations.
21 | - Performance monitor.
22 |
23 | 
24 |
25 | ## Dashboard Visualizer
26 | 
27 |
28 | # Usage Notes
29 | - Caching support for Update and Delete mutations is not yet implemented.
30 |
31 | # Installation
32 | - User creates application and installs qlutch dependency via [npm](https://www.npmjs.com/package/qlutch) (npm install qlutch)
33 | - Set up Redis database in application
34 | - Require qlutch and Redis in server file
35 | - For the dashboard visualizer, add express static path to node modules:
36 | 
37 | - For the dashboard visualizer, add a dashboard endpoint with a path to the qlutch dist index file:
38 | 
39 | - Need two endpoints – one for qlutch and one for graphql. Install qlutch as middleware in qlutch endpoint – pass in “graphql” endpoint and redis instance as arguments. User would need to return res.locals.response:
40 | 
41 | - Fetch requests on frontend will need to be made to /qlutch endpoint
42 |
43 | # Authors
44 | - [@Michael-Weckop](https://github.com/Michael-Weckop)
45 | - [@lrod8](https://github.com/lrod8)
46 | - [@alroro](https://github.com/alroro)
47 | - [@Reneeto](https://github.com/Reneeto)
48 | # Acknowledgements
49 | - [Charlie Charboneau](https://github.com/CharlieCharboneau)
50 | - [Annie Blazejack](https://github.com/annieblazejack)
51 | - [Matt Severyn](https://github.com/mtseveryn)
52 | - [Erika Collins Reynolds](https://github.com/erikacollinsreynolds)
53 | - [Sam Arnold](https://github.com/sam-a723)
54 |
--------------------------------------------------------------------------------
/dist/bundle.js.LICENSE.txt:
--------------------------------------------------------------------------------
1 | /*!
2 | * ApexCharts v3.42.0
3 | * (c) 2018-2023 ApexCharts
4 | * Released under the MIT License.
5 | */
6 |
7 | /*!
8 | * bytes
9 | * Copyright(c) 2012-2014 TJ Holowaychuk
10 | * Copyright(c) 2015 Jed Watson
11 | * MIT Licensed
12 | */
13 |
14 | /*! svg.draggable.js - v2.2.2 - 2019-01-08
15 | * https://github.com/svgdotjs/svg.draggable.js
16 | * Copyright (c) 2019 Wout Fierens; Licensed MIT */
17 |
18 | /*! svg.filter.js - v2.0.2 - 2016-02-24
19 | * https://github.com/wout/svg.filter.js
20 | * Copyright (c) 2016 Wout Fierens; Licensed MIT */
21 |
22 | /**
23 | * @license React
24 | * react-dom.production.min.js
25 | *
26 | * Copyright (c) Facebook, Inc. and its affiliates.
27 | *
28 | * This source code is licensed under the MIT license found in the
29 | * LICENSE file in the root directory of this source tree.
30 | */
31 |
32 | /**
33 | * @license React
34 | * react.production.min.js
35 | *
36 | * Copyright (c) Facebook, Inc. and its affiliates.
37 | *
38 | * This source code is licensed under the MIT license found in the
39 | * LICENSE file in the root directory of this source tree.
40 | */
41 |
42 | /**
43 | * @license React
44 | * scheduler.production.min.js
45 | *
46 | * Copyright (c) Facebook, Inc. and its affiliates.
47 | *
48 | * This source code is licensed under the MIT license found in the
49 | * LICENSE file in the root directory of this source tree.
50 | */
51 |
--------------------------------------------------------------------------------
/dist/index.html:
--------------------------------------------------------------------------------
1 |
Qlutch
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const { request, gql } = require("graphql-request");
2 | const { visit } = require("graphql");
3 | const { parse } = require("graphql/language");
4 |
5 | module.exports = function (graphQlPath, redis) {
6 | return async function (req, res, next) {
7 | try {
8 | //parse query from frontend
9 | const parsedQuery = parse(req.body.query);
10 |
11 | /*USE INTROSPECTION TO IDENTIFY SCHEMA TYPES*/
12 |
13 | // array to store all query types
14 | const typesArr = [];
15 |
16 | // excluded typenames that are automatically returned by introspection
17 | const excludedTypeNames = [
18 | "Query",
19 | "String",
20 | "Int",
21 | "Boolean",
22 | "__Schema",
23 | "__Type",
24 | "__TypeKind",
25 | "__Field",
26 | "__EnumValue",
27 | "__Directive",
28 | "__DirectiveLocation",
29 | ];
30 |
31 | //using introspection query to find all types in schema
32 | let schemaTypes = await request(
33 | `${graphQlPath}`,
34 | `{
35 | __schema{
36 | types{
37 | name
38 | fields {
39 | name
40 | type{
41 | name
42 | ofType {
43 | name
44 | }
45 | }
46 | }
47 | }
48 | }
49 | }`
50 | );
51 |
52 | // parsing through types with args and storing them in typesArr
53 | schemaTypes.__schema.types.forEach((type) => {
54 | if (type.name === "Query" || type.name === "Mutation") {
55 | type.fields.forEach((type) => {
56 | typesArr.push(type.name);
57 | });
58 | }
59 | });
60 |
61 | // parsing through schema types to idenitfy parent types of each field
62 | schemaTypes.__schema.types.forEach((type) => {
63 | // check if current type name is inside excluded typeArr
64 | if (!excludedTypeNames.includes(type.name)) {
65 | // if not in typeArr iterate through current field
66 | if (type.fields) {
67 | type.fields.forEach((field) => {
68 | // if ofType field is truthy && it's a string && it's included in typeArr
69 | if (
70 | field.type.ofType &&
71 | typeof field.type.ofType.name === "string" &&
72 | typesArr.includes(field.type.ofType.name.toLowerCase())
73 | ) {
74 | typesArr.push(field.name);
75 | }
76 | });
77 | }
78 | }
79 | });
80 |
81 | /*FIND TYPES IN CURRENT QUERY/MUTATION*/
82 |
83 | //checks parsedQuery for types used in query
84 | const findAllTypes = (query, types) => {
85 | const valuesObj = {};
86 |
87 | //helper function to traverse deeply nested query
88 | const traverseParsedQuery = (currentObj, visited) => {
89 | visited.add(currentObj);
90 |
91 | for (let key in currentObj) {
92 | const value = currentObj[key];
93 | //this is where the actual query types are getting stored in valuesObj
94 | if (types.includes(value)) {
95 | valuesObj[value] = value;
96 | }
97 | //if current value is an array, iterates through array to find more objects to traverse, or recursively calls traverseParsedQuery if an object is found
98 | if (Array.isArray(value)) {
99 | value.forEach((el) => {
100 | if (typeof el === "object" && !visited.has(el)) {
101 | traverseParsedQuery(el, visited);
102 | }
103 | });
104 | } else if (typeof value === "object" && !visited.has(value)) {
105 | traverseParsedQuery(value, visited);
106 | }
107 | }
108 | };
109 |
110 | traverseParsedQuery(query, new Set());
111 | return valuesObj;
112 | };
113 |
114 | //returns object with all types found in query
115 | const valuesObj = findAllTypes(parsedQuery, typesArr);
116 |
117 | //the actual query types being used in the query
118 | const valuesArr = Object.values(valuesObj);
119 |
120 | // var to store the parent type for mutations
121 | let mutationQueryType;
122 |
123 | //finds query type associated with mutation
124 | const findMutationQueryType = (introspection, parsedQuery) => {
125 | const types = introspection.__schema.types;
126 |
127 | types.forEach((type) => {
128 | if (type.name === "Mutation") {
129 | const mutations = type.fields;
130 | mutations.forEach((mutation) => {
131 | if (valuesArr.includes(mutation.name)) {
132 | mutationQueryType = mutation.type.name.toLowerCase();
133 | }
134 | });
135 | }
136 | });
137 | return mutationQueryType;
138 | };
139 |
140 | /* VISITOR FUNCTION */
141 |
142 | // var to store current operation
143 | let operation = "";
144 |
145 | // var to store root field including args
146 | let rootField;
147 |
148 | // create a var to store an object of arrays
149 | const keysToCache = [];
150 |
151 | // var to store id of current query/mutation
152 | let id;
153 |
154 | // visitor object for arguments is called from field method
155 | const argVisitor = {
156 | Argument: (node) => {
157 | if (node.value.kind === "StringValue") {
158 | return `(${node.name.value}:"${node.value.value}")`;
159 | } else return `(${node.name.value}:${node.value.value})`;
160 | },
161 | };
162 |
163 | //var to store parent field in current mutation
164 | let mutationRootField;
165 |
166 | //similar to keysToCache, but instead of the query type, it includes the mutation type
167 | const mutationForGQLResponse = [];
168 |
169 | // main visitor object that builds out rootfield and mutationRootField array
170 | const visitor = {
171 | // return current operation
172 | OperationDefinition: (node) => {
173 | operation = node.operation;
174 | },
175 | // returns each query field
176 | Field: (node) => {
177 | // create a var to store an current field name
178 | const currentField = node.name.value;
179 |
180 | // check if field is in typesArr
181 | if (valuesArr.includes(currentField)) {
182 | if (operation === "mutation" && !rootField) {
183 | rootField = findMutationQueryType(schemaTypes, parsedQuery);
184 | mutationRootField = currentField;
185 |
186 | //sends arguments node to visitor function
187 | const args = visit(node, argVisitor);
188 | const arguments = args.arguments.map((arg) => {
189 | if (arg.includes("id:")) id = arg;
190 | return arg;
191 | });
192 |
193 | //concats id to rootField and mutationRootField - this helps normalize cache data
194 | rootField = rootField.concat(id);
195 | mutationRootField = mutationRootField.concat(id);
196 | } else if (currentField === valuesArr[0]) {
197 | // reassign root var with root field of first element in typesArr with arguments from visiotr function if any
198 | rootField = currentField;
199 | // check if there are args on current node and if so call argument visitor method
200 | const args = visit(node, argVisitor);
201 | // add to main root
202 | const arguments = args.arguments.map((arg) => {
203 | if (arg.includes("id:")) id = arg;
204 | return arg;
205 | });
206 | rootField = rootField.concat(id);
207 | } else {
208 | // else re-assign currentType to current type
209 | let currentType = node.name.value;
210 | if (operation === "mutation") {
211 | mutationRootField = mutationRootField.concat(`{${currentType}`);
212 | }
213 | rootField = rootField.concat(`{${currentType}`);
214 | }
215 | } else {
216 | // else add each field to root value and build out object
217 | if (operation === "mutation") {
218 | mutationForGQLResponse.push(
219 | mutationRootField.concat(`{${node.name.value}}`)
220 | );
221 | }
222 | keysToCache.push(rootField.concat(`{${node.name.value}}`));
223 | }
224 | },
225 | };
226 |
227 | //invokes initial visit function
228 | visit(parsedQuery, visitor);
229 |
230 | /* GETS RESPONSE DATA FROM CACHE OR GQL AND BUILDS OUT RESPONSE TO SEND TO FRONT END */
231 |
232 | //combines data found in cache and data requested from database - called in GQLResponse
233 | function combineCacheAndResponseData(...objects) {
234 | return objects.reduce((merged, obj) => {
235 | for (const key in obj) {
236 | if (obj.hasOwnProperty(key)) {
237 | if (typeof obj[key] === "object" && !Array.isArray(obj[key])) {
238 | // if the property is an object, recursively merge it
239 | merged[key] = combineCacheAndResponseData(
240 | merged[key] || {},
241 | obj[key]
242 | );
243 | } else if (Array.isArray(obj[key])) {
244 | // if the property is an array, handle each element
245 | if (!merged[key]) {
246 | merged[key] = [];
247 | }
248 | obj[key].forEach((el, index) => {
249 | //check if the element is an object and merge it if needed
250 | if (typeof el === "object" && !Array.isArray(el)) {
251 | merged[key][index] = combineCacheAndResponseData(
252 | merged[key][index] || {},
253 | el
254 | );
255 | } else {
256 | merged[key][index] = el;
257 | }
258 | });
259 | } else {
260 | // otherwise, assign the value
261 | merged[key] = obj[key];
262 | }
263 | }
264 | }
265 | return merged;
266 | }, {});
267 | }
268 |
269 | //check redis if key is stored and return value - called in createResponse
270 | async function checkCache(key) {
271 | try {
272 | const cachedData = JSON.parse(await redis.get(key));
273 | return cachedData;
274 | } catch (err) {
275 | const errObj = {
276 | log: "error in checking cache",
277 | status: 400,
278 | message: "Invalid request",
279 | };
280 | return next(err, errObj);
281 | }
282 | }
283 |
284 | //checks if data is in cache or if it needs to be requested in gql
285 | async function createResponse() {
286 | try {
287 | //array to store non-cached keys that need to be sent to gql to request response - used in createResponse and GQLResponse
288 | const keysToRequestArr = [];
289 |
290 | // array to store cached keys - used in createResponse and GQLResponse
291 | const responseToMergeArr = [];
292 |
293 | //checks cache to see if data exists already in cache
294 | const checkDataIsCachedArr = keysToCache.map((key) =>
295 | checkCache(key)
296 | );
297 |
298 | //array with whatever data is found in the cache
299 | const response = await Promise.all(checkDataIsCachedArr);
300 |
301 | // iterates through response array and checks for values to be push to responseToMerge or keyToRequest
302 | for (let i = 0; i < response.length; i++) {
303 | if (response[i] === null) {
304 | keysToRequestArr.push(keysToCache[i]);
305 | } else {
306 | responseToMergeArr.push(response[i]);
307 | }
308 | }
309 |
310 | // getResponseAndCache writes a query and requests each field from gql, then caches responses - called in GQLResponse
311 | async function getResponseAndCache(key) {
312 | try {
313 | // create graphql query
314 | let parsedGraphQLQuery;
315 |
316 | if (operation === "mutation") {
317 | parsedGraphQLQuery = `mutation {`;
318 | } else {
319 | parsedGraphQLQuery = `query {`;
320 | }
321 |
322 | // creates a new gql object
323 | let curlyBracesCount = 0;
324 |
325 | key.split("").forEach((char) => {
326 | if (char === "{") curlyBracesCount++;
327 |
328 | parsedGraphQLQuery = parsedGraphQLQuery.concat(char);
329 | });
330 |
331 | parsedGraphQLQuery = parsedGraphQLQuery.concat(
332 | "}".repeat(curlyBracesCount)
333 | );
334 |
335 | // request new query to graphQL
336 | let document;
337 |
338 | if (operation === "mutation") {
339 | document = gql`
340 | ${req.body.query}
341 | `;
342 | } else {
343 | document = gql`
344 | ${parsedGraphQLQuery}
345 | `;
346 | }
347 |
348 | //requests, caches and returns response
349 | let gqlResponse = await request(`${graphQlPath}`, document);
350 | redis.set(key, JSON.stringify(gqlResponse));
351 | return gqlResponse;
352 | } catch (err) {
353 | const errObj = {
354 | log: "error in getResponse",
355 | status: 400,
356 | message: "error in getResponse",
357 | };
358 | return next(err, errObj);
359 | }
360 | }
361 |
362 | //ensures we cache nested mutation data
363 | let arrayInMutation = null;
364 |
365 | //iterates through mutation data from gql and builds out objects to add to cache - called in GQLResponse
366 | async function cacheMutations(keysToCache, response) {
367 | try {
368 | for (const key in response) {
369 | if (
370 | typeof response[key] === "object" &&
371 | !Array.isArray(response[key])
372 | ) {
373 | cacheMutations(keysToCache, response[key]);
374 | } else if (Array.isArray(response[key])) {
375 | arrayInMutation = key;
376 | response[key].forEach((el) => {
377 | if (typeof el === "object" && !Array.isArray(el)) {
378 | cacheMutations(keysToCache, response[key]);
379 | }
380 | });
381 | } else {
382 | for (let i = 0; i < keysToCache.length; i++) {
383 | if (keysToCache[i].includes(key)) {
384 | let mutationResponse;
385 | //builds out properly formatted response object to cache
386 | if (arrayInMutation) {
387 | mutationResponse = {
388 | [mutationQueryType]: {
389 | [arrayInMutation]: [{ [key]: response[key] }],
390 | },
391 | };
392 | } else {
393 | mutationResponse = {
394 | [mutationQueryType]: {
395 | [key]: response[key],
396 | },
397 | };
398 | }
399 | //sets mutation data in redis
400 | redis.set(
401 | keysToCache[i],
402 | JSON.stringify(mutationResponse)
403 | );
404 | }
405 | }
406 | }
407 | }
408 | } catch (err) {
409 | const errObj = {
410 | log: "error in cacheMutations",
411 | status: 400,
412 | message: "error in cacheMutations",
413 | };
414 | return next(err, errObj);
415 | }
416 | }
417 |
418 | // requests response from gql and calls combineCacheAndResponseData to return merged object to sendResponse - called in createResponse
419 | async function GQLResponse() {
420 | if (operation === "mutation") {
421 | document = gql`
422 | ${req.body.query}
423 | `;
424 | let gqlResponse = await request(`${graphQlPath}`, document);
425 |
426 | cacheMutations(keysToCache, gqlResponse);
427 |
428 | sendResponse(gqlResponse);
429 | } else {
430 | const mergeArr = keysToRequestArr.map(
431 | async (key) => await getResponseAndCache(key)
432 | );
433 |
434 | const toBeMerged = await Promise.all(mergeArr);
435 | sendResponse(
436 | combineCacheAndResponseData(
437 | ...toBeMerged,
438 | ...responseToMergeArr
439 | )
440 | );
441 | }
442 | }
443 |
444 | //sends response to front end
445 | async function sendResponse(resObj) {
446 | const dataToReturn = {
447 | data: {},
448 | };
449 | dataToReturn.data = resObj;
450 | res.locals.response = dataToReturn;
451 | return next();
452 | }
453 |
454 | GQLResponse();
455 | } catch (err) {
456 | const errObj = {
457 | log: "sendResponse error",
458 | status: 400,
459 | message: "Invalid response",
460 | };
461 | return next(err, errObj);
462 | }
463 | }
464 |
465 | createResponse();
466 | } catch (err) {
467 | const errObj = {
468 | log: "QLutch error",
469 | status: 400,
470 | message: "Invalid request",
471 | };
472 | return next(err, errObj);
473 | }
474 | };
475 | };
476 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "qlutch",
3 | "description": "QLutch is a lightweight, server-side caching solution for GraphQL using Redis",
4 | "version": "1.0.4",
5 | "main": "index.js",
6 | "scripts": {
7 | "build": "webpack --mode production",
8 | "dev": "concurrently \"webpack server --open\" \"nodemon src/server/server.js\"",
9 | "start": "node server/server.js",
10 | "test": "echo \"Error: no test specified\" && exit 1"
11 | },
12 | "keywords": [
13 | "GraphQL",
14 | "graphql",
15 | "Redis",
16 | "redis",
17 | "cache",
18 | "caching",
19 | "qlutch",
20 | "QLutch"
21 | ],
22 | "author": "Renee Toscan, Allan Ross, Lisa Rodrigues, Michael Weckop",
23 | "license": "ISC",
24 | "dependencies": {
25 | "bytes": "^3.1.2",
26 | "concurrently": "^8.2.1",
27 | "cors": "^2.8.5",
28 | "express": "^4.18.2",
29 | "express-graphql": "^0.12.0",
30 | "graphql": "^15.8.0",
31 | "graphql-request": "^6.1.0",
32 | "mongoose": "^7.6.3",
33 | "node-fetch": "^2.7.0",
34 | "react": "^18.2.0",
35 | "react-apexcharts": "^1.4.1",
36 | "react-dom": "^18.2.0",
37 | "redis": "^4.6.8",
38 | "webpack-dev-server": "^4.15.1"
39 | },
40 | "devDependencies": {
41 | "@babel/core": "^7.22.11",
42 | "@babel/preset-env": "^7.22.14",
43 | "@babel/preset-react": "^7.22.5",
44 | "babel-loader": "^9.1.3",
45 | "css-loader": "^6.8.1",
46 | "dotenv": "^16.3.1",
47 | "file-loader": "^6.2.0",
48 | "html-webpack-plugin": "^5.5.3",
49 | "node-sass": "^9.0.0",
50 | "nodemon": "^3.0.1",
51 | "sass-loader": "^13.3.2",
52 | "style-loader": "^3.3.3",
53 | "webpack": "^5.88.2",
54 | "webpack-cli": "^5.1.4"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------