├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── Quell-B2.png ├── QuellDemo-Client-Side-Demo.gif ├── QuellDemo-Client-Side.gif ├── QuellDemo-Costly.gif ├── favicon-16x16.png ├── favicon-32x32.png ├── mstile-150x150.png └── updatedQuell-diagram.png ├── pull_request_template.md ├── quell-client ├── .gitignore ├── README.md ├── __tests__ │ ├── quellify.test.d.ts │ ├── quellify.test.js │ └── quellify.test.ts ├── eslintrc.json ├── package.json ├── src │ ├── Quellify.ts │ ├── helpers │ │ └── determineType.ts │ └── types.ts └── tsconfig.json ├── quell-extension ├── .babelrc ├── .gitignore ├── README.md ├── __mocks__ │ ├── fileTransformer.js │ └── styleMock.js ├── __tests__ │ └── extension.test.js ├── babel.config.js ├── dist │ ├── assets │ │ ├── icon128.png │ │ ├── icon16.png │ │ ├── icon32.png │ │ ├── icon48.png │ │ └── search.png │ ├── background.bundle.js │ ├── devtools.bundle.js │ ├── devtools.html │ ├── index.html │ ├── manifest.json │ ├── panel.bundle.js │ ├── panel.bundle.js.LICENSE.txt │ └── panel.html ├── package.json ├── src │ ├── manifest.json │ └── pages │ │ ├── Background │ │ ├── background.tsx │ │ └── index.tsx │ │ ├── Devtools │ │ ├── index.html │ │ └── index.tsx │ │ └── Panel │ │ ├── App.tsx │ │ ├── Components │ │ ├── CacheTab.tsx │ │ ├── CacheView.tsx │ │ ├── ClientTab.tsx │ │ ├── InputEditor.tsx │ │ ├── Metrics.tsx │ │ ├── NavButton.tsx │ │ ├── OutputEditor.tsx │ │ ├── PrimaryNavBar.tsx │ │ ├── QueryTab.tsx │ │ ├── ServerTab.tsx │ │ ├── Settings.tsx │ │ └── Visualizer │ │ │ ├── FlowTable.tsx │ │ │ ├── FlowTree.tsx │ │ │ ├── Visualizer.modules.css │ │ │ ├── Visualizer.modules.css.d.ts │ │ │ └── Visualizer.tsx │ │ ├── assets │ │ ├── icon128.png │ │ ├── icon16.png │ │ ├── icon32.png │ │ ├── icon48.png │ │ └── search.png │ │ ├── data │ │ └── sampleClientRequests.ts │ │ ├── global.scss │ │ ├── global.scss.d.ts │ │ ├── helpers │ │ ├── getResponseStatus.ts │ │ ├── isGQLQuery.ts │ │ ├── listeners.ts │ │ └── parseQuery.ts │ │ ├── index.d.tsx │ │ ├── index.html │ │ ├── index.tsx │ │ └── interfaces │ │ ├── ClientRequest.ts │ │ └── RedisInfo.ts ├── tsconfig.json └── webpack.config.js └── quell-server ├── .gitignore ├── README.md ├── __tests__ ├── helpers │ ├── buildFromCache.test.ts │ ├── createQueryObj.test.ts │ ├── createQueryStr.test.ts │ ├── getFieldsMap.test.ts │ ├── getMutationMap.test.ts │ ├── getQueryMap.test.ts │ ├── getRedisInfo.test.ts │ ├── joinResponses.test.ts │ ├── parseAST.test.ts │ └── updateProtoWithFragment.test.ts └── query.test.ts ├── eslintrc.json ├── jest.config.js ├── package.json ├── src ├── helpers │ ├── quellHelpers.ts │ ├── redisConnection.ts │ └── redisHelpers.ts ├── quell.ts └── types.ts ├── test-config ├── booksModel.ts ├── countriesModel.ts ├── test-data.ts ├── test-server.ts ├── testSchema.ts ├── testSchemaWithoutFields.ts ├── testSchemaWithoutMuts.ts └── testSchemaWithoutQueries.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .env 4 | .DS_Store 5 | .rdb 6 | quell-server/package-lock.json 7 | quell-server/node_modules 8 | quell-server/coverage 9 | quell-client/package-lock.json 10 | quell-client/node_modules 11 | quell-extension/node_modules 12 | quell-extension/package-lock.json -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Quell 2 | 3 | The Quell Team would like to thank you for your interest in helping to maintain and improve Quell! 4 | 5 | ## Reporting Bugs 6 | 7 | All code changes happen through Github Pull Requests and we actively welcome them. To submit your pull request, follow the steps below: 8 | 9 | ## Pull Requests 10 | 11 | 1. Fork the repo and create your branch from `main`. 12 | 2. If you've added code that should be tested, add tests. 13 | 3. If you've changed APIs, update the documentation. 14 | 4. Ensure the test suite passes. 15 | 5. Make sure your code lints. 16 | 6. Issue that pull request! 17 | 18 | Note: Any contributions you make will be under the MIT Software License and your submissions are understood to be under the same that covers the project. Please reach out to the team if you have any questions. 19 | 20 | ## Issues 21 | 22 | We use GitHub issues to track public bugs. Please ensure your description is clear and has sufficient instructions to be able to reproduce the issue. 23 | 24 | ## Coding Style 25 | 26 | 2 spaces for indentation rather than tabs 27 | 80 character line length 28 | Run npm run lint to comform to our lint rules 29 | 30 | ## License 31 | 32 | By contributing, you agree that your contributions will be licensed under Quell's MIT License. 33 | 34 | ### References 35 | 36 | This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Quell 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 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/open-source-labs/Quell/blob/master/LICENSE) 4 | ![AppVeyor](https://img.shields.io/badge/version-8.0.0-blue.svg) 5 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/open-source-labs/Quell/issues) 6 | 7 | # Quell 8 | 9 | Quell is an easy-to-use, lightweight JavaScript library providing a client- and server-side caching solution and cache invalidation for GraphQL. 10 | 11 |
12 | 13 | ![JavaScript](https://img.shields.io/badge/javascript-%23323330.svg?style=for-the-badge&logo=javascript&logoColor=%23F7DF1E) 14 | ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white) 15 | ![Express.js](https://img.shields.io/badge/express.js-%23404d59.svg?style=for-the-badge&logo=express&logoColor=%2361DAFB) 16 | ![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) 17 | ![Jest](https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white) 18 | ![Testing-Library](https://img.shields.io/badge/-TestingLibrary-%23E33332?style=for-the-badge&logo=testing-library&logoColor=white) 19 | ![Redis](https://img.shields.io/badge/redis-%23DD0031.svg?&style=for-the-badge&logo=redis&logoColor=white) 20 | ![GraphQL](https://img.shields.io/badge/-GraphQL-E10098?style=for-the-badge&logo=graphql&logoColor=white) 21 | ![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB) 22 | ![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) 23 | ![MySQL](https://img.shields.io/badge/mysql-%2300f.svg?style=for-the-badge&logo=mysql&logoColor=white) 24 | ![MongoDB](https://img.shields.io/badge/MongoDB-%234ea94b.svg?style=for-the-badge&logo=mongodb&logoColor=white) 25 | ![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white) 26 | 27 |
28 | 29 | ## Installation 30 | 31 | ### Quell-Client and Quell-Server 32 | 33 | Quell is divided into two npm packages: 34 | 35 | - Download @quell/client from npm in your terminal with `npm i @quell/client` 36 | - Download @quell/server from npm in your terminal with `npm i @quell/server` 37 | 38 | ## Features 39 | 40 | - IP-Rate Limiting to protect your server. 41 | - Successfully handling cache mutations with cache invalidation. 42 | - Quell/server offers optional depth and cost limiting middleware to protect your GraphQL endpoint! To use, please explore the [@quell/server readme](./quell-server/README.md). 43 | - Server-side cache now caches entire queries in instances where it is unable to cache individual datapoints. 44 | - Client-side caching utilizing JavaScript's built in Map data structure. 45 | - Client-side caching utilizing Least Recently Used (LRU) caching strategy. 46 | - Server-side caching utilizing a configurable Redis in-memory data store with batching. 47 | - Partial and exact match query caching. 48 | - Programmatic rebuilding of GraphQL queries to fetch only the minimum data necessary to complete the response based upon current cache contents. 49 | - A easy-to-use Chrome Developer Tools extension designed for Quell users. With this extension, users can: 50 | - Inspect and monitor the latency of client-side GraphQL/Quell requests. 51 | - Make and monitor the latency of GraphQL/Quell requests to a specified server endpoint. 52 | - View server-side cache data and contents, with the ability to manually clear the cache 53 | - Features require zero-to-minimal configuration and can work independently of `@quell/client` and `@quell/server` 54 | 55 |

56 | 57 | ### Demo 58 | 59 |

60 | The first bar represents an uncached client-side query and the second and third bars represent the response time of a cached request (0 ms). 61 | 62 |
63 |
64 | 65 |

66 | A demonstration of how Quell can be implemented to protect against costly network requests. 67 | 68 |
69 |
70 | 71 | Try Quell out [here](https://quell.dev/) 72 | 73 | #### Usage Notes 74 | 75 | - Currently, Quell can cache 1) query-type requests without variables or directives and 2) mutation-type requests (add, update, and delete) with cache invalidation implemented. Quell will still process other requests, but will not cache the responses. 76 | 77 | ### Quell Developer Tool 78 | 79 | Quell Developer Tool is currently available as a Chrome Developer Tools extension. The easiest way to get it is to [add it from the Chrome Web Store.](https://chrome.google.com/webstore/detail/quell-developer-tool/jnegkegcgpgfomoolnjjkmkippoellod) 80 | 81 | ## Documentation 82 | 83 | - [@quell/client README](./quell-client/README.md) 84 | - [@quell/server README](./quell-server/README.md) 85 | - [Quell Developer Tool README](./quell-extension/README.md) 86 | - [Quell Demo Repo](https://github.com/oslabs-beta/QuellDemo-ts-7.0) 87 | 88 | ### Contribute to Quell 89 | 90 | Interested in making a contribution to Quell? Find our open-source contribution guidelines [here](./CONTRIBUTING.md). Please feel free to also review the larger future direction for Quell in the Client and Server readme's. 91 | 92 | Thank you for your interest and support! 93 | 94 | -Team Quell 95 | 96 | ## Quell Contributors 97 | 98 | Accelerated by [OS Labs](https://github.com/open-source-labs) and developed by [Andrew Dai](https://github.com/andrewmdai), [Cassidy Komp](https://github.com/mimikomp), [Ian Weinholtz](https://github.com/itsHackinTime), [Stacey Lee](https://github.com/staceyjhlee), [Jonah Weinbum](https://github.com/jonahpw), [Justin Hua](https://github.com/justinfhua), [Lenny Yambao](https://github.com/lennin6), [Michael Lav](https://github.com/mikelav258), [Angelo Chengcuenca](https://github.com/amchengcuenca), [Emily Hoang](https://github.com/emilythoang), [Keely Timms](https://github.com/keelyt), [Yusuf Bhaiyat](https://github.com/yusuf-bha), [Hannah Spencer](https://github.com/Hannahspen), [Garik Asplund](https://github.com/garikAsplund), [Katie Sandfort](https://github.com/katiesandfort), [Sarah Cynn](https://github.com/cynnsarah), [Rylan Wessel](https://github.com/XpIose), [Alex Martinez](https://github.com/alexmartinez123), [Cera Barrow](https://github.com/cerab), [Jackie He](https://github.com/Jckhe), [Zoe Harper](https://github.com/ContraireZoe), [David Lopez](https://github.com/DavidMPLopez), [Sercan Tuna](https://github.com/srcntuna), [Idan Michael](https://github.com/IdanMichael), [Tom Pryor](https://github.com/Turmbeoz), [Chang Cai](https://github.com/ccai89), [Robert Howton](https://github.com/roberthowton), [Joshua Jordan](https://github.com/jjordan-90), [Jinhee Choi](https://github.com/jcroadmovie), [Nayan Parmar](https://github.com/nparmar1), [Tashrif Sanil](https://github.com/tashrifsanil), [Tim Frenzel](https://github.com/TimFrenzel), [Robleh Farah](https://github.com/farahrobleh), [Angela Franco](https://github.com/ajfranco18), [Ken Litton](https://github.com/kenlitton), [Thomas Reeder](https://github.com/nomtomnom), [Andrei Cabrera](https://github.com/Andreicabrerao), [Dasha Kondratenko](https://github.com/dasha-k), [Derek Sirola](https://github.com/dsirola1), [Xiao Yu Omeara](https://github.com/xyomeara), [Nick Kruckenberg](https://github.com/kruckenberg), [Mike Lauri](https://github.com/MichaelLauri), [Rob Nobile](https://github.com/RobNobile) and [Justin Jaeger](https://github.com/justinjaeger). 99 | -------------------------------------------------------------------------------- /assets/Quell-B2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/assets/Quell-B2.png -------------------------------------------------------------------------------- /assets/QuellDemo-Client-Side-Demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/assets/QuellDemo-Client-Side-Demo.gif -------------------------------------------------------------------------------- /assets/QuellDemo-Client-Side.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/assets/QuellDemo-Client-Side.gif -------------------------------------------------------------------------------- /assets/QuellDemo-Costly.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/assets/QuellDemo-Costly.gif -------------------------------------------------------------------------------- /assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/assets/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/assets/favicon-32x32.png -------------------------------------------------------------------------------- /assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/assets/mstile-150x150.png -------------------------------------------------------------------------------- /assets/updatedQuell-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/assets/updatedQuell-diagram.png -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | **What is the problem you were trying to solve?** 2 | 3 | 4 | 5 | **What is your solution and why did you solve this problem the way you did?** 6 | 7 | 8 | 9 | **Provide any additional notes on testing this functionality:** 10 | 11 | 12 | 13 | **Screenshots / gifs of working solution:** 14 | 15 | 16 | -------------------------------------------------------------------------------- /quell-client/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dump.rdb 3 | package-lock.json 4 | coverage 5 | dist/ 6 | dist 7 | .tgz -------------------------------------------------------------------------------- /quell-client/README.md: -------------------------------------------------------------------------------- 1 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/open-source-labs/Quell/blob/master/LICENSE) 2 | ![AppVeyor](https://img.shields.io/badge/build-passing-brightgreen.svg) 3 | ![AppVeyor](https://img.shields.io/badge/version-9.0.0-blue.svg) 4 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/open-source-labs/Quell/issues) 5 | 6 | # @quell/client 7 | 8 | @quell/client is an easy-to-implement JavaScript library providing a client-side caching solution and cache invalidation for GraphQL. Quell's schema-governed, type-level normalization algorithm caches GraphQL query responses as flattened key-value representations of the graph's nodes, making it possible to partially satisfy queries from the client-side cache storage, reformulate the query, and then fetch additional data from other APIs or databases. 9 | 10 | @quell/client is an open-source NPM package accelerated by [OS Labs](https://github.com/open-source-labs) and developed by [Cassidy Komp](https://github.com/mimikomp), [Andrew Dai](https://github.com/andrewmdai), [Stacey Lee](https://github.com/staceyjhlee), [Ian Weinholtz](https://github.com/itsHackinTime), [Angelo Chengcuenca](https://github.com/amchengcuenca), [Emily Hoang](https://github.com/emilythoang), [Keely Timms](https://github.com/keelyt), [Yusuf Bhaiyat](https://github.com/yusuf-bha), [David Lopez](https://github.com/DavidMPLopez), [Sercan Tuna](https://github.com/srcntuna), [Idan Michael](https://github.com/IdanMichael), [Tom Pryor](https://github.com/Turmbeoz), [Chang Cai](https://github.com/ccai89), [Robert Howton](https://github.com/roberthowton), [Joshua Jordan](https://github.com/jjordan-90), [Jinhee Choi](https://github.com/jcroadmovie), [Nayan Parmar](https://github.com/nparmar1), [Tashrif Sanil](https://github.com/tashrifsanil), [Tim Frenzel](https://github.com/TimFrenzel), [Robleh Farah](https://github.com/farahrobleh), [Angela Franco](https://github.com/ajfranco18), [Ken Litton](https://github.com/kenlitton), [Thomas Reeder](https://github.com/nomtomnom), [Andrei Cabrera](https://github.com/Andreicabrerao), [Dasha Kondratenko](https://github.com/dasha-k), [Derek Sirola](https://github.com/dsirola1), [Xiao Yu Omeara](https://github.com/xyomeara), [Nick Kruckenberg](https://github.com/kruckenberg), [Mike Lauri](https://github.com/MichaelLauri), [Rob Nobile](https://github.com/RobNobile), and [Justin Jaeger](https://github.com/justinjaeger). 11 | 12 | ## Installation 13 | 14 | Download @quell/client from npm in your terminal with `npm i @quell/client`. 15 | `@quell/client` will be added as a dependency to your package.json file. 16 | 17 | ## Implementation 18 | 19 | Let's take a look at a typical use case for @quell/client by re-writing a fetch request to a GraphQL endpoint. 20 | 21 | Sample code of fetch request without Quell: 22 | 23 | ```javascript 24 | const sampleQuery = `query { 25 | countries { 26 | id 27 | name 28 | cities { 29 | id 30 | name 31 | population 32 | } 33 | } 34 | }` 35 | 36 | 37 | fetch('/graphQL', { 38 | method: "POST", 39 | body: JSON.stringify(sampleQuery) 40 | }) 41 | 42 | costOptions = { 43 | maxCost: 50, 44 | maxDepth: 10, 45 | ipRate: 5 46 | } 47 | ``` 48 | 49 | To make that same request with Quell: 50 | 51 | 1. Import Quell with `import { Quellify } from '@quell/client/dist/Quellify'` 52 | 2. Instead of calling `fetch(endpoint)` and passing the query through the request body, replace with `Quellify(endpoint, query, costOptions)` 53 | 54 | - The `Quellify` method takes in three parameters 55 | 1. **_endpoint_** - your GraphQL endpoint as a string (ex. '/graphQL') 56 | 2. **_query_** - your GraphQL query as a string (ex. see sampleQuery, above) 57 | 3. **_costOptions_** - your cost limit, depth limit, and IP rate limit for your queries (ex. see costOptions, above) 58 | 4. **_mutationMap_** - maps mutation names to corresponding parts of the schema. 59 | *(For more information, see the Schema section in @quell/server [README file](https://github.com/open-source-labs/Quell/tree/master/quell-server))* 60 | 61 | 62 | And in the end , your Quell-powered GraphQL fetch would look like this: 63 | 64 | ```javascript 65 | Quellify('/graphQL', sampleQuery, costOptions, mutationMap) 66 | .then( /* use parsed response */ ); 67 | ``` 68 | 69 | Note: Quell will return a promise that resolves into an array with two elements. The first element will be a JS object containing your data; this is in the same form as the response found on the 'data' key of a typical GraphQL response `{ data: // response }`. The second element will be a boolean indicating whether or not the data was found in the client-side cache. 70 | 71 | That's it! You're now caching your GraphQL queries in the client-side cache storage. 72 | 73 | ### Usage Notes 74 | 75 | - @quell/client now client-side caching speed is 4-5 times faster than it used to be. 76 | 77 | - Currently, Quell can cache any non-mutative query. Quell will still process other requests, but all mutations will cause cache invalidation for the entire client-side cache. Please report edge cases, issues, and other user stories to us, we would be grateful to expand on Quells use cases! 78 | 79 | #### For information on @quell/server, please visit the corresponding [README file](https://github.com/open-source-labs/Quell/tree/master/quell-server). 80 | -------------------------------------------------------------------------------- /quell-client/__tests__/quellify.test.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /quell-client/__tests__/quellify.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | Object.defineProperty(exports, "__esModule", { value: true }); 12 | const Quellify_1 = require("../src/Quellify"); 13 | const defaultCostOptions = { 14 | maxCost: 5000, 15 | mutationCost: 5, 16 | objectCost: 2, 17 | scalarCost: 1, 18 | depthCostFactor: 1.5, 19 | maxDepth: 10, 20 | ipRate: 3 21 | }; 22 | // Command to run jest tests: 23 | // npx jest client/src/quell-client/client-tests/__tests__/quellify.test.ts 24 | describe('Quellify', () => { 25 | beforeEach(() => { 26 | // Clear the client cache before each test 27 | (0, Quellify_1.clearCache)(); 28 | }); 29 | it('checks that caching queries is working correctly', () => __awaiter(void 0, void 0, void 0, function* () { 30 | const endPoint = 'http://localhost:3000/api/graphql'; 31 | const query = 'query { artist(name: "Frank Ocean") { id name albums { id name } } }'; 32 | const costOptions = defaultCostOptions; 33 | const [data, foundInCache] = yield (0, Quellify_1.Quellify)(endPoint, query, costOptions); 34 | // Assertion: the data should not be found in the cache 35 | expect(foundInCache).toBe(false); 36 | // Invoke Quellify on query again 37 | const [cachedData, updatedCache] = yield (0, Quellify_1.Quellify)(endPoint, query, costOptions); 38 | // Assertion: Cached data should be the same as the original query 39 | expect(cachedData).toBe(data); 40 | // Assertion: The boolean should return true if it is found in the cache 41 | expect(updatedCache).toEqual(true); 42 | })); 43 | it('should update the cache for edit mutation queries', () => __awaiter(void 0, void 0, void 0, function* () { 44 | const endPoint = 'http://localhost:3000/api/graphql'; 45 | const addQuery = 'mutation { addCity(name: "San Diego", country: "United States") { id name } }'; 46 | const costOptions = defaultCostOptions; 47 | // Perform add mutation query to the cache 48 | const [addMutationData, addMutationfoundInCache] = yield (0, Quellify_1.Quellify)(endPoint, addQuery, costOptions); 49 | // Get the cityId on the mutation query 50 | const cityId = addMutationData.addCity.id; 51 | const city = "Las Vegas"; 52 | // Perform edit mutation on query to update the name 53 | const editQuery = `mutation { editCity(id: "${cityId}", name: "${city}", country: "United States") { id name } }`; 54 | const [editMutationData, editMutationDataFoundInCache] = yield (0, Quellify_1.Quellify)(endPoint, editQuery, costOptions); 55 | //Assertion: The first mutation query name should be updated by the second edit mutation 56 | expect(addMutationData.name).toEqual(editMutationData.name); 57 | })); 58 | it('should delete an item from the server and invalidate the cache', () => __awaiter(void 0, void 0, void 0, function* () { 59 | const endPoint = 'http://localhost:3000/api/graphql'; 60 | const addQuery = 'mutation { addCity(name: "Irvine", country: "United States") { id name } }'; 61 | const costOptions = defaultCostOptions; 62 | // Perform add mutation query to the server 63 | const [addMutationData, addMutationfoundInCache] = yield (0, Quellify_1.Quellify)(endPoint, addQuery, costOptions); 64 | // Get the cityId on the mutation query 65 | const cityId = addMutationData.addCity.id; 66 | // Perform a delete mutation on the city 67 | const deleteQuery = `mutation { deleteCity(id: "${cityId}") { id name } }`; 68 | const [deleteMutationData, deleteMutationDataFoundInCache] = yield (0, Quellify_1.Quellify)(endPoint, deleteQuery, costOptions); 69 | //Assertion: The item should be removed from the cache 70 | expect(deleteMutationDataFoundInCache).toBe(false); 71 | })); 72 | it('should evict the LRU item from cache if cache size is exceeded', () => __awaiter(void 0, void 0, void 0, function* () { 73 | const endPoint = 'http://localhost:3000/api/graphql'; 74 | const costOptions = defaultCostOptions; 75 | const query1 = 'query { artist(name: "Frank Ocean") { id name albums { id name } } }'; 76 | const query2 = 'query { country(name: "United States") { id name cities { id name attractions { id name } } } }'; 77 | const query3 = 'mutation { addCity(name: "San Diego", country: "United States") { id name } }'; 78 | // Invoke Quellify on each query to add to cache 79 | yield (0, Quellify_1.Quellify)(endPoint, query1, costOptions); 80 | yield (0, Quellify_1.Quellify)(endPoint, query2, costOptions); 81 | // Assertion: lruCache should contain the queries 82 | expect(Quellify_1.lruCache.has(query1)).toBe(true); 83 | expect(Quellify_1.lruCache.has(query2)).toBe(true); 84 | // Invoke Quellify again on third query to exceed max cache size 85 | yield (0, Quellify_1.Quellify)(endPoint, query3, costOptions); 86 | // Assertion: lruCache should evict the LRU item 87 | expect(Quellify_1.lruCache.has(query1)).toBe(false); 88 | // Assertion: lruCache should still contain the most recently used items 89 | expect(Quellify_1.lruCache.has(query2)).toBe(true); 90 | expect(Quellify_1.lruCache.has(query3)).toBe(true); 91 | })); 92 | }); 93 | -------------------------------------------------------------------------------- /quell-client/__tests__/quellify.test.ts: -------------------------------------------------------------------------------- 1 | import { Quellify, clearCache, lruCache } from '../src/Quellify'; 2 | import { CostParamsType } from '../src/types'; 3 | 4 | const defaultCostOptions: CostParamsType = { 5 | maxCost: 5000, 6 | mutationCost: 5, 7 | objectCost: 2, 8 | scalarCost: 1, 9 | depthCostFactor: 1.5, 10 | maxDepth: 10, 11 | ipRate: 3 12 | }; 13 | 14 | // Command to run jest tests: 15 | // npx jest client/src/quell-client/client-tests/__tests__/quellify.test.ts 16 | 17 | describe('Quellify', () => { 18 | beforeEach(() => { 19 | // Clear the client cache before each test 20 | clearCache(); 21 | }); 22 | 23 | it('checks that caching queries is working correctly', async () => { 24 | const endPoint = 'http://localhost:3000/api/graphql'; 25 | const query = 'query { artist(name: "Frank Ocean") { id name albums { id name } } }'; 26 | const costOptions = defaultCostOptions; 27 | const [data, foundInCache] = await Quellify(endPoint, query, costOptions) as [any, boolean]; 28 | 29 | // Assertion: the data should not be found in the cache 30 | expect(foundInCache).toBe(false); 31 | 32 | // Invoke Quellify on query again 33 | const [cachedData, updatedCache] = await Quellify(endPoint, query, costOptions) as [any, boolean]; 34 | // Assertion: Cached data should be the same as the original query 35 | expect(cachedData).toBe(data); 36 | // Assertion: The boolean should return true if it is found in the cache 37 | expect(updatedCache).toEqual(true); 38 | 39 | }); 40 | 41 | it('should update the cache for edit mutation queries', async () => { 42 | const endPoint = 'http://localhost:3000/api/graphql'; 43 | const addQuery = 'mutation { addCity(name: "San Diego", country: "United States") { id name } }'; 44 | const costOptions = defaultCostOptions; 45 | 46 | // Perform add mutation query to the cache 47 | const [addMutationData, addMutationfoundInCache] = await Quellify(endPoint, addQuery, costOptions) as [any, boolean]; 48 | // Get the cityId on the mutation query 49 | const cityId = addMutationData.addCity.id; 50 | const city = "Las Vegas"; 51 | // Perform edit mutation on query to update the name 52 | const editQuery = `mutation { editCity(id: "${cityId}", name: "${city}", country: "United States") { id name } }`; 53 | const [editMutationData, editMutationDataFoundInCache] = await Quellify(endPoint, editQuery, costOptions) as [any, boolean]; 54 | 55 | //Assertion: The first mutation query name should be updated by the second edit mutation 56 | expect(addMutationData.name).toEqual(editMutationData.name); 57 | 58 | }); 59 | 60 | 61 | it('should delete an item from the server and invalidate the cache', async () => { 62 | const endPoint = 'http://localhost:3000/api/graphql'; 63 | const addQuery = 'mutation { addCity(name: "Irvine", country: "United States") { id name } }'; 64 | const costOptions = defaultCostOptions; 65 | 66 | // Perform add mutation query to the server 67 | const [addMutationData, addMutationfoundInCache] = await Quellify(endPoint, addQuery, costOptions) as [any, boolean]; 68 | // Get the cityId on the mutation query 69 | const cityId = addMutationData.addCity.id; 70 | 71 | // Perform a delete mutation on the city 72 | const deleteQuery = `mutation { deleteCity(id: "${cityId}") { id name } }`; 73 | const [deleteMutationData, deleteMutationDataFoundInCache] = await Quellify(endPoint, deleteQuery, costOptions) as [any, boolean]; 74 | 75 | //Assertion: The item should be removed from the cache 76 | expect(deleteMutationDataFoundInCache).toBe(false); 77 | 78 | }); 79 | 80 | 81 | it('should evict the LRU item from cache if cache size is exceeded', async () => { 82 | const endPoint = 'http://localhost:3000/api/graphql'; 83 | const costOptions = defaultCostOptions; 84 | const query1 = 'query { artist(name: "Frank Ocean") { id name albums { id name } } }'; 85 | const query2 = 'query { country(name: "United States") { id name cities { id name attractions { id name } } } }'; 86 | const query3 = 'mutation { addCity(name: "San Diego", country: "United States") { id name } }'; 87 | 88 | 89 | // Invoke Quellify on each query to add to cache 90 | await Quellify(endPoint, query1, costOptions); 91 | await Quellify(endPoint, query2, costOptions); 92 | 93 | // Assertion: lruCache should contain the queries 94 | expect(lruCache.has(query1)).toBe(true); 95 | expect(lruCache.has(query2)).toBe(true); 96 | 97 | // Invoke Quellify again on third query to exceed max cache size 98 | await Quellify(endPoint, query3, costOptions); 99 | 100 | // Assertion: lruCache should evict the LRU item 101 | expect(lruCache.has(query1)).toBe(false); 102 | 103 | // Assertion: lruCache should still contain the most recently used items 104 | expect(lruCache.has(query2)).toBe(true); 105 | expect(lruCache.has(query3)).toBe(true); 106 | 107 | }); 108 | 109 | }); 110 | -------------------------------------------------------------------------------- /quell-client/eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier", 13 | "plugin:prettier/recommended" 14 | ], 15 | "overrides": [], 16 | "parserOptions": { 17 | "ecmaVersion": "latest" 18 | }, 19 | "plugins": ["@typescript-eslint", "prettier"], 20 | "rules": { 21 | "no-console": "off", 22 | "prefer-const": "warn", 23 | "quotes": ["warn", "single"], 24 | "semi": ["warn", "always"], 25 | "prettier/prettier": [ 26 | "error", 27 | { 28 | "printWidth": 80, 29 | "semi": true, 30 | "singleQuote": true, 31 | "tabWidth": 2, 32 | "bracketSpacing": true, 33 | "trailingComma": "none" 34 | } 35 | ], 36 | "space-before-function-paren": 0 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /quell-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@quell/client", 3 | "version": "9.0.2", 4 | "description": "Quell is an open-source NPM package providing a light-weight caching layer implementation and cache invalidation for GraphQL responses on both the client- and server-side. Use Quell to prevent redundant client-side API requests and to minimize costly server-side response latency.", 5 | "main": "./dist/Quellify.js", 6 | "types": "./dist/types.d.ts", 7 | "files": [ 8 | "dist/**/*", 9 | "package.json", 10 | "README.md" 11 | ], 12 | "scripts": { 13 | "start": "node ./dist/Quellify.js" 14 | }, 15 | "engines": { 16 | "node": ">=14.17.5", 17 | "npm": ">=8.1.0" 18 | }, 19 | "author": "Quell", 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/open-source-labs/Quell/tree/main/quell-client" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/open-source-labs/Quell/issues" 27 | }, 28 | "keywords": [ 29 | "cache-invalidation", 30 | "graphQL", 31 | "cache", 32 | "caching", 33 | "client-side", 34 | "quell" 35 | ], 36 | "contributors": [ 37 | { 38 | "name": "Alexander Martinez", 39 | "url": "https://github.com/alexmartinez123" 40 | }, 41 | { 42 | "name": "Cera Barrow", 43 | "url": "https://github.com/cerab" 44 | }, 45 | { 46 | "name": "Jackie He", 47 | "url": "https://github.com/Jckhe" 48 | }, 49 | { 50 | "name": "Zoe Harper", 51 | "url": "https://github.com/ContraireZoe" 52 | }, 53 | { 54 | "name": "Alexander Martinez", 55 | "url": "https://github.com/alexmartinez123" 56 | }, 57 | { 58 | "name": "Cera Barrow", 59 | "url": "https://github.com/cerab" 60 | }, 61 | { 62 | "name": "Jackie He", 63 | "url": "https://github.com/Jckhe" 64 | }, 65 | { 66 | "name": "Zoe Harper", 67 | "url": "https://github.com/ContraireZoe" 68 | }, 69 | { 70 | "name": "Idan Michael", 71 | "url": "https://github.com/idanmichael" 72 | }, 73 | { 74 | "name": "Sercan Tuna", 75 | "url": "https://github.com/srcntuna" 76 | }, 77 | { 78 | "name": "Thomas Pryor", 79 | "url": " https://github.com/Turmbeoz" 80 | }, 81 | { 82 | "name": "David Lopez", 83 | "url": "https://github.com/DavidMPLopez" 84 | }, 85 | { 86 | "name": "Chang Cai", 87 | "url": "https://github.com/ccai89" 88 | }, 89 | { 90 | "name": "Robert Howton", 91 | "url": "https://github.com/roberthowton" 92 | }, 93 | { 94 | "name": "Joshua Jordan", 95 | "url": "https://github.com/jjordan-90" 96 | }, 97 | { 98 | "name": "Jinhee Choi", 99 | "url": "https://github.com/jcroadmovie" 100 | }, 101 | { 102 | "name": "Nayan Parmar", 103 | "url": "https://github.com/nparmar1" 104 | }, 105 | { 106 | "name": "Tashrif Sanil", 107 | "url": "https://github.com/tashrifsanil" 108 | }, 109 | { 110 | "name": "Tim Frenzel", 111 | "url": "(https://github.com/TimFrenzel" 112 | }, 113 | { 114 | "name": "Thomas Reeder", 115 | "url": "https://github.com/nomtomnom" 116 | }, 117 | { 118 | "name": "Ken Litton", 119 | "url": "https://github.com/kenlitton" 120 | }, 121 | { 122 | "name": "Robleh Farah", 123 | "url": "https://github.com/farahrobleh" 124 | }, 125 | { 126 | "name": "Angela Franco", 127 | "url": "https://github.com/ajfranco18" 128 | }, 129 | { 130 | "name": "Andrei Cabrera", 131 | "url": "https://github.com/Andreicabrerao" 132 | }, 133 | { 134 | "name": "Dasha Kondratenko", 135 | "url": "https://github.com/dasha-k" 136 | }, 137 | { 138 | "name": "Derek Sirola", 139 | "url": "https://github.com/dsirola1" 140 | }, 141 | { 142 | "name": "Xiao Yu Omeara", 143 | "url": "https://github.com/xyomeara" 144 | }, 145 | { 146 | "name": "Mike Lauri", 147 | "url": "https://github.com/MichaelLauri" 148 | }, 149 | { 150 | "name": "Rob Nobile", 151 | "url": "https://github.com/RobNobile" 152 | }, 153 | { 154 | "name": "Justin Jaeger", 155 | "url": "https://github.com/justinjaeger" 156 | }, 157 | { 158 | "name": "Nick Kruckenberg", 159 | "url": "https://github.com/kruckenberg" 160 | }, 161 | { 162 | "name": "Cassidy Komp", 163 | "url": "https://github.com/mimikomp" 164 | }, 165 | { 166 | "name": "Andrew Dai", 167 | "url": "https://github.com/andrewmdai" 168 | }, 169 | { 170 | "name": "Stacey Lee", 171 | "url": "https://github.com/staceyjhlee" 172 | }, 173 | { 174 | "name": "Ian Weinholtz", 175 | "url": "https://github.com/itsHackinTime" 176 | } 177 | ], 178 | "homepage": "https://www.quell.dev/", 179 | "devDependencies": { 180 | "@types/jest": "^29.5.2", 181 | "@types/node": "^18.15.3", 182 | "@typescript-eslint/eslint-plugin": "^5.54.1", 183 | "@typescript-eslint/parser": "^5.54.1", 184 | "eslint": "^8.35.0", 185 | "eslint-config-prettier": "^8.7.0", 186 | "eslint-config-standard-with-typescript": "^34.0.0", 187 | "eslint-plugin-import": "^2.27.5", 188 | "eslint-plugin-n": "^15.6.1", 189 | "eslint-plugin-prettier": "^4.2.1", 190 | "eslint-plugin-promise": "^6.1.1", 191 | "prettier": "^2.8.4", 192 | "typescript": "^4.9.5" 193 | }, 194 | "dependencies": { 195 | "lru-cache": "^10.0.0" 196 | }, 197 | "peerDependencies": { 198 | "graphql": "^16.7.1" 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /quell-client/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IntValueNode, 3 | FloatValueNode, 4 | StringValueNode, 5 | BooleanValueNode, 6 | EnumValueNode, 7 | OperationDefinitionNode, 8 | VariableDefinitionNode, 9 | FieldNode, 10 | FragmentSpreadNode, 11 | InlineFragmentNode, 12 | FragmentDefinitionNode, 13 | SchemaDefinitionNode, 14 | ScalarTypeDefinitionNode, 15 | ObjectTypeDefinitionNode, 16 | FieldDefinitionNode, 17 | InputValueDefinitionNode, 18 | InterfaceTypeDefinitionNode, 19 | UnionTypeDefinitionNode, 20 | EnumTypeDefinitionNode, 21 | EnumValueDefinitionNode, 22 | InputObjectTypeDefinitionNode, 23 | SchemaExtensionNode, 24 | ScalarTypeExtensionNode, 25 | ObjectTypeExtensionNode, 26 | InterfaceTypeExtensionNode, 27 | UnionTypeExtensionNode, 28 | EnumTypeExtensionNode, 29 | InputObjectTypeExtensionNode 30 | } from 'graphql'; 31 | 32 | // Interface for prototype object type 33 | export interface ProtoObjType { 34 | [key: string]: string | boolean | null | ProtoObjType; 35 | } 36 | 37 | // Interface for fragments type 38 | export interface FragsType { 39 | [fragName: string]: { 40 | [fieldName: string]: boolean; 41 | }; 42 | } 43 | 44 | // Interface for argument object type 45 | export interface ArgsObjType { 46 | [fieldName: string]: string | boolean | null; 47 | } 48 | 49 | // Interface for field arguments type 50 | export interface FieldArgsType { 51 | [fieldName: string]: AuxObjType; 52 | } 53 | 54 | // Interface for auxiliary object type 55 | export interface AuxObjType { 56 | __type?: string | boolean | null; 57 | __alias?: string | boolean | null; 58 | __args?: ArgsObjType | null; 59 | __id?: string | boolean | null; 60 | } 61 | 62 | // Define a type that represents a GraphQL node with directives 63 | export type GQLNodeWithDirectivesType = 64 | | OperationDefinitionNode 65 | | VariableDefinitionNode 66 | | FieldNode 67 | | FragmentSpreadNode 68 | | InlineFragmentNode 69 | | FragmentDefinitionNode 70 | | SchemaDefinitionNode 71 | | ScalarTypeDefinitionNode 72 | | ObjectTypeDefinitionNode 73 | | FieldDefinitionNode 74 | | InputValueDefinitionNode 75 | | InterfaceTypeDefinitionNode 76 | | UnionTypeDefinitionNode 77 | | EnumTypeDefinitionNode 78 | | EnumValueDefinitionNode 79 | | InputObjectTypeDefinitionNode 80 | | SchemaExtensionNode 81 | | ScalarTypeExtensionNode 82 | | ObjectTypeExtensionNode 83 | | InterfaceTypeExtensionNode 84 | | UnionTypeExtensionNode 85 | | EnumTypeExtensionNode 86 | | InputObjectTypeExtensionNode; 87 | 88 | // Define a type for valid argument node types 89 | export type ValidArgumentNodeType = 90 | | IntValueNode 91 | | FloatValueNode 92 | | StringValueNode 93 | | BooleanValueNode 94 | | EnumValueNode; 95 | 96 | // Interface for fields values type 97 | export interface FieldsValuesType { 98 | [fieldName: string]: boolean; 99 | } 100 | 101 | // Interface for fields object type 102 | export interface FieldsObjectType { 103 | [fieldName: string]: string | boolean | null | ArgsObjType; 104 | } 105 | 106 | // Interface for cost parameters type 107 | export interface CostParamsType { 108 | [key: string]: number | undefined; 109 | maxCost: number; 110 | mutationCost?: number; 111 | objectCost?: number; 112 | scalarCost?: number; 113 | depthCostFactor?: number; 114 | maxDepth: number; 115 | ipRate: number; 116 | } 117 | 118 | // Interface for map cache type 119 | export interface MapCacheType { 120 | data: JSONObject; 121 | fieldNames: string[]; 122 | } 123 | 124 | // Interface for fetch object type 125 | export interface FetchObjType { 126 | method?: string; 127 | headers: { 'Content-Type': string }; 128 | body: string; 129 | } 130 | 131 | // Interface for JSON object 132 | export interface JSONObject { 133 | [k: string]: JSONValue; 134 | } 135 | 136 | // Interface for JSON object with 'id' property 137 | export interface JSONObjectWithId { 138 | id?: string; 139 | } 140 | 141 | // Type for JSON value 142 | export type JSONValue = JSONObject | JSONArray | JSONPrimitive; 143 | type JSONPrimitive = number | string | boolean | null; 144 | type JSONArray = JSONValue[]; 145 | 146 | // Type for client error 147 | export type ClientErrorType = { 148 | log: string; 149 | status: number; 150 | message: { err: string }; 151 | }; 152 | 153 | // Type for query response 154 | export type QueryResponse = { 155 | queryResponse: { data: JSONObject } 156 | } -------------------------------------------------------------------------------- /quell-extension/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", 3 | [ 4 | "@babel/preset-react", 5 | { 6 | "runtime":"automatic" 7 | } 8 | ], 9 | "@babel/preset-typescript" 10 | ] 11 | } -------------------------------------------------------------------------------- /quell-extension/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/.gitignore -------------------------------------------------------------------------------- /quell-extension/README.md: -------------------------------------------------------------------------------- 1 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/open-source-labs/Quell/blob/master/LICENSE) 2 | ![AppVeyor](https://img.shields.io/badge/build-passing-brightgreen.svg) 3 | ![AppVeyor](https://img.shields.io/badge/version-2.0.0-blue.svg) 4 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/open-source-labs/Quell/issues) 5 | 6 | # Quell Developer Tool 7 | 8 | The Quell Developer Tool is an easy-to-use Chrome Developer Tools extension designed for Quell users. With this extension, users can: 9 | 10 | - Inspect and monitor the latency of client-side GraphQL/Quell requests 11 | - View their GraphQL query execution context using our visualizer tool which includes resolution times of resolver fields 12 | - Make and monitor the latency of GraphQL/Quell requests to a specified server endpoint 13 | - View server-side cache data and contents, with the ability to manually clear the cache 14 | 15 | These features require zero-to-minimal configuration and can work independently of `@quell/client` and `@quell/server`, but are designed with the needs of Quell users especially in mind. 16 | 17 | The Quell Developer Tool is an open-source developer tool accelerated by [OS Labs](https://github.com/open-source-labs) and developed by [Jonah Weinbum](https://github.com/jonahpw), [Justin Hua](https://github.com/justinfhua), [Lenny Yambao](https://github.com/lennin6), [Michael Lav](https://github.com/mikelav258), [Angelo Chengcuenca](https://github.com/amchengcuenca), [Emily Hoang](https://github.com/emilythoang), [Keely Timms](https://github.com/keelyt), [Yusuf Bhaiyat](https://github.com/yusuf-bha), [Hannah Spencer](https://github.com/Hannahspen), [Garik Asplund](https://github.com/garikAsplund), [Katie Sandfort](https://github.com/katiesandfort), [Sarah Cynn](https://github.com/cynnsarah), [Rylan Wessel](https://github.com/XpIose), [Chang Cai](https://github.com/ccai89), [Robert Howton](https://github.com/roberthowton), [Joshua Jordan](https://github.com/jjordan-90), [Jinhee Choi](https://github.com/jcroadmovie), [Nayan Parmar](https://github.com/nparmar1), [Tashrif Sanil](https://github.com/tashrifsanil), [Tim Frenzel](https://github.com/TimFrenzel), [Robleh Farah](https://github.com/farahrobleh), [Angela Franco](https://github.com/ajfranco18), [Ken Litton](https://github.com/kenlitton), [Thomas Reeder](https://github.com/nomtomnom), [Andrei Cabrera](https://github.com/Andreicabrerao), [Dasha Kondratenko](https://github.com/dasha-k), [Derek Sirola](https://github.com/dsirola1), [Xiao Yu Omeara](https://github.com/xyomeara), [Nick Kruckenberg](https://github.com/kruckenberg), [Mike Lauri](https://github.com/MichaelLauri), [Rob Nobile](https://github.com/RobNobile) and [Justin Jaeger](https://github.com/justinjaeger). 18 | 19 | ## Installation 20 | 21 | The Quell Developer Tool is currently available as a Chrome Developer Tools extension. The easiest way to install it is to [add it from the Chrome Web Store.](https://chrome.google.com/webstore/detail/quell-developer-tool/jnegkegcgpgfomoolnjjkmkippoellod) 22 | 23 | The latest build can also be built from source and added manually as a Chrome extension. To build the latest version, execute the following commands: 24 | 25 | ``` 26 | git clone https://github.com/open-source-labs/Quell.git Quell 27 | cd Quell/quell-extension 28 | npm install 29 | npm run build 30 | ``` 31 | 32 | Then, in the Chrome Extensions Page (`chrome://extensions/`), click on "Load unpacked" and navigate to `.../Quell/quell-extension/dist/` and click "Select". (You may need to toggle on "Developer mode" to do this.) The extension should now be loaded and available in the Chrome Developer Tools. 33 | 34 | ## Usage and Configuration 35 | 36 | The Quell Developer Tool will work out-of-the-box as a GraphQL network monitor from its **Client** tab. Minimal configuration as described below is required to benefit from Quell Developer Tool's other features. 37 | 38 | ### Server 39 | 40 | To enable the features on the **Server** tab, navigate to the **Settings** tab and complete the following fields: 41 | 42 | - _GraphQL Route_. Your server's GraphQL endpoint (default: `http://localhost:3000`) 43 | - _Server Address_. The HTTP address of server (default: `/graphQL`) 44 | 45 | With this information the Quell Developer Tool will retrieve your GraphQL schema (and display it on the **Settings** tab) and permit you to make and view the latency of GraphQL queries from the **Server** tab. 46 | 47 | To enable the "Clear Cache" button, you can additionally specify a server endpoint configured with `@quell/server`'s `clearCache` middleware. 48 | 49 | - _Clear Cache Route_. Endpoint which `QuellCache.clearCache` middleware is configured (default: `/clearCache`) 50 | 51 | Here is an example configuration 52 | 53 | ```javascript 54 | app.get('/clearCache', quellCache.clearCache, (req, res) => { 55 | return res.status(200).send('Redis cache successfully cleared'); 56 | }); 57 | ``` 58 | 59 | ### Cache 60 | 61 | The **Cache** tab will display data from the Redis-based `@quell/server` cache. For it to do so, Quell Developer Tool requires an endpoint at which `@quell/server`'s `getRedisInfo` is configured. This enpoint can be specified in the **Settings** tab: 62 | 63 | - _Redis Route_. Endpoint at which `QuellCache.getRedisInfo` is configured. (default: `/redis`) 64 | 65 | The `getRedisInfo` middleware accepts an options object with the following keys: 66 | 67 | - `getStats` (`true`/`false`) - return a suite of statistics from Redis cache 68 | - `getKeys` (`true`/`false`) - return a list of keys currently stored in Redis cache 69 | - `getValues` (`true`/`false`) - return list of keys from Redis cache with their values 70 | 71 | Here is an example configuration: 72 | 73 | ```javascript 74 | app.use( 75 | '/redis', 76 | ...quellCache.getRedisInfo({ 77 | getStats: true, 78 | getKeys: true, 79 | getValues: true 80 | }) 81 | ); 82 | ``` 83 | 84 | ### Usage Note 85 | 86 | Use of `QuellCache.getRedisInfo` requires `@quell/server` at version `2.3.1` or greater. 87 | 88 | ## More information 89 | 90 | For more on `@quell/client` and `@quell/server`, see their documentation: 91 | 92 | - [@quell/client README](../quell-client/README.md) 93 | - [@quell/server README](../quell-server/README.md) 94 | -------------------------------------------------------------------------------- /quell-extension/__mocks__/fileTransformer.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; -------------------------------------------------------------------------------- /quell-extension/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /quell-extension/__tests__/extension.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import React from 'react'; 3 | import { render, fireEvent, screen } from '@testing-library/react'; 4 | import { jest } from '@jest/globals'; 5 | import { enableFetchMocks, fetchMock } from 'jest-fetch-mock'; 6 | import '@testing-library/jest-dom'; 7 | import userEvent from '@testing-library/user-event'; 8 | 9 | // import App from '../src/pages/Panel/App'; 10 | // import CacheTab from '../src/pages/Panel/Components/CacheTab'; 11 | // import InputEditor from '../src/pages/Panel/Components/InputEditor'; 12 | // import Metrics from '../src/pages/Panel/Components/Metrics'; 13 | // import NavButton from '../src/pages/Panel/Components/NavButton'; 14 | // import OutputEditor from '../src/pages/Panel/Components/OutputEditor'; 15 | // import PrimaryNavBar from '../src/pages/Panel/Components/PrimaryNavBar'; 16 | // import QueryTab from '../src/pages/Panel/Components/QueryTab'; 17 | // import Settings from '../src/pages/Panel/Components/Settings'; 18 | import { act } from 'react-dom/test-utils'; 19 | 20 | enableFetchMocks(); 21 | 22 | //workaround for TypeError: range(...).getBoundingClientRect is not a function 23 | document.createRange = () => { 24 | const range = new Range(); 25 | 26 | range.getBoundingClientRect = jest.fn(); 27 | 28 | range.getClientRects = () => { 29 | return { 30 | item: () => null, 31 | length: 0, 32 | [Symbol.iterator]: jest.fn(), 33 | }; 34 | }; 35 | 36 | return range; 37 | }; 38 | 39 | describe('App', () => { 40 | it('renders App component correctly', () => { 41 | fetch.mockResponseOnce( 42 | JSON.stringify({ 43 | data: { 44 | __schema: { types: [{ name: 'String' }] }, 45 | }, 46 | }) 47 | ); 48 | render(); 49 | 50 | const tabs = screen.queryAllByRole('button', /tab/i); 51 | expect(tabs).toHaveLength(10); 52 | }); 53 | 54 | it('renders correct component when tab is clicked', () => { 55 | const app = render(); 56 | const querybtn = app.container.querySelector('#queryButton'); 57 | const networkbtn = app.container.querySelector('#networkButton'); 58 | const cachebtn = app.container.querySelector('#cacheButton'); 59 | const settingsbtn = app.container.querySelector('#settingsButton'); 60 | 61 | fireEvent.click(networkbtn); 62 | expect(activeTab).toEqual('network'); 63 | fireEvent.click(cachebtn); 64 | expect(activeTab).toEqual('cache'); 65 | fireEvent.click(settingsbtn); 66 | expect(activeTab).toEqual('settings'); 67 | fireEvent.click(querybtn); 68 | expect(activeTab).toEqual('query'); 69 | }); 70 | }); 71 | //test the nav button 72 | 73 | describe('CacheTab', () => { 74 | it('renders CacheTab component correctly', () => { 75 | act(() => { 76 | fetch.mockResponseOnce( 77 | JSON.stringify({ 78 | server: [ 79 | { 80 | name: 'Redis version', 81 | }, 82 | ], 83 | }) 84 | ); 85 | render(); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /quell-extension/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | ], 6 | }; -------------------------------------------------------------------------------- /quell-extension/dist/assets/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/dist/assets/icon128.png -------------------------------------------------------------------------------- /quell-extension/dist/assets/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/dist/assets/icon16.png -------------------------------------------------------------------------------- /quell-extension/dist/assets/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/dist/assets/icon32.png -------------------------------------------------------------------------------- /quell-extension/dist/assets/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/dist/assets/icon48.png -------------------------------------------------------------------------------- /quell-extension/dist/assets/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/dist/assets/search.png -------------------------------------------------------------------------------- /quell-extension/dist/background.bundle.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/dist/background.bundle.js -------------------------------------------------------------------------------- /quell-extension/dist/devtools.bundle.js: -------------------------------------------------------------------------------- 1 | chrome.devtools.panels.create("Quell",null,"panel.html"); -------------------------------------------------------------------------------- /quell-extension/dist/devtools.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /quell-extension/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Quell Devtools 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /quell-extension/dist/manifest.json: -------------------------------------------------------------------------------- 1 | {"description":"Developer tool for the Quell JavaScript library: https://quell.dev","version":"2.0","manifest_version":3,"name":"Quell Developer Tool","homepage_url":"https://quell.dev","author":"Michael Lav, Lenny Yambao, Jonah Weinbaum, Justin Hua, Chang Cai, Robert Howton, Joshua Jordan, Angelo Chengcuenca, Emily Hoang, Keely Timms, Yusuf Bhaiyat","action":{"default_icon":{"16":"./assets/icon16.png","48":"./assets/icon48.png","128":"./assets/icon128.png"},"default_title":"Quell Developer Tool"},"devtools_page":"devtools.html","icons":{"16":"./assets/icon16.png","48":"./assets/icon48.png","128":"./assets/icon128.png"},"background":{"service_worker":"background.bundle.js","type":"module"}} -------------------------------------------------------------------------------- /quell-extension/dist/panel.bundle.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /** 8 | * @license React 9 | * use-sync-external-store-shim.production.min.js 10 | * 11 | * Copyright (c) Facebook, Inc. and its affiliates. 12 | * 13 | * This source code is licensed under the MIT license found in the 14 | * LICENSE file in the root directory of this source tree. 15 | */ 16 | 17 | /** 18 | * @license React 19 | * use-sync-external-store-shim/with-selector.production.min.js 20 | * 21 | * Copyright (c) Facebook, Inc. and its affiliates. 22 | * 23 | * This source code is licensed under the MIT license found in the 24 | * LICENSE file in the root directory of this source tree. 25 | */ 26 | 27 | /** @license React v0.20.2 28 | * scheduler.production.min.js 29 | * 30 | * Copyright (c) Facebook, Inc. and its affiliates. 31 | * 32 | * This source code is licensed under the MIT license found in the 33 | * LICENSE file in the root directory of this source tree. 34 | */ 35 | 36 | /** @license React v17.0.2 37 | * react-dom.production.min.js 38 | * 39 | * Copyright (c) Facebook, Inc. and its affiliates. 40 | * 41 | * This source code is licensed under the MIT license found in the 42 | * LICENSE file in the root directory of this source tree. 43 | */ 44 | 45 | /** @license React v17.0.2 46 | * react-jsx-runtime.production.min.js 47 | * 48 | * Copyright (c) Facebook, Inc. and its affiliates. 49 | * 50 | * This source code is licensed under the MIT license found in the 51 | * LICENSE file in the root directory of this source tree. 52 | */ 53 | 54 | /** @license React v17.0.2 55 | * react.production.min.js 56 | * 57 | * Copyright (c) Facebook, Inc. and its affiliates. 58 | * 59 | * This source code is licensed under the MIT license found in the 60 | * LICENSE file in the root directory of this source tree. 61 | */ 62 | -------------------------------------------------------------------------------- /quell-extension/dist/panel.html: -------------------------------------------------------------------------------- 1 | Quell Devtools
-------------------------------------------------------------------------------- /quell-extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quell_devtools_extension", 3 | "jest": { 4 | "verbose": true, 5 | "moduleNameMapper": { 6 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "./__mocks__/fileMock.js", 7 | "\\.(css|less|scss|sass)$": "identity-obj-proxy" 8 | }, 9 | "testEnvironment": "jsdom" 10 | }, 11 | "version": "2.0.0", 12 | "description": "Quell devtool to help visualize and query GraphQL ", 13 | "main": "index.js", 14 | "scripts": { 15 | "serve": "webpack serve --mode development", 16 | "build": "webpack --mode production", 17 | "test": "jest --forceExit" 18 | }, 19 | "author": "Quell", 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/open-source-labs/Quell/tree/main/quell-extension" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/open-source-labs/Quell/issues" 27 | }, 28 | "keywords": [ 29 | "cache-invalidation", 30 | "LokiJS", 31 | "graphQL", 32 | "cache", 33 | "caching", 34 | "redis", 35 | "batching", 36 | "client-side", 37 | "server-side", 38 | "apollo", 39 | "quell" 40 | ], 41 | "contributors": [ 42 | { 43 | "name": "Chang Cai", 44 | "url": "https://github.com/ccai89" 45 | }, 46 | { 47 | "name": "Robert Howton", 48 | "url": "https://github.com/roberthowton" 49 | }, 50 | { 51 | "name": "Joshua Jordan", 52 | "url": "https://github.com/jjordan-90" 53 | }, 54 | { 55 | "name": "Jinhee Choi", 56 | "url": "https://github.com/jcroadmovie" 57 | }, 58 | { 59 | "name": "Nayan Parmar", 60 | "url": "https://github.com/nparmar1" 61 | }, 62 | { 63 | "name": "Tashrif Sanil", 64 | "url": "https://github.com/tashrifsanil" 65 | }, 66 | { 67 | "name": "Tim Frenzel", 68 | "url": "(https://github.com/TimFrenzel" 69 | }, 70 | { 71 | "name": "Thomas Reeder", 72 | "url": "https://github.com/nomtomnom" 73 | }, 74 | { 75 | "name": "Ken Litton", 76 | "url": "https://github.com/kenlitton" 77 | }, 78 | { 79 | "name": "Robleh Farah", 80 | "url": "https://github.com/farahrobleh" 81 | }, 82 | { 83 | "name": "Angela Franco", 84 | "url": "https://github.com/ajfranco18" 85 | }, 86 | { 87 | "name": "Andrei Cabrera", 88 | "url": "https://github.com/Andreicabrerao" 89 | }, 90 | { 91 | "name": "Dasha Kondratenko", 92 | "url": "https://github.com/dasha-k" 93 | }, 94 | { 95 | "name": "Derek Sirola", 96 | "url": "https://github.com/dsirola1" 97 | }, 98 | { 99 | "name": "Xiao Yu Omeara", 100 | "url": "https://github.com/xyomeara" 101 | }, 102 | { 103 | "name": "Mike Lauri", 104 | "url": "https://github.com/MichaelLauri" 105 | }, 106 | { 107 | "name": "Rob Nobile", 108 | "url": "https://github.com/RobNobile" 109 | }, 110 | { 111 | "name": "Justin Jaeger", 112 | "url": "https://github.com/justinjaeger" 113 | }, 114 | { 115 | "name": "Nick Kruckenberg", 116 | "url": "https://github.com/kruckenberg" 117 | }, 118 | { 119 | "name": "Angelo Chengcuenca", 120 | "url": "https://github.com/amchengcuenca" 121 | }, 122 | { 123 | "name": "Emily Hoang", 124 | "url": "https://github.com/emilythoang" 125 | }, 126 | { 127 | "name": "Keely Timms", 128 | "url": "https://github.com/keelyt" 129 | }, 130 | { 131 | "name": "Yusuf Bhaiyat", 132 | "url": "https://github.com/yusuf-bha" 133 | }, 134 | { 135 | "name": "Jonah Weinbaum", 136 | "url": "https://github.com/jonahpw" 137 | }, 138 | { 139 | "name": "Justin Hua", 140 | "url": "https://github.com/justinfhua" 141 | }, 142 | { 143 | "name": "Lenny Yambao", 144 | "url": "https://github.com/lennin6" 145 | }, 146 | { 147 | "name": "Michael Lav", 148 | "url": "https://github.com/mikelav258" 149 | } 150 | ], 151 | "homepage": "http://quell.dev", 152 | "dependencies": { 153 | "@monaco-editor/react": "^4.5.1", 154 | "@testing-library/jest-dom": "^5.16.1", 155 | "@testing-library/user-event": "^13.5.0", 156 | "@types/chrome": "^0.0.172", 157 | "codemirror": "^5.64.0", 158 | "codemirror-graphql": "^1.2.4", 159 | "graphql": "^16.6.0", 160 | "graphql-tag": "^2.12.6", 161 | "jest-environment-jsdom": "^27.4.4", 162 | "jest-fetch-mock": "^3.0.3", 163 | "react": "^17.0.2", 164 | "react-codemirror2-react-17": "^1.0.0", 165 | "react-dom": "^17.0.2", 166 | "react-split-pane": "^0.1.92", 167 | "react-table": "^7.7.0", 168 | "react-trend": "^1.2.5", 169 | "reactflow": "^11.7.2" 170 | }, 171 | "devDependencies": { 172 | "@babel/core": "^7.12.13", 173 | "@babel/preset-env": "^7.12.13", 174 | "@babel/preset-react": "^7.12.13", 175 | "@babel/preset-typescript": "^7.16.0", 176 | "@emotion/react": "^11.7.0", 177 | "@emotion/styled": "^11.6.0", 178 | "@testing-library/dom": "^8.11.1", 179 | "@testing-library/jest-dom": "^5.16.1", 180 | "@testing-library/user-event": "^13.5.0", 181 | "@types/jest": "^27.0.3", 182 | "@types/react": "^17.0.37", 183 | "@types/react-dom": "^17.0.11", 184 | "antd": "^4.17.2", 185 | "babel-jest": "^27.4.4", 186 | "babel-loader": "^8.2.2", 187 | "codemirror": "^5.64.0", 188 | "codemirror-graphql": "^1.2.4", 189 | "copy-webpack-plugin": "^10.0.0", 190 | "css-loader": "^5.0.1", 191 | "css-modules-typescript-loader": "^4.0.1", 192 | "file-loader": "^6.2.0", 193 | "fs": "^0.0.1-security", 194 | "html-webpack-plugin": "^5.5.0", 195 | "identity-obj-proxy": "^3.0.0", 196 | "jest": "^27.4.4", 197 | "jest-dom": "^4.0.0", 198 | "sass": "^1.32.6", 199 | "sass-loader": "^10.1.1", 200 | "source-map-loader": "^3.0.0", 201 | "style-loader": "^2.0.0", 202 | "typescript": "^4.5.2", 203 | "url-loader": "^4.1.1", 204 | "webpack": "^5.64.4", 205 | "webpack-cli": "^4.9.1", 206 | "webpack-dev-server": "^4.6.0" 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /quell-extension/src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Quell Developer Tool", 4 | "description": "Developer tool for the Quell JavaScript library: https://quell.dev", 5 | "version": "2.0", 6 | "homepage_url": "https://quell.dev", 7 | "author": "Michael Lav, Lenny Yambao, Jonah Weinbaum, Justin Hua, Chang Cai, Robert Howton, Joshua Jordan, Angelo Chengcuenca, Emily Hoang, Keely Timms, Yusuf Bhaiyat", 8 | "action": { 9 | "default_icon": { 10 | "16": "./assets/icon16.png", 11 | "48": "./assets/icon48.png", 12 | "128": "./assets/icon128.png" 13 | }, 14 | "default_title": "Quell Developer Tool" 15 | }, 16 | "devtools_page": "devtools.html", 17 | "icons": { 18 | "16": "./assets/icon16.png", 19 | "48": "./assets/icon48.png", 20 | "128": "./assets/icon128.png" 21 | }, 22 | "background": { 23 | "service_worker": "background.bundle.js", 24 | "type": "module" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Background/background.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/src/pages/Background/background.tsx -------------------------------------------------------------------------------- /quell-extension/src/pages/Background/index.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/src/pages/Background/index.tsx -------------------------------------------------------------------------------- /quell-extension/src/pages/Devtools/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Devtools/index.tsx: -------------------------------------------------------------------------------- 1 | // Leave this alone - simply to create a tab in Chrome DevTools 2 | 3 | chrome.devtools.panels.create( 4 | 'Quell', //input dev tool name 5 | null, //icon for the dev tool if any - may use null 6 | 'panel.html' //panel code 7 | ); 8 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/App.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import { useState, useEffect } from 'react'; 3 | import PrimaryNavBar from './Components/PrimaryNavBar'; 4 | import ServerTab from './Components/ServerTab'; 5 | import CacheTab from './Components/CacheTab'; 6 | import ClientTab from './Components/ClientTab'; 7 | import isGQLQuery from './helpers/isGQLQuery'; 8 | import { handleNavigate, handleRequestFinished } from './helpers/listeners'; 9 | 10 | // GraphQL 11 | import { getIntrospectionQuery, buildClientSchema } from "graphql"; 12 | import Settings from "./Components/Settings"; 13 | 14 | // Sample clientRequest data for building Network component 15 | import data from "./data/sampleClientRequests"; 16 | import { ClientRequest } from './interfaces/ClientRequest'; 17 | // import { IgnorePlugin } from "webpack"; 18 | 19 | const App = () => { 20 | // sets active tab - default: 'client' 21 | // other options - 'server', 'cache', 'settings' 22 | const [activeTab, setActiveTab] = useState("client"); 23 | 24 | // queried data results 25 | const [results, setResults] = useState({}); 26 | const [schema, setSchema] = useState({}); 27 | const [queryString, setQueryString] = useState(""); 28 | const [queryTimes, setQueryTimes] = useState([]); 29 | const [clientRequests, setClientRequests] = useState([]); 30 | 31 | // various routes to get information 32 | const [graphQLRoute, setGraphQLRoute] = useState("/api/graphql"); 33 | const [clientAddress, setClientAddress] = useState( 34 | "http://localhost:8080" 35 | ); 36 | const [serverAddress, setServerAddress] = useState( 37 | "http://localhost:3000" 38 | ); 39 | const [redisRoute, setRedisRoute] = useState("/api/redis"); 40 | const [clearCacheRoute, setClearCacheRoute] = useState("/api/clearCache"); 41 | 42 | // function to clear front end cache 43 | const handleClearCache = (): void => { 44 | const address = `${serverAddress}${clearCacheRoute}`; 45 | fetch(address) 46 | .then((data) => console.log(data)) 47 | .catch((err) => console.log(err)); 48 | }; 49 | 50 | // function used to listen to network requests and return any graphQL/Quell queries to populate client page 51 | const gqlListener = (request: ClientRequest): void => { 52 | if (isGQLQuery(request)) { 53 | request.getContent((body) => { 54 | const responseData = JSON.parse(body); 55 | request.responseData = responseData; 56 | setClientRequests((prev) => prev.concat([request])); 57 | }); 58 | } 59 | }; 60 | 61 | // function to listen to network requests and add query times to state 62 | const timeListener = (request: ClientRequest): void => { 63 | // if request was sent to the /api/queryTime route, add response body to queryTimes state 64 | request.getContent((body) => { 65 | const responseData = JSON.parse(body); 66 | if (responseData.time) { 67 | setQueryTimes((prev) => prev.concat([responseData.time])); 68 | } 69 | }); 70 | }; 71 | 72 | // COMMENT OUT IF WORKING FROM DEV SERVER 73 | useEffect(() => { 74 | handleRequestFinished(gqlListener); 75 | handleNavigate(gqlListener); 76 | 77 | handleRequestFinished(timeListener); 78 | handleNavigate(timeListener); 79 | }, []); 80 | 81 | useEffect(() => { 82 | const introspectionQuery = getIntrospectionQuery(); 83 | const address = `${serverAddress}${graphQLRoute}`; 84 | fetch(address, { 85 | method: "POST", 86 | headers: { 87 | Accept: "application/json", 88 | "Content-Type": "application/json", 89 | }, 90 | body: JSON.stringify({ 91 | query: introspectionQuery, 92 | costOptions: { maxDepth: 15, maxCost: 6000, ipRate: 22} 93 | }), 94 | }) 95 | .then((response) => response.json()) 96 | .then((data) => { 97 | const schema = buildClientSchema(data.queryResponse.data); 98 | setSchema(schema || 'No schema retreived'); 99 | }) 100 | .catch((err) => console.log(err)); 101 | }, [clientAddress, serverAddress, graphQLRoute]); 102 | 103 | // generates the page 104 | return ( 105 |
106 | 113 | 114 |
115 | {activeTab === "client" && ( 116 | 122 | )} 123 | 124 | {activeTab === "server" && ( 125 | <> 126 |
Query Quell Server
127 | 139 | 140 | )} 141 | 142 | {activeTab === "cache" && ( 143 |
144 | 149 |
150 | )} 151 | 152 | {activeTab === "settings" && ( 153 |
154 | 165 |
166 | )} 167 |
168 |
169 | ); 170 | }; 171 | 172 | export default App; 173 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/CacheTab.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import React, { useState, useEffect } from 'react'; 3 | import NavButton from './NavButton'; 4 | import CacheView from './CacheView'; 5 | import SearchImg from '../assets/search.png'; 6 | import { RedisInfo, RedisStats } from '../interfaces/RedisInfo'; 7 | 8 | type CacheTabProps = { 9 | serverAddress: string; 10 | redisRoute: string; 11 | handleClearCache: () => void; 12 | }; 13 | 14 | const CacheTab = ({ 15 | serverAddress, 16 | redisRoute, 17 | handleClearCache, 18 | }: CacheTabProps) => { 19 | //Store data from Redis server 20 | const [redisStats, setRedisStats] = useState({ 21 | server: [], 22 | client: [], 23 | memory: [], 24 | stats: [], 25 | }); 26 | const [redisKeys, setRedisKeys] = useState([]); 27 | const [redisValues, setRedisValues] = useState([]); 28 | const [activeTab, setActiveTab] = useState('server'); 29 | 30 | const fetchRedisInfo = () => { 31 | fetch(`${serverAddress}${redisRoute}`) 32 | .then((response) => response.json()) 33 | .then((data: RedisInfo) => { 34 | if (data.redisStats) setRedisStats(data.redisStats); 35 | if (data.redisKeys) setRedisKeys(data.redisKeys); 36 | if (data.redisValues) setRedisValues(data.redisValues); 37 | }) 38 | .catch((error) => 39 | console.log('error fetching from redis endpoint: ', error) 40 | ); 41 | }; 42 | 43 | useEffect(() => { 44 | fetchRedisInfo(); 45 | }, []); 46 | 47 | const genTable = (title: string) => { 48 | const output = []; 49 | if (title in redisStats) { 50 | for (let key in redisStats[title]) { 51 | // "Redis build id" value was just showing up as zeroes extending off the page, 52 | // and "Path to configuration file" had no value, 53 | // so we're just not going to show those two stats for now. 54 | if (redisStats[title][key].name === 'Redis build id') continue; 55 | if (redisStats[title][key].name === 'Path to configuration file') continue; 56 | output.push( 57 |
58 |
69 | {redisStats[title][key].name} 70 |
71 |
79 | {redisStats[title][key].value} 80 |
81 |
82 | ); 83 | } 84 | } 85 | return output; 86 | }; 87 | 88 | const [filter, setFilter] = useState(''); 89 | const activeStyle = { backgroundColor: '#444' }; 90 | const handleFilter = (e: React.ChangeEvent) => { 91 | setFilter(e.target.value); 92 | }; 93 | 94 | return ( 95 |
96 |
97 | Redis Database Status 98 | Quell Server Cache 99 |
100 | 101 |
102 |
103 |
104 | 110 | 111 | 117 | 118 | 124 | 125 | 131 |
132 | 133 |
134 | {activeTab === 'server' &&
{genTable('server')}
} 135 | 136 | {activeTab === 'client' &&
{genTable('client')}
} 137 | 138 | {activeTab === 'memory' &&
{genTable('memory')}
} 139 | 140 | {activeTab === 'stats' &&
{genTable('stats')}
} 141 |
142 | 143 | 150 | 151 | 161 |
162 | 163 |
164 |
165 | search 166 | 173 |
174 |
175 | 180 |
181 |
182 |
183 |
184 | ); 185 | }; 186 | 187 | export default CacheTab; 188 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/CacheView.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | const CacheView = ({ redisKeys, redisValues, filteredVal } = props) => { 4 | const getFilteredCache = () => { 5 | const temp = []; 6 | let i = 0; 7 | if (redisValues.length > 0) { 8 | while (i < redisKeys.length && i < redisValues.length) { 9 | if (redisKeys[i].includes(filteredVal)) 10 | temp.push( 11 |
12 | {redisKeys[i]} 13 | {redisValues[i]} 14 |
15 | ); 16 | i++; 17 | } 18 | } else if (redisKeys.length > 0) { 19 | redisKeys.forEach((el:object, i:number) => { 20 | temp.push( 21 |

22 | {el} 23 |

24 | ); 25 | }); 26 | } else 27 | temp.push( 28 |
29 | No keys or values returned. Your Redis cache may be empty, or the 30 | specified endpoint may not be configured to return keys and/or values. 31 | See the{" "} 32 | 33 | Quell docs 34 | {" "} 35 | for configuration instructions. 36 |
37 | ); 38 | return temp; 39 | }; 40 | 41 | return <>{getFilteredCache()}; 42 | }; 43 | 44 | export default CacheView; 45 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/ClientTab.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useMemo } from 'react'; 2 | import { useTable } from 'react-table'; 3 | import Metrics from './Metrics'; 4 | import SplitPane from 'react-split-pane'; 5 | import { Controlled as CodeMirror } from "react-codemirror2-react-17"; 6 | import 'codemirror/lib/codemirror.css'; 7 | import 'codemirror/theme/material-darker.css'; 8 | import 'codemirror/theme/xq-light.css'; 9 | import 'codemirror'; 10 | import 'codemirror/addon/lint/lint'; 11 | import 'codemirror/addon/hint/show-hint'; 12 | import 'codemirror-graphql/lint'; 13 | import 'codemirror-graphql/hint'; 14 | import 'codemirror-graphql/mode'; 15 | import NavButton from './NavButton'; 16 | import { getResponseStatus } from '../helpers/getResponseStatus'; 17 | import { getQueryString, getOperationNames } from '../helpers/parseQuery'; 18 | import { useEffect } from 'react'; 19 | import {Visualizer} from './Visualizer/Visualizer' 20 | 21 | const ClientTab = ({ graphQLRoute, clientAddress, clientRequests, queryTimes } = props) => { 22 | // allows for highlighting of selected row and saves row data in state to display upon clicking for more information 23 | // A value of '-1' indicates row is not selected and will display metrics, otherwise >= 0 is the index of the row 24 | const [activeRow, setActiveRow] = useState(-1); 25 | const [clickedRowData, setClickedRowData] = useState({}); 26 | 27 | return ( 28 |
29 |
Client Quell Requests
30 |
31 | 42 |
43 | 50 |
51 | {/* conditionally renders either the metrics or additional info about specific query*/} 52 | {activeRow > -1 ? ( 53 | 54 | ) : ( 55 |
59 | 0 62 | ? clientRequests[clientRequests.length - 1].time.toFixed(2) 63 | : 0 64 | } 65 | fetchTimeInt={ 66 | clientRequests.length > 0 67 | ? clientRequests.map((request) => request.time) 68 | : [0] 69 | } 70 | /> 71 |
72 | )} 73 |
74 |
75 |
76 | ); 77 | }; 78 | 79 | const RequestDetails = ({ clickedRowData, queryTime } = props) => { 80 | const [activeTab, setActiveTab] = useState('request'); 81 | const activeStyle = { 82 | backgroundColor: '#444', 83 | color: '#bbb', 84 | }; 85 | 86 | return ( 87 |
88 |
89 | 96 | 103 | 104 | 111 | 112 | 119 | 120 | 127 |
128 | 129 |
133 | {activeTab === 'display' && ( 134 | <> 135 | 138 | 139 | )} 140 | {activeTab === 'request' && ( 141 | <> 142 | {/*
Request Headers
*/} 143 | {clickedRowData.request.headers.map((header, index) => ( 144 |

145 | {header.name}: {header.value} 146 |

147 | ))} 148 | 149 | )} 150 | 151 | {activeTab === 'query' && ( 152 | <> 153 | 162 | 163 | )} 164 | 165 | {activeTab === 'response' && ( 166 | <> 167 | {/*
Response Headers
*/} 168 | {clickedRowData.response.headers.map((header, index) => ( 169 |

170 | {header.name}: {header.value} 171 |

172 | ))} 173 | 174 | )} 175 |
176 | 177 | {activeTab === 'data' && ( 178 | <> 179 | 188 | 189 | )} 190 |
191 | ); 192 | }; 193 | 194 | const NetworkRequestTable = ({ 195 | clientRequests, 196 | setClickedRowData, 197 | setActiveRow, 198 | activeRow, 199 | } = props) => { 200 | const handleRowClick = (cell) => { 201 | setClickedRowData(cell.row.original); 202 | }; 203 | 204 | const columns = useMemo( 205 | () => [ 206 | { 207 | id: 'number', 208 | Header: '#', 209 | accessor: (row, index) => index + 1, 210 | }, 211 | { 212 | // maybe instead of query type, use `graphql-tag` to display name of queried table/document 213 | id: 'query-type', 214 | Header: 'Operation Type(s)', 215 | // accessor: (row) => Object.keys(JSON.parse(row.request.postData.text)), 216 | accessor: (row) => getOperationNames(row), 217 | }, 218 | { 219 | id: 'url', 220 | Header: 'URL', 221 | accessor: (row) => row.request.url, 222 | }, 223 | { 224 | id: 'status', 225 | Header: 'Status', 226 | accessor: (row) => getResponseStatus(row), 227 | }, 228 | { 229 | id: 'size', 230 | Header: 'Size (kB)', 231 | accessor: (row) => (row.response.content.size / 1000).toFixed(2), 232 | }, 233 | { 234 | id: 'time', 235 | Header: 'Time (ms)', 236 | accessor: (row) => row.time.toFixed(2), 237 | }, 238 | ], 239 | [] 240 | ); 241 | 242 | // React Table suggests memoizing table data as best practice, to reduce computation 243 | // in populating table, but this prevents live updating on new client requests 244 | // const data = useMemo(() => [...clientRequests], []); 245 | const data = clientRequests; 246 | 247 | const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = 248 | useTable({ columns, data }); 249 | 250 | return ( 251 | <> 252 |
253 | 254 | 255 | {headerGroups.map((headerGroup) => ( 256 | 257 | {headerGroup.headers.map((column) => ( 258 | 261 | ))} 262 | 263 | ))} 264 | 265 | 266 | {rows.map((row) => { 267 | prepareRow(row); 268 | return ( 269 | 270 | {row.cells.map((cell) => { 271 | return ( 272 | 288 | ); 289 | })} 290 | 291 | ); 292 | })} 293 | 294 |
259 | {column.render('Header')} 260 |
{ 280 | if (activeRow !== cell.row.id) 281 | setActiveRow(cell.row.id); 282 | else setActiveRow(-1); 283 | handleRowClick(cell); 284 | }} 285 | > 286 |
{cell.render('Cell')}
287 |
295 |
296 | 297 | ); 298 | }; 299 | 300 | export default ClientTab; -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/InputEditor.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React, { useState } from 'react'; 3 | import { Controlled as CodeMirror } from "react-codemirror2-react-17"; 4 | import 'codemirror/lib/codemirror.css'; 5 | import 'codemirror/theme/material-darker.css'; 6 | import 'codemirror/theme/xq-light.css'; 7 | import 'codemirror'; 8 | import 'codemirror/addon/lint/lint'; 9 | import 'codemirror/addon/hint/show-hint'; 10 | import 'codemirror-graphql/lint'; 11 | import 'codemirror-graphql/hint'; 12 | import 'codemirror-graphql/mode'; 13 | 14 | const InputEditor = (props) => { 15 | const [defaultText, setText] = useState( 16 | '# Enter GraphQL query here\n' 17 | ); 18 | const [queryTimes, setQueryTimes] = useState([0]); 19 | 20 | const handleClickSubmit = () => { 21 | let startT = performance.now(); 22 | const query = props.queryString; 23 | const address = `${props.serverAddress}${props.graphQLRoute}`; 24 | fetch(address, { 25 | method: 'POST', 26 | headers: { 27 | Accept: 'application/json', 28 | 'Content-Type': 'application/json', 29 | }, 30 | body: JSON.stringify({ 31 | query: query, 32 | costOptions: { maxDepth: 15, maxCost: 6000, ipRate: 22} 33 | }), 34 | }) 35 | .then((response) => response.json()) 36 | .then((data) => props.setResults(data.queryResponse)) 37 | .then(() => props.logNewTime(performance.now() - startT)) 38 | .catch((err) => props.setResults(err)); 39 | }; 40 | 41 | return ( 42 |
43 | { 53 | setText(value); 54 | }} 55 | // sends Query to parent componet to be processed 56 | onChange={(editor, data, value) => { 57 | props.setQueryString(value); 58 | }} 59 | /> 60 |
68 | 71 | 74 |
75 |
76 | ); 77 | }; 78 | 79 | export default InputEditor; 80 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/Metrics.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | import React, { useState, useEffect } from 'react'; 4 | import Trend from 'react-trend'; 5 | 6 | const Metrics = (props) => { 7 | const { 8 | fetchTime, // time to fetch request 9 | fetchTimeInt, // array of time values at each point in fetching and caching 10 | } = props; 11 | const avgFetchTime = fetchTimeInt[0] ? (fetchTimeInt.reduce((a:number, b:number) => a+b, 0)/fetchTimeInt.length).toFixed(2) + " ms": " - ms"; 12 | 13 | return ( 14 |
15 |
Metrics:
16 |
17 |
Latest query/mutation time:
18 |
{fetchTime ? fetchTime + " ms" :" - ms"}
19 |
Average cache time: {avgFetchTime}
20 |
21 |
22 |

Speed Graph:

23 | =250 ? Number(`${window.innerHeight}`)-220 : 30} 25 | // width={Number(window.innerWidth / 5)} 26 | className="trend" 27 | data={fetchTimeInt} 28 | gradient={['#1feaea','#ffd200', '#f72047']} 29 | radius={0.9} 30 | strokeWidth={2.2} 31 | strokeLinecap={'round'} 32 | /> 33 |
34 |
35 | ); 36 | }; 37 | 38 | export default Metrics; -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/NavButton.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | /* eslint-disable react/react-in-jsx-scope */ 3 | const NavButton = ({ 4 | text, 5 | activeTab, 6 | setActiveTab, 7 | altText, 8 | altClass 9 | } = props) => { 10 | 11 | return ( 12 | 25 | ) 26 | } 27 | 28 | export default NavButton; -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/OutputEditor.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import { useState, useEffect } from 'react'; 3 | import { Controlled as CodeMirror } from "react-codemirror2-react-17"; 4 | import 'codemirror/lib/codemirror.css'; 5 | import 'codemirror/theme/material-darker.css'; 6 | import 'codemirror/theme/xq-light.css'; 7 | 8 | const OutputEditor = ({results}) => { 9 | const [output, setOutput] = useState('# GraphQL query results') 10 | 11 | useEffect(() => { 12 | if (Object.keys(results).length > 0) { 13 | setOutput(JSON.stringify(results, null, 2)); 14 | } 15 | }, [results]) 16 | 17 | return( 18 | 27 | ); 28 | }; 29 | 30 | export default OutputEditor; -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/PrimaryNavBar.tsx: -------------------------------------------------------------------------------- 1 | import NavButton from './NavButton'; 2 | import Logo from '../assets/icon128.png'; 3 | 4 | const PrimaryNavBar = ({ 5 | activeTab, 6 | setActiveTab, 7 | graphQL_field, 8 | server_field, 9 | redis_field 10 | } = props) => { 11 | 12 | const goToSettings = () => { 13 | setActiveTab('settings'); 14 | } 15 | 16 | return ( 17 | 49 | ); 50 | }; 51 | 52 | export default PrimaryNavBar; 53 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/QueryTab.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | /* eslint-disable react/prop-types */ 3 | import {useState} from 'react'; 4 | import InputEditor from './InputEditor'; 5 | import OutputEditor from './OutputEditor'; 6 | import Metrics from './Metrics'; 7 | import SplitPane from 'react-split-pane'; 8 | 9 | const QueryTab = ({ 10 | clientAddress, 11 | serverAddress, 12 | graphQLRoute, 13 | queryString, 14 | setQueryString, 15 | setResults, 16 | schema, 17 | clearCacheRoute, 18 | results 19 | } = props) => { 20 | 21 | // storing response times for each query as an array 22 | const [queryResponseTime, setQueryResponseTime] = useState([]); 23 | 24 | // grabbing the time to query results and rounding to two digits 25 | const logNewTime = (recordedTime: number) => { 26 | setQueryResponseTime( 27 | queryResponseTime.concat(Number(recordedTime.toFixed(2))) 28 | ); 29 | }; 30 | 31 | return ( 32 |
33 |
34 | 35 |
36 | 47 |
48 | 49 |
50 | 51 |
52 |
53 |
54 |
55 | 59 |
60 |
61 | ) 62 | } 63 | 64 | export default QueryTab; -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/ServerTab.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import InputEditor from "./InputEditor"; 3 | import OutputEditor from "./OutputEditor"; 4 | import Metrics from "./Metrics"; 5 | import SplitPane from "react-split-pane"; 6 | 7 | const ServerTab = ({ 8 | clientAddress, 9 | serverAddress, 10 | graphQLRoute, 11 | queryString, 12 | setQueryString, 13 | setResults, 14 | schema, 15 | results, 16 | handleClearCache, 17 | } = props) => { 18 | // storing response times for each query as an array 19 | const [queryResponseTime, setQueryResponseTime] = useState([]); 20 | 21 | // grabbing the time to query results and rounding to two digits 22 | const logNewTime = (recordedTime: number) => { 23 | setQueryResponseTime( 24 | queryResponseTime.concat(Number(recordedTime.toFixed(2))) 25 | ); 26 | }; 27 | 28 | return ( 29 |
30 | 36 |
37 | 38 |
39 | 50 |
51 | 52 |
53 | 54 |
55 |
56 |
57 |
58 | 62 |
63 |
64 |
65 | ); 66 | }; 67 | 68 | export default ServerTab; 69 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/Settings.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React, { useState } from 'react'; 3 | import { Controlled as CodeMirror } from "react-codemirror2-react-17"; 4 | import 'codemirror/lib/codemirror.css'; 5 | import 'codemirror/theme/material-darker.css'; 6 | import 'codemirror/theme/xq-light.css'; 7 | import 'codemirror'; 8 | import 'codemirror/addon/lint/lint'; 9 | import 'codemirror/addon/hint/show-hint'; 10 | import 'codemirror-graphql/lint'; 11 | import 'codemirror-graphql/hint'; 12 | import 'codemirror-graphql/mode'; 13 | 14 | const Settings = ({ 15 | graphQLRoute, 16 | setGraphQLRoute, 17 | serverAddress, 18 | setServerAddress, 19 | redisRoute, 20 | setRedisRoute, 21 | schema, 22 | clearCacheRoute, 23 | setClearCacheRoute, 24 | } = props) => { 25 | const [editorText, setEditorText] = useState(JSON.stringify(schema, null, 2)); 26 | 27 | const inputArea = (_id:string, func, defaultVal) => { 28 | return ( 29 |
30 | {`${_id}`} 31 |
32 | func(e.target.value)} 35 | value={`${defaultVal}`} 36 | /> 37 |
38 | ); 39 | }; 40 | 41 | return ( 42 | 43 |
44 |
Basic Configuration
45 |
46 | {inputArea("GraphQL Route", setGraphQLRoute, graphQLRoute)} 47 |
51 | {`${ 52 | graphQLRoute === "" ? "*REQUIRED!* Please enter e" : "E" 53 | }ndpoint where GraphQL schema will be retrieved and queries sent.`} 54 |
55 | {inputArea("Server Address", setServerAddress, serverAddress)} 56 |
60 | {`${ 61 | serverAddress === "" ? "*REQUIRED!* Please enter " : "" 62 | }HTTP address of server from which Quell makes GraphQL queries.`} 63 |
64 | {inputArea("Redis Route", setRedisRoute, redisRoute)} 65 |
69 | {`${ 70 | redisRoute === "" ? "*REQUIRED!* Please enter e" : "E" 71 | }ndpoint where `}QuellCache.getRedisInfo{` middleware is configured.`} 72 |
73 | {inputArea("Clear Cache Route", setClearCacheRoute, clearCacheRoute)} 74 |
75 | {`Endpoint where `}QuellCache.clearCache{` endpoint is configured.`} 76 |
77 |
78 |
79 | 80 |
81 |
Retrieved GraphQL Schema
82 | 90 |
91 |
92 | ); 93 | }; 94 | 95 | export default Settings; 96 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/Visualizer/FlowTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { parse, DocumentNode, SelectionSetNode, OperationDefinitionNode } from 'graphql'; 3 | import { Controlled as CodeMirror } from 'react-codemirror2-react-17'; 4 | import styles from './Visualizer.modules.css'; 5 | 6 | // defining the expected type 7 | interface Props { 8 | query: string; 9 | elapsed: { [key: string]: number }; 10 | } 11 | 12 | // The FC stands for Function Component 13 | const FlowTable: React.FC = ({ query, elapsed }) => { 14 | const [queryOperations, setQueryOperations] = useState([]); 15 | const [elapsedTime, setElapsedTime] = useState<{ [key: string]: number }>(elapsed); 16 | const editorRef = useRef(null); 17 | 18 | // Set elapsed time 19 | useEffect(() => { 20 | setElapsedTime(elapsed); 21 | }, [query, elapsed]); 22 | 23 | // The useEffect parse the query and generate the operation order 24 | useEffect(() => { 25 | const operation = parseQuery(query); 26 | if (operation) { 27 | setElapsedTime(elapsed); 28 | const operationOrder = generateOperationOrder(operation); 29 | setQueryOperations(operationOrder); 30 | } 31 | }, [elapsedTime]); 32 | 33 | 34 | // Parses the query, returning the selection set 35 | const parseQuery = (query: string): SelectionSetNode | OperationDefinitionNode | undefined => { 36 | const ast: DocumentNode = parse(query); 37 | 38 | if (ast.definitions.length === 1) { 39 | const definition = ast.definitions[0]; 40 | if (definition.kind === 'OperationDefinition') { 41 | return definition.selectionSet; 42 | } else if (definition.kind === 'FragmentDefinition') { 43 | return definition.selectionSet; 44 | } 45 | } 46 | 47 | return undefined; 48 | }; 49 | 50 | // Function that takes the query and returns an array of operations in order of the query 51 | const generateOperationOrder = (operation: SelectionSetNode | OperationDefinitionNode | any, parentName = ''): string[] => { 52 | const operationOrder: string[] = []; 53 | if (!operation) { 54 | return operationOrder; 55 | } 56 | // Iterate over the selection in the operation 57 | operation.selections.forEach((selection: { name: { value: any; }; selectionSet: OperationDefinitionNode | SelectionSetNode; }) => { 58 | if ('name' in selection) { 59 | let fieldName = parentName ? `${parentName}.${selection.name.value}` : selection.name.value; 60 | if (elapsedTime[selection.name.value] && operationOrder.length > 1) { 61 | const newName = fieldName + ` [resolved in ${elapsedTime[selection.name.value]}ms]`; 62 | operationOrder.push(newName); 63 | } else{ 64 | operationOrder.push(fieldName) 65 | }; 66 | // Recursively generate the operation order for nested selection 67 | if ('selectionSet' in selection) { 68 | const nestedSelections = generateOperationOrder(selection.selectionSet, fieldName); 69 | operationOrder.push(...nestedSelections); 70 | } 71 | } 72 | }); 73 | 74 | return operationOrder; 75 | }; 76 | 77 | const handleEditorDidMount = (editor: any) => { 78 | editorRef.current = editor; 79 | }; 80 | 81 | return ( 82 |
83 | 93 |
94 | ); 95 | }; 96 | 97 | export default FlowTable; -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/Visualizer/FlowTree.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react'; 2 | import ReactFlow, { Controls, Background, applyEdgeChanges, applyNodeChanges, MiniMap, NodeChange, EdgeChange, Edge, Node, MarkerType, XYPosition } from 'reactflow'; 3 | import { parse, DocumentNode, FieldNode, SelectionNode, OperationDefinitionNode } from 'graphql'; 4 | 5 | // Type for NodeData 6 | // data describes the content of the node 7 | interface NodeData { 8 | id: string; 9 | data?: { label: string } ; 10 | position?: { 11 | x: number; 12 | y: number; 13 | }; 14 | style?: any; 15 | type?: string; 16 | } 17 | 18 | // Type for FlowElement 19 | interface FlowElement extends NodeData { 20 | id: string; 21 | position?: Position; 22 | target: string; 23 | source: string; 24 | animated?: boolean | undefined; 25 | label?: any; 26 | markerEnd?: { 27 | type: MarkerType; 28 | width?: number; 29 | height?: number; 30 | color?: string; 31 | } | string; 32 | style?: any; 33 | labelStyle?: any; 34 | labelBgBorderRadius?: any; 35 | } 36 | 37 | interface Position { 38 | x: number; 39 | y: number; 40 | } 41 | 42 | // Declares prop x on Position 43 | interface PositionWithX extends Position { 44 | x: number; 45 | } 46 | 47 | // Convert AST field to React Flow node 48 | const getNode = ( 49 | node: FieldNode | SelectionNode | OperationDefinitionNode, 50 | depth: number, 51 | siblingIndex: number, 52 | numSiblings: number, 53 | numNodes: number, 54 | parentPosition?: Position, 55 | ): NodeData => { 56 | const label = node.kind === 'Field' ? node.name.value : node.kind; 57 | const id = `${node.loc?.start}-${node.loc?.end}`; 58 | const parentX = parentPosition ? (parentPosition as PositionWithX).x : 0; 59 | const x = ((siblingIndex + 0.3) / 3) * 400 + 230 ; 60 | return { 61 | id: id!, 62 | data: {label}, 63 | position: { 64 | y: 100 + depth * 100, 65 | x: parentX + x - (numSiblings / 2) * 290, 66 | }, 67 | style: { 68 | width: 125, 69 | height: 30, 70 | fontSize: 18, 71 | border: `none`, 72 | borderRadius: 12, 73 | boxShadow: `0px 0px 3px #11262C`, 74 | padding: `2px 0px 0px 0px` 75 | } 76 | }; 77 | }; 78 | 79 | // Gets edge connection between parent and child nodes 80 | // edge is the line that visually connects the parent and child node 81 | 82 | const getEdge = (parent: FieldNode, child: SelectionNode, elapsed: any): FlowElement => { 83 | const parentId = `${parent.loc?.start}-${parent.loc?.end}`; 84 | const childId = `${child.loc?.start}-${child.loc?.end}`; 85 | const edgeProps : FlowElement = { 86 | id: `${parentId}-${childId}`, 87 | source: parentId, 88 | target: childId, 89 | animated: false, 90 | markerEnd: { 91 | type: MarkerType.ArrowClosed, 92 | width: 10, 93 | height: 10, 94 | color: '#03C6FF' 95 | }, 96 | style: { 97 | strokeWidth: 2, 98 | stroke: '#03C6FF' 99 | }, 100 | labelStyle: { 101 | fontSize: 14, 102 | }, 103 | labelBgBorderRadius: 10, 104 | }; 105 | 106 | const childNode = child as FieldNode; 107 | if(elapsed[childNode.name.value]){ 108 | edgeProps.label = `${elapsed[childNode.name.value]}ms`; 109 | } 110 | return edgeProps; 111 | }; 112 | 113 | // Recursively constructs a tree structure from GraphQL AST 114 | const buildTree = ( 115 | node: FieldNode | SelectionNode, 116 | nodes: NodeData[], 117 | edges: FlowElement[], 118 | elapsed: {}, 119 | depth = 0, 120 | siblingIndex = 0, 121 | numSiblings = 1, 122 | parentPosition?: Position 123 | ): void => { 124 | // Gets the parent node and pushes it into the nodes array 125 | const parent = getNode(node, depth, siblingIndex, numSiblings, numSiblings, parentPosition); 126 | nodes.push(parent); 127 | // The selectionSet means that it has child nodes 128 | if (node.kind === 'Field' && node.selectionSet) { 129 | const numChildren = node.selectionSet.selections.length; 130 | // Call getNode for each child node 131 | node.selectionSet.selections.forEach((childNode, i) => { 132 | const child = getNode(childNode, depth + 1, i, numChildren, numSiblings, parent.position); 133 | // Pushes the child node and edge into the respective arrays 134 | edges.push(getEdge(node as FieldNode, childNode, elapsed)); 135 | buildTree(childNode, nodes, edges, elapsed, depth + 1, i, numChildren, parent.position); 136 | }); 137 | } 138 | }; 139 | 140 | 141 | 142 | // Takes the AST and returns nodes and edges as arrays for ReactFlow to render 143 | const astToTree = (query: string, elapsed: {}): { nodes: NodeData[]; edges: FlowElement[] } => { 144 | const ast: DocumentNode = parse(query); 145 | const operation = ast.definitions.find( 146 | (def) => def.kind === 'OperationDefinition' && def.selectionSet 147 | ); 148 | if (!operation) { 149 | throw new Error('No operation definition found in query'); 150 | } 151 | const selections = (operation as OperationDefinitionNode).selectionSet.selections; 152 | const nodes: NodeData[] = []; 153 | const edges: FlowElement[] = []; 154 | let currentX = 0; // Adjust the initial x position for the first tree 155 | let currentY = 0; // Adjust the initial y position for the first tree 156 | 157 | selections.forEach((selection, index) => { 158 | const numSiblings = selections.length; 159 | const siblingIndex = index; 160 | const x = ((siblingIndex + 0.5) / numSiblings) * 900 + currentX; 161 | const y = 100 + currentY; 162 | 163 | buildTree(selection, nodes, edges, elapsed, 0, siblingIndex, numSiblings, { x, y }); 164 | 165 | }); 166 | 167 | return { nodes, edges }; 168 | }; 169 | 170 | // Render a tree graph from GraphQL AST 171 | const FlowTree: React.FC<{query: string, elapsed: {}}> = ({query, elapsed}) => { 172 | const [currentQuery, setCurrentQuery] = useState(query); 173 | const [elapsedTime, setElapsedTime] = useState(elapsed); 174 | 175 | // update the state of nodes and edges when query changes 176 | useEffect(() => { 177 | const { nodes: newNodes, edges: newEdges } = astToTree(query, elapsedTime); 178 | const nodes = newNodes.map(node => ({ 179 | id: node.id, 180 | data: node.data, 181 | position: node.position!, 182 | style: node.style 183 | })); 184 | setNodes(nodes); 185 | setEdges(newEdges); 186 | setCurrentQuery(query); 187 | setElapsedTime(elapsed); 188 | } , [query, currentQuery, elapsed, elapsedTime]); 189 | 190 | const { nodes, edges } = astToTree(query, elapsedTime); 191 | // storing the initial values of the nodes and edges 192 | const [newNodes, setNodes] = useState(nodes); 193 | const [newEdges, setEdges] = useState(edges); 194 | 195 | // setNodes/setEdges updates the state of the component causing it to re-render 196 | const onNodesChange = useCallback( (changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)),[] ); 197 | const onEdgesChange = useCallback( (changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)),[] ); 198 | 199 | // Remove React Flow watermark 200 | const proOptions = { hideAttribution: true }; 201 | 202 | return ( 203 | []} 205 | edges={newEdges as Edge[]} 206 | onNodesChange={onNodesChange} 207 | onEdgesChange={onEdgesChange} 208 | fitView 209 | proOptions={proOptions} 210 | > 211 | 212 | 213 | 214 | 215 | ); 216 | }; 217 | 218 | 219 | export default FlowTree; 220 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/Visualizer/Visualizer.modules.css: -------------------------------------------------------------------------------- 1 | .graphContainer { 2 | display: flex; 3 | flex-direction: column; 4 | height: 90vh; 5 | width: 100%; 6 | gap:10px 7 | } 8 | 9 | .flowTree { 10 | min-height: 300px; 11 | border: none; 12 | border-radius: 10px; 13 | background:#474F57; 14 | width: 95%; 15 | } 16 | 17 | .flowTable { 18 | margin-top: 0; 19 | height: 200px; 20 | width: 95%; 21 | padding: 5px; 22 | border-radius: 10px; 23 | border: 1px solid rgb(161, 183, 202); 24 | } 25 | 26 | .codemirror { 27 | height: 200px; 28 | width: 100%; 29 | overflow: scroll; 30 | } -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/Visualizer/Visualizer.modules.css.d.ts: -------------------------------------------------------------------------------- 1 | // This file is automatically generated. 2 | // Please do not change this file! 3 | interface CssExports { 4 | 'codemirror': string; 5 | 'flowTable': string; 6 | 'flowTree': string; 7 | 'graphContainer': string; 8 | } 9 | export const cssExports: CssExports; 10 | export default cssExports; 11 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/Components/Visualizer/Visualizer.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Visualizer.modules.css'; 2 | import FlowTree from "./FlowTree"; 3 | import FlowTable from "./FlowTable"; 4 | 5 | // Container that renders the flow tree and flow table 6 | export function Visualizer({ query, elapsed }: VisualizerProps) { 7 | // If no query time is passed, set elapsed to an empty object to avoid app crash 8 | const elapsedProp = elapsed !== null && elapsed !== undefined ? elapsed : {}; 9 | 10 | return ( 11 |
12 |

Execution Tree

13 |
14 | 15 |
16 |

Execution Table

17 |
18 | 19 |
20 |
21 | ); 22 | } 23 | 24 | interface VisualizerProps { 25 | query: string; 26 | elapsed: {}; 27 | } 28 | 29 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/assets/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/src/pages/Panel/assets/icon128.png -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/assets/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/src/pages/Panel/assets/icon16.png -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/assets/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/src/pages/Panel/assets/icon32.png -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/assets/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/src/pages/Panel/assets/icon48.png -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/assets/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-labs/Quell/cede780bdae7817c4a267e70d746796cc94e5325/quell-extension/src/pages/Panel/assets/search.png -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/global.scss: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;500&display=swap"); 2 | 3 | /******* Global CSS for extension *******/ 4 | html { 5 | background-color: #222; 6 | font-family: "Roboto", sans-serif; 7 | color: #ddd; 8 | overflow-y: hidden; 9 | 10 | a:link, 11 | a:visited, 12 | a:hover, 13 | a:active { 14 | color: #7cd1e9; 15 | font-weight: bold; 16 | text-decoration: none; 17 | } 18 | 19 | &::-webkit-scrollbar { 20 | width: 0px; 21 | } 22 | } 23 | 24 | p { 25 | font-size: 0.8rem; 26 | } 27 | 28 | .title_bar { 29 | font-size: 1.5rem; 30 | font-weight: bold; 31 | margin: -28px 0 4px; 32 | } 33 | 34 | .devtools { 35 | overflow-y: none; 36 | } 37 | 38 | .metrics-container { 39 | height: calc(100vh - 80px); 40 | overflow-y: none; 41 | } 42 | 43 | // gives border to codemirror editors 44 | .CodeMirror { 45 | border: 1px solid rgb(85, 84, 84); 46 | font-size: 0.9rem; 47 | } 48 | 49 | button:hover { 50 | background-color: #555; 51 | color: white; 52 | } 53 | 54 | /******* CSS for Nav Bar *******/ 55 | #logo-img { 56 | padding-left: 10px; 57 | padding-right: 10px; 58 | height: 12px; 59 | width: auto; 60 | } 61 | 62 | #navbar { 63 | background-color: #222222; 64 | border-width: 0 0 2px; 65 | border-color: rgb(110, 110, 110); 66 | width: 100%; 67 | top: 15px; 68 | z-index: 5; 69 | } 70 | 71 | #navbar_container { 72 | display: flex; 73 | justify-content: space-between; 74 | } 75 | 76 | .navbutton { 77 | background-color: #222222; 78 | color: #ccc; 79 | border: none; 80 | padding: 5px; 81 | padding-left: 10px; 82 | padding-right: 10px; 83 | font-weight: bold; 84 | font-size: 0.8rem; 85 | } 86 | 87 | #docs_link { 88 | margin-right: 10px; 89 | a:link, 90 | a:visited, 91 | a:hover, 92 | a:active { 93 | color: #7cd1e9; 94 | font-weight: bold; 95 | text-decoration: none; 96 | } 97 | } 98 | 99 | /******* CSS for ClientTab *******/ 100 | 101 | .clientTable { 102 | margin-top: -5px; 103 | background-color: orange; 104 | } 105 | 106 | #client_page_container { 107 | margin-left: 10px; 108 | margin-right: 10px; 109 | display: grid; 110 | min-height: 175px; 111 | overflow-y: hidden; 112 | 113 | .SplitPane { 114 | margin-left: 10px; 115 | position: static; 116 | } 117 | } 118 | 119 | table { 120 | font-size: 0.8rem; 121 | 122 | th { 123 | background-color: #444; 124 | border-top: 1px solid #222; 125 | position: sticky; 126 | top: 0px; 127 | } 128 | 129 | td { 130 | height: 10px; 131 | padding: 1px 0 3px; 132 | margin-top: -1px; 133 | 134 | } 135 | 136 | } 137 | 138 | #client-request-table { 139 | border: 1px solid #444; 140 | margin-left: 10px; 141 | height: calc(100vh - 70px); 142 | overflow-y: auto; 143 | } 144 | 145 | tr:hover { 146 | background-color: #444; 147 | } 148 | 149 | #dataTable_container { 150 | display: flex; 151 | flex-direction: column; 152 | } 153 | 154 | .headersTabs { 155 | overflow: auto; 156 | margin: 20px 5px 0 5px; 157 | font: 0.8rem; 158 | } 159 | 160 | #queryExtras { 161 | border: 1px solid #444; 162 | padding: 2px; 163 | width: 99%; 164 | height: calc(100vh - 74px); 165 | overflow-x: hidden; 166 | body::-webkit-scrollbar { 167 | display: none; 168 | } 169 | display: grid; 170 | grid-template-rows: 17px ; 171 | } 172 | 173 | .clientNavBar { 174 | background-color: #999; 175 | height: 40px; 176 | display: flex; 177 | justify-content: space-evenly; 178 | position: sticky; 179 | top:0; 180 | height: 40px; 181 | } 182 | 183 | .clientNavButton { 184 | background-color: #222; 185 | color: #999; 186 | font-weight: bold; 187 | width: 33.333%; 188 | border: none; 189 | font-size: 0.8rem; 190 | } 191 | 192 | .clientTitle { 193 | font-size: 1.5rem; 194 | font-weight: bolder; 195 | border-width: 0 2px; 196 | border-color: white; 197 | } 198 | 199 | .statsColumns { 200 | display: grid; 201 | grid-template-columns: 25% 25% 25% 25%; 202 | } 203 | 204 | .client_editor .CodeMirror { 205 | height: calc(100vh - 92px); 206 | width: calc(100% - 2px); 207 | border: none; 208 | } 209 | 210 | .client_query_editor .CodeMirror { 211 | margin-top: 10px; 212 | height: calc(100vh - 92px); 213 | width: calc(100% - 2px); 214 | border: none; 215 | } 216 | 217 | /******* CSS for Server Tab *******/ 218 | // splits editor area from Metrics 219 | 220 | .serverTab { 221 | min-height: 175px; 222 | overflow-y: hidden; 223 | 224 | .outerPane .SplitPane { 225 | margin-left: 10px; 226 | position: static; 227 | width: 100vw; 228 | } 229 | 230 | .query_input_editor { 231 | margin-left: 10px; 232 | 233 | // limits height & width of the input editor 234 | .CodeMirror { 235 | height: calc(100vh - 70px); 236 | min-height: 200px; 237 | border-width: 1px 1px 0px 1px; 238 | } 239 | } 240 | 241 | // limits height of output editor to keep elements in line with input editor 242 | .query_output_editor .CodeMirror { 243 | height: calc(100vh - 70px); 244 | min-height: 200px; 245 | } 246 | } 247 | 248 | // css for 'Submit Query' and 'Clear Cache' 249 | .editorButtons { 250 | background-color: #222222; 251 | border: none; 252 | color: #999; 253 | width: 50%; 254 | margin-top: -25px; 255 | z-index: 5; 256 | padding: 3px 0 3px 0; 257 | } 258 | 259 | /******* CSS for cache data page *******/ 260 | // evenly splits grid in each sub-table for value pairs 261 | .subStats { 262 | display: grid; 263 | grid-template-columns: 50% 50%; 264 | font-size: 0.8rem; 265 | } 266 | 267 | .extensionTabs { 268 | margin-top: 30px; 269 | padding: 0 10px 0 10px; 270 | } 271 | 272 | .Cache_Server { 273 | display: grid; 274 | grid-template-columns: 50% 50%; 275 | grid-gap: 1px; 276 | } 277 | 278 | // aligns selectable table to server cache table 279 | .cacheTables { 280 | margin-left: -8px; 281 | border-top: 1px solid #444; 282 | height: auto; 283 | } 284 | 285 | .optionButtons { 286 | background-color: #222; 287 | color: #999; 288 | width: 50%; 289 | margin-top: -25px; 290 | z-index: 5; 291 | padding: 3px 0 3px 0; 292 | border: 1px solid #444; 293 | border-width: 1px 0 1px 1px; 294 | } 295 | 296 | #cacheTabClear { 297 | border: 1px solid #444; 298 | border-left: 0px; 299 | } 300 | 301 | .cacheButtons { 302 | display: flex; 303 | justify-content: space-around; 304 | padding: 0 1px 2px 1px; 305 | border: 1px solid #444; 306 | border-width: 0 1px 0 1px; 307 | } 308 | 309 | .cacheNavButton { 310 | border: none; 311 | width: calc(100% / 4); 312 | background-color: #444; 313 | color: white; 314 | font-weight: bold; 315 | font-size: 0.8rem; 316 | border: 1px solid #222; 317 | border-top: 2px solid #222; 318 | padding: 1px 2px 1px 2px; 319 | } 320 | 321 | .dynamicCacheTable { 322 | max-height: calc(100vh - 110px); 323 | overflow-y: auto; 324 | } 325 | 326 | 327 | .cacheStatTab .title_bar { 328 | display: grid; 329 | grid-template-columns: 50% 50%; 330 | } 331 | 332 | .cache_filter_field { 333 | padding-right: -20px; 334 | } 335 | 336 | .cache_entry { 337 | // border: 1px solid #444; 338 | margin-top: 2vh; 339 | margin-bottom: 2vh; 340 | margin-left: 1vw; 341 | } 342 | 343 | .cache_entry_key { 344 | margin-top: 2vh; 345 | margin-bottom: 2vh; 346 | } 347 | 348 | .cache_entry_value { 349 | margin: 2px 0 2px 4px; 350 | } 351 | 352 | .redisCache { 353 | padding: 0 5px 0 5px; 354 | border: 1px solid #444; 355 | height: calc(100vh - 71px); 356 | font-size: 0.8rem; 357 | } 358 | 359 | .cacheViewer { 360 | max-height: calc(100vh - 91px); 361 | overflow-y: auto; 362 | } 363 | 364 | .cacheSearchbar { 365 | display: flex; 366 | flex-direction: row; 367 | justify-content: space-between; 368 | padding: 3px 0 3px 0; 369 | } 370 | 371 | #searchIcon { 372 | padding: 2px 5px 0 2px; 373 | height: 15px; 374 | width: auto; 375 | border-right: 5px solid #222; 376 | } 377 | 378 | /******* CSS for Settings Tab *******/ 379 | .settingsTab { 380 | width: 100%; 381 | display: grid; 382 | grid-template-columns: 50% 50%; 383 | } 384 | 385 | // sets up underline and input boxes for settings 386 | input { 387 | background-color: #2b2a2a; 388 | color: #999; 389 | width: calc(100% - 6px); 390 | outline: 0; 391 | border-width: 0 0 2px; 392 | border-color: #999; 393 | } 394 | 395 | input:hover { 396 | background-color: #505050; 397 | } 398 | 399 | .configSettings { 400 | color: #aaa; 401 | margin-left: -8px; 402 | } 403 | 404 | .settingInputsDesc { 405 | font-size: 0.75rem; 406 | color: #888; 407 | } 408 | 409 | .configSettings { 410 | font-size: 0.8rem; 411 | align-content: center; 412 | width: calc(100%); 413 | height: calc(100vh - 64px); 414 | overflow-y: scroll; 415 | color: white; 416 | 417 | &::-webkit-scrollbar { 418 | display: none; 419 | } 420 | } 421 | 422 | .schema_editor .CodeMirror { 423 | height: calc(100vh - 71px); 424 | } 425 | 426 | /******* CSS for SplitPane Resizer *******/ 427 | // DO NOT TOUCH WITHOUT REFERRING TO react-split-pane 428 | .Resizer { 429 | background: #000; 430 | opacity: 0.2; 431 | z-index: 1; 432 | -moz-box-sizing: border-box; 433 | -webkit-box-sizing: border-box; 434 | box-sizing: border-box; 435 | -moz-background-clip: padding; 436 | -webkit-background-clip: padding; 437 | background-clip: padding-box; 438 | } 439 | 440 | .Resizer:hover { 441 | -webkit-transition: all 2s ease; 442 | transition: all 2s ease; 443 | } 444 | 445 | .Resizer.horizontal { 446 | height: 11px; 447 | margin: -5px 0; 448 | border-top: 5px solid rgba(255, 255, 255, 0); 449 | border-bottom: 5px solid rgba(255, 255, 255, 0); 450 | cursor: row-resize; 451 | width: 100%; 452 | } 453 | 454 | .Resizer.horizontal:hover { 455 | border-top: 5px solid rgba(0, 0, 0, 0.5); 456 | border-bottom: 5px solid rgba(0, 0, 0, 0.5); 457 | } 458 | 459 | .Resizer.vertical { 460 | width: 11px; 461 | height: calc(100vh - 51px); 462 | margin: 0 -5px; 463 | border-left: 5px solid rgba(255, 255, 255, 0); 464 | border-right: 5px solid rgba(255, 255, 255, 0); 465 | cursor: col-resize; 466 | } 467 | 468 | .Resizer.vertical:hover { 469 | border-left: 5px solid rgba(0, 0, 0, 0.5); 470 | border-right: 5px solid rgba(0, 0, 0, 0.5); 471 | } 472 | .Resizer.disabled { 473 | cursor: not-allowed; 474 | } 475 | .Resizer.disabled:hover { 476 | border-color: transparent; 477 | } -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/global.scss.d.ts: -------------------------------------------------------------------------------- 1 | // This file is automatically generated. 2 | // Please do not change this file! 3 | interface CssExports { 4 | 5 | } 6 | export const cssExports: CssExports; 7 | export default cssExports; 8 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/helpers/getResponseStatus.ts: -------------------------------------------------------------------------------- 1 | export const getResponseStatus = (request: chrome.devtools.network.Request):string => { 2 | const status = request.response.status; 3 | const statusText = request.response.statusText || null; 4 | return statusText ? `${status} (${statusText})`: status; 5 | } -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/helpers/isGQLQuery.ts: -------------------------------------------------------------------------------- 1 | import { parseGqlQuery } from "./parseQuery"; 2 | 3 | /** Returns true iff HAR entry request property has 4 | * requestBody containing GraphQL operations 5 | * @param{Object} req - HAR log entry 6 | */ 7 | const isGQLQuery = (req): boolean => { 8 | return parseGqlQuery(req) ? true : false; 9 | } 10 | 11 | export default isGQLQuery; -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/helpers/listeners.ts: -------------------------------------------------------------------------------- 1 | export const handleRequestFinished = ( 2 | callback: (e: chrome.devtools.network.Request) => void 3 | ) => { 4 | chrome.devtools.network.onRequestFinished.addListener(callback) 5 | return () => { 6 | chrome.devtools.network.onRequestFinished.removeListener(callback) 7 | } 8 | } 9 | 10 | export const handleNavigate = (callback: (e: chrome.devtools.network.Request) => void) => { 11 | chrome.devtools.network.onNavigated.addListener(callback) 12 | return () => { 13 | chrome.devtools.network.onNavigated.removeListener(callback) 14 | } 15 | } -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/helpers/parseQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | /** Get query (if any) from HAR request's postData key 4 | * @param {Object} req - HAR log entry 5 | */ 6 | export const getQueryString = (req: object) => { 7 | if (!req.request) return null; 8 | if (!req.request.postData?.text) return null; 9 | return JSON.parse(req.request.postData.text).query; 10 | }; 11 | 12 | export const parseGqlQuery = ( 13 | request: chrome.devtools.network.Request 14 | ): object | null => { 15 | try { 16 | const queryString = getQueryString(request); 17 | const parsedQuery = gql` 18 | ${queryString} 19 | `; 20 | return parsedQuery; 21 | } catch (err) { 22 | console.log('Request does not contain a valid GraphQL query'); 23 | return null; 24 | } 25 | }; 26 | 27 | export const getOperationNames = ( 28 | request: chrome.devtools.network.Request 29 | ): any => { 30 | const operationNames = new Set(); 31 | const parsedQuery = parseGqlQuery(request); 32 | parsedQuery.definitions 33 | .filter((definition) => definition.kind === 'OperationDefinition') 34 | .forEach((op) => operationNames.add(op.operation)); 35 | return [...operationNames].join(', ') 36 | }; 37 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/index.d.tsx: -------------------------------------------------------------------------------- 1 | declare module '*.png'; -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Quell Devtools 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/index.tsx: -------------------------------------------------------------------------------- 1 | require('file-loader?name=[name].[ext]!./index.html'); 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './global.scss'; 5 | import 'reactflow/dist/style.css'; 6 | 7 | ReactDOM.render( 8 | , 9 | document.getElementById('app') 10 | ); 11 | -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/interfaces/ClientRequest.ts: -------------------------------------------------------------------------------- 1 | export interface ClientRequest extends chrome.devtools.network.Request { 2 | responseData?: object; 3 | } -------------------------------------------------------------------------------- /quell-extension/src/pages/Panel/interfaces/RedisInfo.ts: -------------------------------------------------------------------------------- 1 | export interface RedisInfo { 2 | redisStats?: RedisStats; 3 | redisKeys?: string[]; 4 | redisValues?: string[]; 5 | } 6 | 7 | export interface RedisStats { 8 | server: Stat[]; 9 | client: Stat[]; 10 | memory: Stat[]; 11 | stats: Stat[]; 12 | [key: string]: Stat[]; 13 | } 14 | 15 | export interface Stat { 16 | name: string; 17 | value: string; 18 | } 19 | -------------------------------------------------------------------------------- /quell-extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "compilerOptions": { 4 | "target": "ES5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 5 | "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 6 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ /* Type declaration files to be included in compilation. */, 7 | "lib": [ 8 | "DOM", 9 | "ESNext" 10 | ] /* Specify library files to be included in the compilation. */, 11 | "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', 'react' or 'react-jsx'. */, 12 | "noEmit": true /* Do not emit outputs. */, 13 | "isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */, 14 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 15 | "strict": true /* Enable all strict type-checking options. */, 16 | "skipLibCheck": true /* Skip type checking of declaration files. */, 17 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 18 | "resolveJsonModule": true 19 | // "allowJs": true /* Allow javascript files to be compiled. Useful when migrating JS to TS */, 20 | // "checkJs": true /* Report errors in .js files. Works in tandem with allowJs. */, 21 | }, 22 | "include": ["src/**/*"] 23 | } -------------------------------------------------------------------------------- /quell-extension/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | module.exports = { 6 | entry: { 7 | background: './src/pages/Background/background.tsx', 8 | devtools: './src/pages/Devtools/index.tsx', 9 | panel: '/src/pages/Panel/index.tsx', 10 | }, 11 | resolve: { 12 | extensions: ['.tsx', '.ts', '.jsx', '.js', '.css', '.scss'], 13 | }, 14 | output: { 15 | path: path.join(__dirname, '/dist'), 16 | filename: '[name].bundle.js', 17 | clean: true, 18 | }, 19 | devServer: { 20 | port: 3030, 21 | static: { 22 | directory: path.join(__dirname, 'src', 'pages', 'Panel'), 23 | }, 24 | proxy: { 25 | '/graphql': { 26 | target: 'http://localhost:3000', 27 | secure: false, 28 | }, 29 | '/clearCache': { 30 | target: 'http://localhost:3000', 31 | secure: false, 32 | }, 33 | }, 34 | }, 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.js$/, 39 | enforce: 'pre', 40 | use: ['source-map-loader'], 41 | }, 42 | { 43 | test: /\.(js|ts)x?$/, 44 | exclude: /node_modules/, 45 | use: { 46 | loader: 'babel-loader', 47 | }, 48 | }, 49 | { 50 | test: /\.(css|scss)$/, 51 | use: [ 52 | 'style-loader', 53 | 'css-modules-typescript-loader', 54 | 'css-loader', 55 | 'sass-loader', 56 | ], 57 | }, 58 | { 59 | test: /\.(jpg|png)$/, 60 | use: { 61 | loader: 'url-loader', 62 | } 63 | } 64 | ], 65 | }, 66 | plugins: [ 67 | new CopyWebpackPlugin({ 68 | patterns: [ 69 | { 70 | from: 'src/manifest.json', 71 | to: path.join(__dirname, './dist'), 72 | force: true, 73 | transform: function (content, path) { 74 | // generates the manifest file using the package.json informations 75 | return Buffer.from( 76 | JSON.stringify({ 77 | description: process.env.npm_package_description, 78 | version: process.env.npm_package_version, 79 | ...JSON.parse(content.toString()), 80 | }) 81 | ); 82 | }, 83 | }, 84 | { 85 | from: 'src/pages/Panel/assets', 86 | to: path.join(__dirname, './dist/assets'), 87 | force: true 88 | } 89 | ], 90 | }), 91 | new HtmlWebpackPlugin({ 92 | template: './src/pages/Devtools/index.html', 93 | filename: 'devtools.html', 94 | chunks: ['devtools'], 95 | cache: false, 96 | }), 97 | new HtmlWebpackPlugin({ 98 | template: './src/pages/Panel/index.html', 99 | filename: 'panel.html', 100 | chunks: ['panel'], 101 | cache: false, 102 | }), 103 | ], 104 | }; 105 | -------------------------------------------------------------------------------- /quell-server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dump.rdb 3 | package-lock.json 4 | coverage 5 | dist 6 | dist/ 7 | /dist 8 | .tgz -------------------------------------------------------------------------------- /quell-server/__tests__/helpers/buildFromCache.test.ts: -------------------------------------------------------------------------------- 1 | import { QuellCache } from '../../src/quell'; 2 | import schema from '../../test-config/testSchema'; 3 | 4 | describe('server test for buildFromCache', () => { 5 | const Quell = new QuellCache({ 6 | schema: schema, 7 | redisPort: Number(process.env.REDIS_PORT) || 6379, 8 | redisHost: process.env.REDIS_HOST || '127.0.0.1', 9 | redisPassword: process.env.REDIS_PASSWORD || '', 10 | }); 11 | 12 | // inputs: prototype object (which contains args), collection (defaults to an empty array) 13 | // outputs: protoype object with fields that were not found in the cache set to false 14 | 15 | beforeAll(() => { 16 | const promise1 = new Promise((resolve, reject) => { 17 | resolve( 18 | Quell.writeToCache('country--1', { 19 | id: '1', 20 | capitol: { id: '2', name: 'DC' }, 21 | }) 22 | ); 23 | }); 24 | const promise2 = new Promise((resolve, reject) => { 25 | resolve(Quell.writeToCache('country--2', { id: '2' })) 26 | }); 27 | const promise3 = new Promise((resolve, reject) => { 28 | resolve(Quell.writeToCache('country--3', { id: '3' })) 29 | }); 30 | const promise4 = new Promise((resolve, reject) => { 31 | resolve( 32 | Quell.writeToCache('countries', [ 33 | 'country--1', 34 | 'country--2', 35 | 'country--3', 36 | ]) 37 | ) 38 | }); 39 | return Promise.all([promise1, promise2, promise3, promise4]); 40 | }); 41 | 42 | afterAll(() => { 43 | Quell.redisCache.flushAll(); 44 | Quell.redisCache.quit(); 45 | }); 46 | 47 | test('Basic query', async () => { 48 | const testProto = { 49 | country: { 50 | id: true, 51 | name: true, 52 | __alias: null, 53 | __args: { id: '3' }, 54 | __type: 'country', 55 | __id: '3', 56 | }, 57 | }; 58 | const endProto = { 59 | country: { 60 | id: true, 61 | name: false, 62 | __alias: null, 63 | __args: { id: '3' }, 64 | __type: 'country', 65 | __id: '3', 66 | }, 67 | }; 68 | const expectedResponseFromCache = { 69 | data: { 70 | country: { 71 | id: '3', 72 | }, 73 | }, 74 | }; 75 | const prototypeKeys = Object.keys(testProto); 76 | const responseFromCache = await Quell.buildFromCache( 77 | testProto, 78 | prototypeKeys 79 | ); 80 | // we expect prototype after running through buildFromCache to have id has stayed true but every other field has been toggled to false (if not found in sessionStorage) 81 | expect(testProto).toEqual(endProto); 82 | expect(responseFromCache).toEqual(expectedResponseFromCache); 83 | }); 84 | 85 | test('Basic query for data not in the cache', async () => { 86 | const testProto = { 87 | book: { 88 | id: true, 89 | name: true, 90 | __alias: null, 91 | __args: { id: '3' }, 92 | __type: 'book', 93 | __id: '3', 94 | }, 95 | }; 96 | const endProto = { 97 | book: { 98 | id: false, 99 | name: false, 100 | __alias: null, 101 | __args: { id: '3' }, 102 | __type: 'book', 103 | __id: '3', 104 | }, 105 | }; 106 | const expectedResponseFromCache = { 107 | data: { book: {} }, 108 | }; 109 | const prototypeKeys = Object.keys(testProto); 110 | const responseFromCache = await Quell.buildFromCache( 111 | testProto, 112 | prototypeKeys 113 | ); 114 | // we expect prototype after running through buildFromCache to have id has stayed true but every other field has been toggled to false (if not found in sessionStorage) 115 | expect(testProto).toEqual(endProto); 116 | expect(responseFromCache).toEqual(expectedResponseFromCache); 117 | }); 118 | 119 | test('Multiple nested queries that include args and aliases', async () => { 120 | const testProto = { 121 | Canada: { 122 | id: true, 123 | name: true, 124 | __alias: 'Canada', 125 | __args: { id: '1' }, 126 | __type: 'country', 127 | __id: '1', 128 | capitol: { 129 | id: true, 130 | name: true, 131 | population: true, 132 | __alias: null, 133 | __args: {}, 134 | __type: 'capitol', 135 | __id: null, 136 | }, 137 | }, 138 | Mexico: { 139 | id: true, 140 | name: true, 141 | __alias: 'Mexico', 142 | __args: { id: '2' }, 143 | __type: 'country', 144 | __id: '2', 145 | climate: { 146 | seasons: true, 147 | __alias: null, 148 | __args: {}, 149 | __type: 'climate', 150 | __id: null, 151 | }, 152 | }, 153 | }; 154 | const endProto = { 155 | Canada: { 156 | id: true, 157 | name: false, 158 | __alias: 'Canada', 159 | __args: { id: '1' }, 160 | __type: 'country', 161 | __id: '1', 162 | capitol: { 163 | id: true, 164 | name: true, 165 | population: false, 166 | __alias: null, 167 | __args: {}, 168 | __type: 'capitol', 169 | __id: null, 170 | }, 171 | }, 172 | Mexico: { 173 | id: true, 174 | name: false, 175 | __alias: 'Mexico', 176 | __args: { id: '2' }, 177 | __type: 'country', 178 | __id: '2', 179 | climate: { 180 | seasons: false, 181 | __alias: null, 182 | __args: {}, 183 | __type: 'climate', 184 | __id: null, 185 | }, 186 | }, 187 | }; 188 | const expectedResponseFromCache = { 189 | data: { 190 | Canada: { 191 | id: '1', 192 | capitol: { 193 | id: '2', 194 | name: 'DC', 195 | }, 196 | }, 197 | Mexico: { 198 | id: '2', 199 | }, 200 | }, 201 | }; 202 | const prototypeKeys = Object.keys(testProto); 203 | const responseFromCache = await Quell.buildFromCache( 204 | testProto, 205 | prototypeKeys 206 | ); 207 | expect(testProto).toEqual(endProto); 208 | expect(responseFromCache).toEqual(expectedResponseFromCache); 209 | }); 210 | 211 | xtest('Handles array', async () => { 212 | const testProto = { 213 | countries: { 214 | id: true, 215 | name: true, 216 | __alias: null, 217 | __args: {}, 218 | __type: 'countries', 219 | }, 220 | }; 221 | const endProto = { 222 | countries: { 223 | id: true, 224 | name: false, 225 | __alias: null, 226 | __args: {}, 227 | __type: 'countries', 228 | }, 229 | }; 230 | const expectedResponseFromCache = { 231 | data: { 232 | countries: [ 233 | { 234 | id: '1', 235 | }, 236 | { 237 | id: '2', 238 | }, 239 | { 240 | id: '3', 241 | }, 242 | ], 243 | }, 244 | }; 245 | const prototypeKeys = Object.keys(testProto); 246 | const responseFromCache = await Quell.buildFromCache( 247 | testProto, 248 | prototypeKeys 249 | ); 250 | expect(testProto).toEqual(endProto); 251 | expect(responseFromCache).toEqual(expectedResponseFromCache); 252 | }); 253 | 254 | test('Handles deeply nested queries with an empty cache', async () => { 255 | const testProto = { 256 | continents: { 257 | id: true, 258 | name: true, 259 | __type: 'continents', 260 | __alias: null, 261 | __args: {}, 262 | __id: null, 263 | cities: { 264 | id: true, 265 | name: true, 266 | __type: 'cities', 267 | __alias: null, 268 | __args: {}, 269 | __id: null, 270 | attractions: { 271 | id: true, 272 | name: true, 273 | __type: 'attractions', 274 | __alias: null, 275 | __args: {}, 276 | __id: null, 277 | }, 278 | }, 279 | }, 280 | }; 281 | const endProto = { 282 | continents: { 283 | id: false, 284 | name: false, 285 | __type: 'continents', 286 | __alias: null, 287 | __args: {}, 288 | __id: null, 289 | cities: { 290 | id: false, 291 | name: false, 292 | __type: 'cities', 293 | __alias: null, 294 | __args: {}, 295 | __id: null, 296 | attractions: { 297 | id: false, 298 | name: false, 299 | __type: 'attractions', 300 | __alias: null, 301 | __args: {}, 302 | __id: null, 303 | }, 304 | }, 305 | }, 306 | }; 307 | const expectedResponseFromCache = { 308 | data: { continents: {} }, 309 | }; 310 | const prototypeKeys = Object.keys(testProto); 311 | const responseFromCache = await Quell.buildFromCache( 312 | testProto, 313 | prototypeKeys 314 | ); 315 | expect(testProto).toEqual(endProto); 316 | expect(responseFromCache).toEqual(expectedResponseFromCache); 317 | }); 318 | 319 | test('Basic query', async () => { 320 | const testProto = { 321 | country: { 322 | id: true, 323 | name: true, 324 | __alias: null, 325 | __args: { id: '3' }, 326 | __type: 'country', 327 | __id: '3', 328 | }, 329 | }; 330 | const endProto = { 331 | country: { 332 | id: true, 333 | name: false, 334 | __alias: null, 335 | __args: { id: '3' }, 336 | __type: 'country', 337 | __id: '3', 338 | }, 339 | }; 340 | const expectedResponseFromCache = { 341 | data: { 342 | country: { 343 | id: '3', 344 | }, 345 | }, 346 | }; 347 | const prototypeKeys = Object.keys(testProto); 348 | const responseFromCache = await Quell.buildFromCache( 349 | testProto, 350 | prototypeKeys 351 | ); 352 | // we expect prototype after running through buildFromCache to have id has stayed true but every other field has been toggled to false (if not found in sessionStorage) 353 | expect(testProto).toEqual(endProto); 354 | expect(responseFromCache).toEqual(expectedResponseFromCache); 355 | }); 356 | }); 357 | -------------------------------------------------------------------------------- /quell-server/__tests__/helpers/createQueryObj.test.ts: -------------------------------------------------------------------------------- 1 | import { createQueryObj } from '../../src/helpers/quellHelpers'; 2 | 3 | describe('server side tests for createQueryObj.js', () => { 4 | 5 | // TO-DO: Add the same test to the client side test folder 6 | test('inputs prototype w/ all true should output empty object', () => { 7 | const prototype = { 8 | countries: { 9 | __id: null, 10 | __alias: null, 11 | __args: {}, 12 | __type: 'countries', 13 | id: true, 14 | name: true, 15 | capitol: true, 16 | cities: { 17 | __id: null, 18 | __alias: null, 19 | __args: {}, 20 | __type: 'cities', 21 | id: true, 22 | country_id: true, 23 | name: true, 24 | population: true, 25 | }, 26 | }, 27 | }; 28 | 29 | expect(createQueryObj(prototype)).toEqual({}); 30 | }); 31 | 32 | test('inputs prototype w/ only false scalar types should output same object', () => { 33 | const map = { 34 | countries: { 35 | __id: null, 36 | __alias: null, 37 | __args: {}, 38 | __type: 'countries', 39 | id: false, 40 | name: false, 41 | capitol: false, 42 | }, 43 | }; 44 | 45 | expect(createQueryObj(map)).toEqual({ 46 | 47 | countries: { 48 | __id: null, 49 | __alias: null, 50 | __args: {}, 51 | __type: 'countries', 52 | id: false, 53 | name: false, 54 | capitol: false, 55 | }, 56 | }); 57 | }); 58 | 59 | test('inputs prototype w/ false for only scalar types should output object for scalar types only', () => { 60 | const map = { 61 | countries: { 62 | __id: null, 63 | __alias: null, 64 | __args: {}, 65 | __type: 'countries', 66 | id: false, 67 | name: false, 68 | capitol: false, 69 | cities: { 70 | __id: null, 71 | __alias: null, 72 | __args: {}, 73 | __type: 'cities', 74 | id: true, 75 | country_id: true, 76 | name: true, 77 | population: true, 78 | }, 79 | }, 80 | }; 81 | 82 | expect(createQueryObj(map)).toEqual({ 83 | 84 | countries: { 85 | __id: null, 86 | __alias: null, 87 | __args: {}, 88 | __type: 'countries', 89 | id: false, 90 | name: false, 91 | capitol: false, 92 | }, 93 | }); 94 | }); 95 | 96 | test('inputs prototype w/ false for only object types should output object for object types only', () => { 97 | const map = { 98 | countries: { 99 | __id: null, 100 | __alias: null, 101 | __args: {}, 102 | __type: 'countries', 103 | id: true, 104 | name: true, 105 | capital: true, 106 | cities: { 107 | __id: null, 108 | __alias: null, 109 | __args: {}, 110 | __type: 'cities', 111 | id: false, 112 | country_id: false, 113 | name: false, 114 | population: false, 115 | }, 116 | }, 117 | }; 118 | 119 | expect(createQueryObj(map)).toEqual({ 120 | countries: { 121 | id: false, 122 | __id: null, 123 | __alias: null, 124 | __args: {}, 125 | __type: 'countries', 126 | cities: { 127 | __id: null, 128 | __alias: null, 129 | __args: {}, 130 | __type: 'cities', 131 | id: false, 132 | country_id: false, 133 | name: false, 134 | population: false, 135 | }, 136 | }, 137 | }); 138 | }); 139 | 140 | test('inputs prototype w/ true/false for both scalar & object types and outputs object for all false', () => { 141 | const map = { 142 | countries: { 143 | __id: null, 144 | __alias: null, 145 | __args: {}, 146 | __type: 'countries', 147 | id: true, 148 | name: false, 149 | capital: false, 150 | cities: { 151 | __id: null, 152 | __alias: null, 153 | __args: {}, 154 | __type: 'cities', 155 | id: true, 156 | country_id: false, 157 | name: true, 158 | population: false, 159 | }, 160 | }, 161 | }; 162 | 163 | expect(createQueryObj(map)).toEqual({ 164 | 165 | countries: { 166 | __id: null, 167 | __alias: null, 168 | __args: {}, 169 | __type: 'countries', 170 | id: false, 171 | name: false, 172 | capital: false, 173 | cities: { 174 | __id: null, 175 | __alias: null, 176 | __args: {}, 177 | __type: 'cities', 178 | id: false, 179 | country_id: false, 180 | population: false, 181 | }, 182 | }, 183 | }); 184 | }); 185 | 186 | test('inputs prototype with multiple queries', () => { 187 | const map = { 188 | Canada: { 189 | __id: '1', 190 | __alias: 'Canada', 191 | __args: { id: '1' }, 192 | __type: 'country', 193 | id: true, 194 | name: false, 195 | capitol: { 196 | __id: null, 197 | __alias: null, 198 | __args: {}, 199 | __type: 'capitol', 200 | id: false, 201 | name: true, 202 | population: false, 203 | }, 204 | }, 205 | Mexico: { 206 | __id: '2', 207 | __alias: 'Mexico', 208 | __args: { id: '2' }, 209 | __type: 'country', 210 | id: true, 211 | name: false, 212 | climate: { 213 | __id: null, 214 | __alias: null, 215 | __args: {}, 216 | __type: 'climate', 217 | seasons: true, 218 | id: false, 219 | }, 220 | }, 221 | }; 222 | 223 | expect(createQueryObj(map)).toEqual({ 224 | 225 | Canada: { 226 | __id: '1', 227 | __type: 'country', 228 | name: false, 229 | id: false, 230 | __alias: 'Canada', 231 | __args: { id: '1' }, 232 | capitol: { 233 | id: false, 234 | population: false, 235 | __alias: null, 236 | __args: {}, 237 | __type: 'capitol', 238 | __id: null, 239 | }, 240 | }, 241 | Mexico: { 242 | name: false, 243 | id: false, 244 | __alias: 'Mexico', 245 | __args: { id: '2' }, 246 | __type: 'country', 247 | __id: '2', 248 | }, 249 | }); 250 | }); 251 | 252 | test('test requests with multiple queries in which half of the request if managed by the cache and the other half is managed by the response', () => { 253 | const map = { 254 | Canada: { 255 | __id: '1', 256 | __alias: 'Canada', 257 | __args: { id: '1' }, 258 | __type: 'country', 259 | id: true, 260 | name: true, 261 | capitol: { 262 | __id: null, 263 | __alias: null, 264 | __args: {}, 265 | __type: 'capitol', 266 | id: true, 267 | name: true, 268 | population: true, 269 | }, 270 | }, 271 | WarBook: { 272 | __id: '2', 273 | __alias: 'WarBook', 274 | __args: { id: '10' }, 275 | __type: 'book', 276 | id: false, 277 | name: false, 278 | author: { 279 | __id: null, 280 | __alias: null, 281 | __args: {}, 282 | __type: 'author', 283 | id: false, 284 | name: false, 285 | }, 286 | }, 287 | }; 288 | 289 | expect(createQueryObj(map)).toEqual({ 290 | 291 | WarBook: { 292 | __id: '2', 293 | __alias: 'WarBook', 294 | __args: { id: '10' }, 295 | __type: 'book', 296 | id: false, 297 | name: false, 298 | author: { 299 | __id: null, 300 | __alias: null, 301 | __args: {}, 302 | __type: 'author', 303 | name: false, 304 | id: false, 305 | }, 306 | }, 307 | }); 308 | }); 309 | }); 310 | -------------------------------------------------------------------------------- /quell-server/__tests__/helpers/createQueryStr.test.ts: -------------------------------------------------------------------------------- 1 | import { createQueryStr } from "../../src/helpers/quellHelpers"; 2 | 3 | describe("server side tests for createQueryStr.js", () => { 4 | test("inputs query object w/ no values", () => { 5 | const queryObject = {}; 6 | 7 | expect(createQueryStr(queryObject)).toEqual(""); 8 | }); 9 | 10 | test("inputs query object w/ only scalar types and outputs GQL query string", () => { 11 | const queryObject = { 12 | countries: { 13 | __id: null, 14 | __alias: null, 15 | __args: {}, 16 | __type: "countries", 17 | id: false, 18 | name: false, 19 | capitol: false, 20 | }, 21 | }; 22 | 23 | expect(createQueryStr(queryObject)).toEqual( 24 | `{ countries { id name capitol } }` 25 | ); 26 | }); 27 | 28 | test("inputs query object w/ only nested objects and outputs GQL query string", () => { 29 | const queryObject = { 30 | countries: { 31 | __id: null, 32 | __type: "countries", 33 | __alias: null, 34 | __args: {}, 35 | cities: { 36 | __id: null, 37 | __type: "cities", 38 | __alias: null, 39 | __args: {}, 40 | id: false, 41 | country_id: false, 42 | name: false, 43 | population: false, 44 | }, 45 | }, 46 | }; 47 | 48 | expect(createQueryStr(queryObject)).toEqual( 49 | `{ countries { cities { id country_id name population } } }` 50 | ); 51 | }); 52 | 53 | test("inputs query object w/ both scalar & object types and outputs GQL query string", () => { 54 | const queryObject = { 55 | countries: { 56 | __id: null, 57 | __type: "countries", 58 | __alias: null, 59 | __args: {}, 60 | id: false, 61 | name: false, 62 | capitol: false, 63 | cities: { 64 | __id: null, 65 | __type: "cities", 66 | __alias: null, 67 | __args: {}, 68 | id: false, 69 | country_id: false, 70 | name: false, 71 | }, 72 | }, 73 | }; 74 | 75 | expect(createQueryStr(queryObject)).toEqual( 76 | `{ countries { id name capitol cities { id country_id name } } }` 77 | ); 78 | }); 79 | 80 | test("inputs query object w/ an argument & w/ both scalar & object types should output GQL query string", () => { 81 | const queryObject = { 82 | country: { 83 | __id: "1", 84 | __type: "country", 85 | __alias: null, 86 | __args: { id: "1" }, 87 | id: false, 88 | name: false, 89 | capitol: false, 90 | cities: { 91 | __id: null, 92 | __type: "cities", 93 | __alias: null, 94 | __args: {}, 95 | id: false, 96 | country_id: false, 97 | name: false, 98 | population: false, 99 | }, 100 | }, 101 | }; 102 | 103 | expect(createQueryStr(queryObject)).toEqual( 104 | `{ country(id: "1") { id name capitol cities { id country_id name population } } }` 105 | ); 106 | }); 107 | 108 | test("inputs query object w/ multiple arguments & w/ both scalar & object types should output GQL query string", () => { 109 | const queryObject = { 110 | country: { 111 | __id: null, 112 | __type: "country", 113 | __alias: null, 114 | __args: { 115 | name: "China", 116 | capitol:"Beijing", 117 | }, 118 | id: false, 119 | name: false, 120 | capital: false, 121 | cities: { 122 | __id: null, 123 | __type: "cities", 124 | __alias: null, 125 | __args: {}, 126 | id: false, 127 | country_id: false, 128 | name: false, 129 | population: false, 130 | }, 131 | }, 132 | }; 133 | 134 | expect(createQueryStr(queryObject)).toEqual( 135 | `{ country(name: "China", capitol: "Beijing") { id name capital cities { id country_id name population } } }` 136 | ); 137 | }); 138 | 139 | test("inputs query object with alias should output GQL query string", () => { 140 | const queryObject = { 141 | Canada: { 142 | __id: "3", 143 | __type: "country", 144 | __args: { id: "3" }, 145 | __alias: "Canada", 146 | id: false, 147 | cities: { 148 | __id: null, 149 | __type: "cities", 150 | __args: {}, 151 | __alias: null, 152 | id: false, 153 | name: false, 154 | }, 155 | }, 156 | }; 157 | 158 | expect(createQueryStr(queryObject)).toEqual( 159 | `{ Canada: country(id: "3") { id cities { id name } } }` 160 | ); 161 | }); 162 | 163 | test("inputs query object with nested alias should output GQL query string", () => { 164 | const queryObject = { 165 | Canada: { 166 | __type: "country", 167 | __args: { id: 3 }, 168 | __alias: "Toronto", 169 | id: false, 170 | Toronto: { 171 | __type: "city", 172 | __args: { id: 5 }, 173 | __alias: "Toronto", 174 | id: false, 175 | name: false, 176 | }, 177 | }, 178 | }; 179 | 180 | expect(createQueryStr(queryObject)).toEqual( 181 | `{ Canada: country(id: "3") { id Toronto: city(id: "5") { id name } } }` 182 | ); 183 | }); 184 | 185 | test("inputs query object with multiple queries should output GQL query string", () => { 186 | const queryObject = { 187 | country: { 188 | __id: "1", 189 | __type: "country", 190 | __args: { id: "1" }, 191 | __alias: null, 192 | id: false, 193 | name: false, 194 | cities: { 195 | __id: null, 196 | __type: "cities", 197 | __args: {}, 198 | __alias: null, 199 | id: false, 200 | name: false, 201 | }, 202 | }, 203 | book: { 204 | __id: "2", 205 | __type: "book", 206 | __args: { id: "2" }, 207 | __alias: null, 208 | id: false, 209 | title: false, 210 | author: { 211 | __id: null, 212 | __type: "author", 213 | id: false, 214 | name: false, 215 | __args: {}, 216 | __alias: null, 217 | }, 218 | }, 219 | }; 220 | 221 | expect(createQueryStr(queryObject)).toEqual( 222 | `{ country(id: "1") { id name cities { id name } } book(id: "2") { id title author { id name } } }` 223 | ); 224 | }); 225 | 226 | test("deeply nested query object", () => { 227 | const queryObject = { 228 | countries: { 229 | id: true, 230 | __type: "countries", 231 | __alias: null, 232 | __args: {}, 233 | __id: null, 234 | cities: { 235 | id: true, 236 | __type: "cities", 237 | __alias: null, 238 | __args: {}, 239 | __id: null, 240 | attractions: { 241 | id: true, 242 | __type: "attractions", 243 | __alias: null, 244 | __args: {}, 245 | __id: null, 246 | location: { 247 | id: true, 248 | __type: "location", 249 | __alias: null, 250 | __args: {}, 251 | __id: null, 252 | latitude: { 253 | id: true, 254 | __type: "latitude", 255 | __alias: null, 256 | __args: {}, 257 | __id: null, 258 | here: { 259 | id: true, 260 | __type: "here", 261 | __alias: null, 262 | __args: {}, 263 | __id: null, 264 | not: { 265 | id: true, 266 | __type: "not", 267 | __alias: null, 268 | __args: {}, 269 | __id: null, 270 | }, 271 | }, 272 | }, 273 | }, 274 | }, 275 | }, 276 | }, 277 | }; 278 | expect(createQueryStr(queryObject)).toEqual( 279 | `{ countries { id cities { id attractions { id location { id latitude { id here { id not { id } } } } } } } }` 280 | ); 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /quell-server/__tests__/helpers/getFieldsMap.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { getFieldsMap } from "../../src/helpers/quellHelpers"; 3 | const schema = require("../../test-config/testSchema"); 4 | const schemaWithoutFields = require("../../test-config/testSchemaWithoutFields"); 5 | 6 | describe("server side tests for getFieldsMap", () => { 7 | afterAll((done) => { 8 | done(); 9 | }); 10 | test("Correctly returns valid fields and their respective type based on schema", () => { 11 | expect(getFieldsMap(schema)).toEqual({ 12 | Book: { 13 | author: "String", 14 | id: "ID", 15 | name: "String", 16 | shelf_id: "String", 17 | }, 18 | BookShelf: { 19 | books: "Book", 20 | id: "ID", 21 | name: "String", 22 | }, 23 | City: { 24 | country_id: "String", 25 | id: "ID", 26 | name: "String", 27 | population: "Int", 28 | }, 29 | Country: { 30 | capital: "String", 31 | cities: "City", 32 | id: "ID", 33 | name: "String", 34 | }, 35 | RootMutationType: { 36 | addBook: "Book", 37 | addBookShelf: "BookShelf", 38 | addCountry: "Country", 39 | changeBook: "Book", 40 | //deleteCity field does not exist in testSchema, therefore commented out 41 | // deleteCity: "City", 42 | }, 43 | }); 44 | }); 45 | test("Returns an empty object for any types in the schema without field values", () => { 46 | expect(getFieldsMap(schemaWithoutFields)).toEqual({ 47 | Book: {}, 48 | BookShelf: {}, 49 | City: {}, 50 | Country: {}, 51 | RootMutationType: {}, 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /quell-server/__tests__/helpers/getMutationMap.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { getMutationMap } from '../../src/helpers/quellHelpers'; 3 | import schema from '../../test-config/testSchema'; 4 | import schemaWithoutMuts from '../../test-config/testSchemaWithoutMuts'; 5 | 6 | describe('server side tests for getMutationMap', () => { 7 | afterAll((done) => { 8 | done(); 9 | }); 10 | test('Correctly returns valid mutations and their respective type based on schema', () => { 11 | expect(getMutationMap(schema)).toEqual({ 12 | addBook: 'Book', 13 | changeBook: 'Book', 14 | addBookShelf: 'BookShelf', 15 | addCountry: 'Country', // Not found in testSchema? 16 | // deleteCity: 'City' // Not found in testSchema? 17 | }); 18 | }); 19 | test('Returns empty object for schema without mutations', () => { 20 | expect(getMutationMap(schemaWithoutMuts)).toEqual({}); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /quell-server/__tests__/helpers/getQueryMap.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { getQueryMap } from '../../src/helpers/quellHelpers'; 3 | import schema from '../../test-config/testSchema'; 4 | import schemaWithoutQueries from '../../test-config/testSchemaWithoutQueries'; 5 | 6 | describe('server side tests for getQueryMap', () => { 7 | afterAll((done) => { 8 | done(); 9 | }); 10 | test('Correctly returns valid queries and their respective type based on schema', () => { 11 | expect(getQueryMap(schema)).toEqual({ 12 | book: 'Book', 13 | bookShelf: 'BookShelf', 14 | bookShelves: ['BookShelf'], 15 | books: ['Book'], 16 | cities: ['City'], 17 | citiesByCountry: ['City'], 18 | city: 'City', 19 | countries: ['Country'], 20 | country: 'Country' 21 | }); 22 | }); 23 | test('Returns empty object for schema without queries', () => { 24 | expect(getQueryMap(schemaWithoutQueries)).toEqual({}); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /quell-server/__tests__/helpers/getRedisInfo.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from '../../test-config/test-server'; 3 | import { QuellCache } from '../../src/quell'; 4 | import schema from '../../test-config/testSchema'; 5 | import { getRedisInfo } from '../../src/helpers/redisHelpers'; 6 | 7 | // tests pass locally, but time out in travis CI build... 8 | xdescribe('server test for getRedisInfo', () => { 9 | const Quell = new QuellCache({ 10 | schema: schema, 11 | redisPort: Number(process.env.REDIS_PORT) || 6379, 12 | redisHost: process.env.REDIS_HOST || '127.0.0.1', 13 | redisPassword: process.env.REDIS_PASSWORD || '', 14 | }); 15 | 16 | app.use( 17 | '/redis', 18 | ...getRedisInfo({ 19 | getStats: true, 20 | getKeys: true, 21 | getValues: true, 22 | }) 23 | ); 24 | 25 | const server = app.listen(3000, () => {}); 26 | 27 | beforeAll(() => { 28 | const promise1 = new Promise((resolve, reject) => { 29 | resolve( 30 | Quell.writeToCache('country--1', { 31 | id: '1', 32 | capitol: { id: '2', name: 'DC' }, 33 | }) 34 | ); 35 | }); 36 | const promise2 = new Promise((resolve, reject) => { 37 | resolve(Quell.writeToCache('country--2', { id: '2' })); 38 | }); 39 | const promise3 = new Promise((resolve, reject) => { 40 | resolve(Quell.writeToCache('country--3', { id: '3' })); 41 | }); 42 | const promise4 = new Promise((resolve, reject) => { 43 | resolve( 44 | Quell.writeToCache('countries', [ 45 | 'country--1', 46 | 'country--2', 47 | 'country--3', 48 | ]) 49 | ); 50 | }); 51 | return Promise.all([promise1, promise2, promise3, promise4]); 52 | }); 53 | 54 | afterAll(() => { 55 | server.close(); 56 | Quell.redisCache.flushAll(); 57 | Quell.redisCache.quit(); 58 | }); 59 | 60 | it('responds with a 200 status code', async () => { 61 | const response = await request(app).get('/redis'); 62 | expect(response.statusCode).toBe(200); 63 | }); 64 | 65 | it('gets stats from redis cache', async () => { 66 | const response = await request(app).get('/redis'); 67 | const redisStats = response.body.redisStats; 68 | expect(Object.keys(redisStats)).toEqual([ 69 | 'server', 70 | 'client', 71 | 'memory', 72 | 'stats', 73 | ]); 74 | }); 75 | 76 | it('gets keys from redis cache', async () => { 77 | const response = await request(app).get('/redis'); 78 | const redisKeys = response.body.redisKeys; 79 | expect(redisKeys).toEqual([ 80 | 'country--2', 81 | 'country--1', 82 | 'countries', 83 | 'country--3', 84 | ]); 85 | }); 86 | 87 | it('gets values from redis cache', async () => { 88 | const response = await request(app).get('/redis'); 89 | const redisValues = response.body.redisValues; 90 | expect(redisValues).toEqual([ 91 | '{"id":"2"}', 92 | '{"id":"1","capitol":{"id":"2","name":"DC"}}', 93 | '["country--1","country--2","country--3"]', 94 | '{"id":"3"}', 95 | ]); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /quell-server/__tests__/helpers/updateProtoWithFragment.test.ts: -------------------------------------------------------------------------------- 1 | import { updateProtoWithFragment } from '../../src/helpers/quellHelpers'; 2 | 3 | describe('tests for update prototype with fragments on the server side', () => { 4 | test('basic prototype object with 2 fields and a fragment, should convert to a protoype with 2 fields and the fields from the fragment without the fragment key on the prototype object', () => { 5 | const protoObj = { 6 | artists: { 7 | __id: null, 8 | __args: null, 9 | __alias: null, 10 | __type: 'artists', 11 | id: true, 12 | name: true, 13 | artistFragment: true, 14 | }, 15 | }; 16 | 17 | const fragment = { 18 | artistFragment: { 19 | instrument: true, 20 | band: true, 21 | hometown: true, 22 | }, 23 | }; 24 | 25 | expect(updateProtoWithFragment(protoObj, fragment)).toEqual({ 26 | 27 | artists: { 28 | __id: null, 29 | __args: null, 30 | __alias: null, 31 | __type: 'artists', 32 | id: true, 33 | name: true, 34 | instrument: true, 35 | band: true, 36 | hometown: true 37 | }, 38 | }) 39 | }); 40 | }) -------------------------------------------------------------------------------- /quell-server/__tests__/query.test.ts: -------------------------------------------------------------------------------- 1 | import e from 'express'; 2 | import { QuellCache } from '../src/quell'; 3 | import { RequestType } from '../src/types'; 4 | import schema from '../test-config/testSchema'; 5 | 6 | describe('server test for query', () => { 7 | const Quell = new QuellCache({ 8 | schema: schema, 9 | redisPort: Number(process.env.REDIS_PORT) || 6379, 10 | redisHost: process.env.REDIS_HOST || '127.0.0.1', 11 | redisPassword: process.env.REDIS_PASSWORD || '', 12 | }); 13 | // inputs: prototype object (which contains args), collection (defaults to an empty array) 14 | // outputs: protoype object with fields that were not found in the cache set to false 15 | 16 | beforeAll(() => { 17 | const promise1 = new Promise((resolve, reject) => { 18 | resolve( 19 | Quell.writeToCache('country--1', { 20 | id: '1', 21 | capitol: { id: '2', name: 'DC' }, 22 | }) 23 | ); 24 | }); 25 | const promise2 = new Promise((resolve, reject) => { 26 | resolve(Quell.writeToCache('country--2', { id: '2' })); 27 | }); 28 | const promise3 = new Promise((resolve, reject) => { 29 | resolve(Quell.writeToCache('country--3', { id: '3' })); 30 | }); 31 | const promise4 = new Promise((resolve, reject) => { 32 | resolve( 33 | Quell.writeToCache('countries', [ 34 | 'country--1', 35 | 'country--2', 36 | 'country--3', 37 | ]) 38 | ); 39 | }); 40 | return Promise.all([promise1, promise2, promise3, promise4]); 41 | }); 42 | 43 | // afterAll(() => { 44 | // Quell.redisCache.flushAll(); 45 | // Quell.redisCache.quit(); 46 | // }); 47 | 48 | test('If query is empty, should error out in rateLimiter', async () => { 49 | const mockReq = { 50 | body: {} 51 | } 52 | 53 | const mockRes = >> {}; 54 | const mockNext = jest.fn(); 55 | 56 | await Quell.rateLimiter( 57 | mockReq, mockRes, mockNext 58 | ); 59 | 60 | expect(mockNext).toHaveBeenCalledTimes(1); 61 | expect(mockNext).toHaveBeenCalledWith({ 62 | log: "Error: no GraphQL query found on request body, inside rateLimiter", 63 | status: 400, 64 | message: { 65 | err: "Error in rateLimiter: Bad Request. Check server log for more details.", 66 | }, 67 | }) 68 | }); 69 | 70 | test('If query is empty, should error out in query', async () => { 71 | const mockReq = { 72 | body: {} 73 | } 74 | 75 | const mockRes = >> {}; 76 | const mockNext = jest.fn(); 77 | 78 | await Quell.query( 79 | mockReq, mockRes, mockNext 80 | ); 81 | 82 | expect(mockNext).toHaveBeenCalledTimes(1); 83 | expect(mockNext).toHaveBeenCalledWith({ 84 | log: "Error: no GraphQL query found on request body", 85 | status: 400, 86 | message: { 87 | err: "Error in quellCache.query: Check server log for more details.", 88 | }, 89 | }) 90 | }); 91 | 92 | }); -------------------------------------------------------------------------------- /quell-server/eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier", 13 | "plugin:prettier/recommended" 14 | ], 15 | "overrides": [], 16 | "parserOptions": { 17 | "ecmaVersion": "latest" 18 | }, 19 | "plugins": ["@typescript-eslint", "prettier"], 20 | "rules": { 21 | "no-console": "off", 22 | "prefer-const": "warn", 23 | "quotes": ["warn", "single"], 24 | "semi": ["warn", "always"], 25 | "prettier/prettier": [ 26 | "error", 27 | { 28 | "printWidth": 80, 29 | "semi": true, 30 | "singleQuote": true, 31 | "tabWidth": 2, 32 | "bracketSpacing": true, 33 | "trailingComma": "none" 34 | } 35 | ], 36 | "space-before-function-paren": 0 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /quell-server/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testPathIgnorePatterns: ["dist"], 5 | forceCoverageMatch: ['**/*.test.js'], 6 | collectCoverage: true, 7 | }; -------------------------------------------------------------------------------- /quell-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@quell/server", 3 | "version": "9.0.1", 4 | "description": "Quell is an open-source NPM package providing a light-weight caching layer implementation and cache invalidation for GraphQL responses on both the client- and server-side. Use Quell to prevent redundant client-side API requests and to minimize costly server-side response latency.", 5 | "main": "dist/quell.js", 6 | "types": "dist/types.d.ts", 7 | "files": [ 8 | "dist/**/*", 9 | "package.json", 10 | "README.md" 11 | ], 12 | "scripts": { 13 | "test": "jest --forceExit", 14 | "clear-cache": "node bin/clearCache.js", 15 | "build": "tsc" 16 | }, 17 | "engines": { 18 | "node": ">=14.17.5", 19 | "npm": ">=8.1.0" 20 | }, 21 | "author": "Quell", 22 | "license": "MIT", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/open-source-labs/Quell/tree/main/quell-server" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/open-source-labs/Quell/issues" 29 | }, 30 | "keywords": [ 31 | "cache-invalidation", 32 | "depth-limiting", 33 | "depth limit", 34 | "cost-limiting", 35 | "cost limit", 36 | "graphQL", 37 | "cache", 38 | "caching", 39 | "redis", 40 | "batching", 41 | "server-side", 42 | "quell" 43 | ], 44 | "contributors": [ 45 | { 46 | "name": "Alexander Martinez", 47 | "url": "https://github.com/alexmartinez123" 48 | }, 49 | { 50 | "name": "Cera Barrow", 51 | "url": "https://github.com/cerab" 52 | }, 53 | { 54 | "name": "Jackie He", 55 | "url": "https://github.com/Jckhe" 56 | }, 57 | { 58 | "name": "Zoe Harper", 59 | "url": "https://github.com/ContraireZoe" 60 | }, 61 | { 62 | "name": "Idan Michael", 63 | "url": "https://github.com/idanmichael" 64 | }, 65 | { 66 | "name": "Sercan Tuna", 67 | "url": "https://github.com/srcntuna" 68 | }, 69 | { 70 | "name": "Thomas Pryor", 71 | "url": " https://github.com/Turmbeoz" 72 | }, 73 | { 74 | "name": "David Lopez", 75 | "url": "https://github.com/DavidMPLopez" 76 | }, 77 | { 78 | "name": "Chang Cai", 79 | "url": "https://github.com/ccai89" 80 | }, 81 | { 82 | "name": "Robert Howton", 83 | "url": "https://github.com/roberthowton" 84 | }, 85 | { 86 | "name": "Joshua Jordan", 87 | "url": "https://github.com/jjordan-90" 88 | }, 89 | { 90 | "name": "Jinhee Choi", 91 | "url": "https://github.com/jcroadmovie" 92 | }, 93 | { 94 | "name": "Nayan Parmar", 95 | "url": "https://github.com/nparmar1" 96 | }, 97 | { 98 | "name": "Tashrif Sanil", 99 | "url": "https://github.com/tashrifsanil" 100 | }, 101 | { 102 | "name": "Tim Frenzel", 103 | "url": "(https://github.com/TimFrenzel" 104 | }, 105 | { 106 | "name": "Thomas Reeder", 107 | "url": "https://github.com/nomtomnom" 108 | }, 109 | { 110 | "name": "Ken Litton", 111 | "url": "https://github.com/kenlitton" 112 | }, 113 | { 114 | "name": "Robleh Farah", 115 | "url": "https://github.com/farahrobleh" 116 | }, 117 | { 118 | "name": "Angela Franco", 119 | "url": "https://github.com/ajfranco18" 120 | }, 121 | { 122 | "name": "Andrei Cabrera", 123 | "url": "https://github.com/Andreicabrerao" 124 | }, 125 | { 126 | "name": "Dasha Kondratenko", 127 | "url": "https://github.com/dasha-k" 128 | }, 129 | { 130 | "name": "Derek Sirola", 131 | "url": "https://github.com/dsirola1" 132 | }, 133 | { 134 | "name": "Xiao Yu Omeara", 135 | "url": "https://github.com/xyomeara" 136 | }, 137 | { 138 | "name": "Mike Lauri", 139 | "url": "https://github.com/MichaelLauri" 140 | }, 141 | { 142 | "name": "Rob Nobile", 143 | "url": "https://github.com/RobNobile" 144 | }, 145 | { 146 | "name": "Justin Jaeger", 147 | "url": "https://github.com/justinjaeger" 148 | }, 149 | { 150 | "name": "Nick Kruckenberg", 151 | "url": "https://github.com/kruckenberg" 152 | }, 153 | { 154 | "name": "Cassidy Komp", 155 | "url": "https://github.com/mimikomp" 156 | }, 157 | { 158 | "name": "Andrew Dai", 159 | "url": "https://github.com/andrewmdai" 160 | }, 161 | { 162 | "name": "Stacey Lee", 163 | "url": "https://github.com/staceyjhlee" 164 | }, 165 | { 166 | "name": "Ian Weinholtz", 167 | "url": "https://github.com/itsHackinTime" 168 | } 169 | ], 170 | "homepage": "https://www.quell.dev/", 171 | "dependencies": { 172 | "dotenv": "^16.3.1", 173 | "express": "^4.18.2", 174 | "pg": "^8.11.1", 175 | "redis": "^4.6.5", 176 | "redis-mock": "^0.56.3" 177 | }, 178 | "peerDependencies": { 179 | "graphql": "^16.7.1" 180 | }, 181 | "devDependencies": { 182 | "@testing-library/react": "^14.0.0", 183 | "@types/express": "^4.17.17", 184 | "@types/jest": "^29.5.2", 185 | "@types/pg": "^8.10.2", 186 | "@types/supertest": "^2.0.12", 187 | "@typescript-eslint/eslint-plugin": "^5.54.1", 188 | "@typescript-eslint/parser": "^5.54.1", 189 | "eslint": "^8.35.0", 190 | "eslint-config-prettier": "^8.7.0", 191 | "eslint-config-standard-with-typescript": "^34.0.0", 192 | "eslint-plugin-import": "^2.27.5", 193 | "eslint-plugin-n": "^15.6.1", 194 | "eslint-plugin-prettier": "^4.2.1", 195 | "eslint-plugin-promise": "^6.1.1", 196 | "jest": "^29.5.0", 197 | "prettier": "^2.8.4", 198 | "supertest": "^6.1.6", 199 | "ts-jest": "^29.1.0", 200 | "typescript": "^4.9.5" 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /quell-server/src/helpers/redisConnection.ts: -------------------------------------------------------------------------------- 1 | import { RedisClientType } from "redis"; 2 | import { createClient } from "redis"; 3 | import dotenv from "dotenv"; 4 | dotenv.config(); 5 | 6 | 7 | // Create and export the Redis client instance 8 | 9 | const redisPort = Number(process.env.REDIS_PORT); 10 | const redisHost = process.env.REDIS_HOST; 11 | const redisPassword = process.env.REDIS_PASSWORD; 12 | 13 | 14 | export const redisCacheMain: RedisClientType = createClient({ 15 | socket: { host: redisHost, port: Number(redisPort) }, 16 | password: redisPassword, 17 | }); 18 | 19 | // Handle errors during the connection 20 | redisCacheMain.on("error", (error: Error) => { 21 | console.error(`Error when trying to connect to redisCacheMain: ${error}`); 22 | }); 23 | 24 | // Establish the connection to Redis 25 | redisCacheMain.connect().then(() => { 26 | console.log("Connected to redisCacheMain"); 27 | }); -------------------------------------------------------------------------------- /quell-server/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GraphQLSchema, 3 | IntValueNode, 4 | FloatValueNode, 5 | StringValueNode, 6 | BooleanValueNode, 7 | EnumValueNode, 8 | OperationDefinitionNode, 9 | VariableDefinitionNode, 10 | FieldNode, 11 | FragmentSpreadNode, 12 | InlineFragmentNode, 13 | FragmentDefinitionNode, 14 | SchemaDefinitionNode, 15 | ScalarTypeDefinitionNode, 16 | ObjectTypeDefinitionNode, 17 | FieldDefinitionNode, 18 | InputValueDefinitionNode, 19 | InterfaceTypeDefinitionNode, 20 | UnionTypeDefinitionNode, 21 | EnumTypeDefinitionNode, 22 | EnumValueDefinitionNode, 23 | InputObjectTypeDefinitionNode, 24 | SchemaExtensionNode, 25 | ScalarTypeExtensionNode, 26 | ObjectTypeExtensionNode, 27 | InterfaceTypeExtensionNode, 28 | UnionTypeExtensionNode, 29 | EnumTypeExtensionNode, 30 | InputObjectTypeExtensionNode, 31 | DocumentNode, 32 | ExecutionResult, 33 | } from 'graphql'; 34 | 35 | import { Response, Request } from 'express'; 36 | 37 | // QuellCache constructor parameters 38 | export interface ConstructorOptions { 39 | schema: GraphQLSchema; 40 | cacheExpiration?: number; 41 | costParameters?: CostParamsType; 42 | redisPort: number; 43 | redisHost: string; 44 | redisPassword: string; 45 | } 46 | 47 | export interface IdCacheType { 48 | [queryName: string]: { 49 | [fieldName: string]: string | string[]; 50 | }; 51 | } 52 | 53 | export interface CostParamsType { 54 | maxCost: number; 55 | mutationCost: number; 56 | objectCost: number; 57 | scalarCost: number; 58 | depthCostFactor: number; 59 | maxDepth: number; 60 | ipRate: number; 61 | } 62 | 63 | /** 64 | * The 'CustomError' interface extends the built-in 'Error' class and represents a custom error object 65 | * It adds optional properties for additional error information 66 | * @interface CustomError 67 | * @extends Error 68 | */ 69 | export interface CustomError extends Error { 70 | log?: string; 71 | status?: number; 72 | msg?: string; 73 | } 74 | 75 | export interface ProtoObjType { 76 | [key: string]: string | number | boolean | null | ProtoObjType; 77 | } 78 | 79 | export interface FragsType { 80 | [fragName: string]: { 81 | [fieldName: string]: boolean; 82 | }; 83 | } 84 | 85 | export interface MutationMapType { 86 | [mutationName: string]: string | undefined | ReturnType; 87 | } 88 | 89 | export interface QueryMapType { 90 | [queryName: string]: string | string[] | undefined; 91 | } 92 | 93 | export interface FieldsMapType { 94 | [typeName: string]: FieldsObjectType; 95 | } 96 | 97 | // Incomplete because not being used 98 | export interface IdMapType { 99 | [fieldType: string]: unknown; 100 | } 101 | 102 | export interface ParseASTOptions { 103 | userDefinedID?: string | null; 104 | } 105 | 106 | /* 107 | * The argsObj is used to store arguments. It is only used if the argument node is one of the 108 | * valid nodes included in the ValidArgumentNodeType interface. It key will be the field name (string) 109 | * and value will be the 'value' property of the argument node. For the valid argument nodes, the 110 | * 'value' property will be a string, boolean, or null. 111 | */ 112 | export interface ArgsObjType { 113 | [fieldName: string]: string | boolean | null; 114 | } 115 | 116 | export interface AuxObjType { 117 | __type?: string | boolean | null; 118 | __alias?: string | boolean | null; 119 | __args?: ArgsObjType | null; 120 | __id?: string | boolean | null; 121 | } 122 | 123 | export interface FieldArgsType { 124 | [fieldName: string]: AuxObjType; 125 | } 126 | 127 | /* 128 | * Types of arguments that Quell is able to cache 129 | */ 130 | export type ValidArgumentNodeType = 131 | | IntValueNode 132 | | FloatValueNode 133 | | StringValueNode 134 | | BooleanValueNode 135 | | EnumValueNode; 136 | // Excludes the following types: 137 | // | VariableNode 138 | // | NullValueNode 139 | // | ListValueNode 140 | // | ObjectValueNode 141 | 142 | export interface FieldsObjectType { 143 | [fieldName: string]: string | boolean | null | ArgsObjType | null; 144 | } 145 | 146 | export interface FieldsValuesType { 147 | [fieldName: string]: boolean; 148 | } 149 | 150 | export interface GQLResponseType { 151 | [key: string]: unknown; 152 | } 153 | 154 | export type GQLNodeWithDirectivesType = 155 | | OperationDefinitionNode 156 | | VariableDefinitionNode 157 | | FieldNode 158 | | FragmentSpreadNode 159 | | InlineFragmentNode 160 | | FragmentDefinitionNode 161 | | SchemaDefinitionNode 162 | | ScalarTypeDefinitionNode 163 | | ObjectTypeDefinitionNode 164 | | FieldDefinitionNode 165 | | InputValueDefinitionNode 166 | | InterfaceTypeDefinitionNode 167 | | UnionTypeDefinitionNode 168 | | EnumTypeDefinitionNode 169 | | EnumValueDefinitionNode 170 | | InputObjectTypeDefinitionNode 171 | | SchemaExtensionNode 172 | | ScalarTypeExtensionNode 173 | | ObjectTypeExtensionNode 174 | | InterfaceTypeExtensionNode 175 | | UnionTypeExtensionNode 176 | | EnumTypeExtensionNode 177 | | InputObjectTypeExtensionNode; 178 | export interface QueryObject { 179 | [query: string]: QueryFields; 180 | } 181 | 182 | export interface QueryFields { 183 | __id: string | null; 184 | __type: string; 185 | __alias: string | null; 186 | __args: null | Argument; 187 | // key can either be a field (ie. id, name) which would then have value of boolean 188 | // key can also be another QueryFields 189 | // null, string, and Argument are add'l types due to string index rules 190 | [key: string]: QueryFields | string | null | Argument | boolean; 191 | } 192 | 193 | interface Argument { 194 | [arg: string]: string | boolean; 195 | } 196 | 197 | export interface MapType { 198 | [query: string]: string | undefined; 199 | } 200 | 201 | export interface DatabaseResponseDataRaw { 202 | data: TypeData; 203 | } 204 | 205 | export interface TypeData { 206 | [type: string]: string | Type | Type[]; 207 | } 208 | 209 | export interface Type { 210 | id?: string; 211 | name?: string; 212 | [type: string]: Type | Type[] | string | undefined; 213 | [index: number]: Type | Type[] | string | undefined; 214 | } 215 | 216 | export interface MergedResponse { 217 | [key: string]: Data | Data[] | boolean | MergedResponse[] | MergedResponse; 218 | } 219 | 220 | export interface DataResponse { 221 | [key: string]: Data | Data[]; 222 | } 223 | 224 | export interface Data { 225 | [key: string]: DataField[] | string | number | Data | Data[]; 226 | } 227 | 228 | interface DataField { 229 | [key: string]: string; 230 | } 231 | 232 | export type MutationTypeFieldsType = { 233 | [key: string]: string | MutationTypeFieldsType | MutationTypeFieldsType[]; 234 | }; 235 | 236 | export type QueryTypeFieldsType = { 237 | [key: string]: 238 | | string 239 | | QueryTypeFieldsType 240 | | QueryTypeFieldsType[] 241 | | undefined; 242 | }; 243 | 244 | export type TypeMapFieldsType = { 245 | [key: string]: string | TypeMapFieldsType | TypeMapFieldsType[]; 246 | }; 247 | 248 | export type ItemFromCacheType = { 249 | [key: string]: any; 250 | }; 251 | 252 | export type PropsFilterType = { 253 | [k: string]: null | string | PropsFilterType; 254 | }; 255 | 256 | export type RedisOptionsType = { 257 | getStats: boolean; 258 | getKeys: boolean; 259 | getValues: boolean; 260 | }; 261 | 262 | export type RedisStatsType = { 263 | server: { name: string; value?: string }[]; 264 | client: { name: string; value?: string }[]; 265 | memory: { name: string; value?: string }[]; 266 | stats: { name: string; value?: string }[]; 267 | }; 268 | export type ServerErrorType = { 269 | log: string; 270 | status: number; 271 | message: { err: string }; 272 | }; 273 | 274 | export type ResponseDataType = { 275 | [k: string]: string | ResponseDataType | ResponseDataType[]; 276 | }; 277 | 278 | // Response Parameter Types: 279 | interface CostOptionsType { 280 | maxDepth?: number; 281 | maxCost?: number; 282 | ipRate?: number; 283 | } 284 | 285 | export interface RequestBodyType { 286 | query?: string; 287 | costOptions?: CostOptionsType; 288 | } 289 | 290 | // AST: 291 | export interface ParsedASTType { 292 | proto: ProtoObjType; 293 | operationType: string; 294 | frags: FragsType; 295 | } 296 | 297 | export type ReturnType = string | string[] | undefined; 298 | 299 | export type FieldType = { 300 | name: string; 301 | type: { 302 | name: string; 303 | ofType: { 304 | name: string; 305 | }; 306 | }; 307 | }; 308 | 309 | export type RedisValue = string | null | void; 310 | 311 | export interface RequestType extends Request { 312 | body: RequestBodyType; 313 | } 314 | 315 | export interface ResLocals extends Response { 316 | AST?: DocumentNode; 317 | parsedAST?: ParsedASTType; 318 | queryResponse?: ExecutionResult | RedisValue; 319 | redisStats?: RedisStatsType; 320 | queryErr?: ServerErrorType; 321 | redisValues?: (string | null)[]; 322 | redisKeys?: string[]; 323 | } 324 | 325 | export interface CustomResponse extends Response { 326 | locals: ResLocals; 327 | } 328 | 329 | export interface FieldKeyValue { 330 | [key: string]: string; 331 | } 332 | -------------------------------------------------------------------------------- /quell-server/test-config/booksModel.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Pool } from 'pg' 3 | import dotenv from 'dotenv' 4 | dotenv.config() 5 | 6 | 7 | const URI = process.env.PG_URI_BOOKS; 8 | 9 | const pool = new Pool({ 10 | connectionString: URI 11 | }); 12 | 13 | 14 | export default { 15 | query: (text: string, params?: (number | string)[]) => { 16 | return pool.query(text, params); 17 | } 18 | }; -------------------------------------------------------------------------------- /quell-server/test-config/countriesModel.ts: -------------------------------------------------------------------------------- 1 | import { Pool } from 'pg' 2 | import dotenv from 'dotenv' 3 | dotenv.config() 4 | 5 | const URI = process.env.PG_URI; 6 | 7 | const pool = new Pool({ 8 | connectionString: URI 9 | }); 10 | 11 | type Name = { 12 | name: string 13 | } 14 | 15 | export default { 16 | query: (text: string, params?: (number | string)[] ) => { 17 | return pool.query(text, params); 18 | }, 19 | // Below are placeholders, need to research more into the Pool methods 20 | create: (text: Name, params?: (number | string)[] ) => { 21 | return pool.query(text.name, params); 22 | }, 23 | findOne: (text: Name, params?: (number | string)[] ) => { 24 | return pool.query(text.name, params); 25 | }, 26 | deleteOne: (text: Name, params?: (number | string)[] ) => { 27 | return pool.query(text.name, params); 28 | } 29 | }; -------------------------------------------------------------------------------- /quell-server/test-config/test-data.ts: -------------------------------------------------------------------------------- 1 | interface Country { 2 | id: string; 3 | name: string; 4 | capital?: string 5 | population?: number 6 | cities?: Country[] 7 | } 8 | interface CountryType { 9 | [key: string]:Country[] 10 | } 11 | interface CountryAndCityType extends CountryType { 12 | 13 | } 14 | interface CountryIdType { 15 | [key: string]: Country 16 | } 17 | interface CountryIdWithCitiesType { 18 | country: Country 19 | } 20 | interface CountryIdWithCitiesAndCities extends CountryIdWithCitiesType { 21 | cities: Country[] 22 | } 23 | export const countryData:{ 24 | countries?: CountryType, 25 | countriesAndCities?: CountryAndCityType, 26 | countryId?: CountryIdType, 27 | countryIdWithCities?: CountryIdWithCitiesType 28 | countryIdWithCitiesAndCities?: CountryIdWithCitiesAndCities 29 | } = {}; 30 | 31 | 32 | countryData.countries = { 33 | "countries": [ 34 | { 35 | "id": "1", 36 | "name": "Andorra" 37 | }, 38 | { 39 | "id": "2", 40 | "name": "Bolivia" 41 | }, 42 | { 43 | "id": "3", 44 | "name": "Armenia" 45 | }, 46 | { 47 | "id": "4", 48 | "name": "American Samoa" 49 | }, 50 | { 51 | "id": "5", 52 | "name": "Aruba" 53 | } 54 | ] 55 | }; 56 | 57 | countryData.countriesAndCities = { 58 | "countries": [ 59 | { 60 | "id": "1", 61 | "name": "Andorra" 62 | }, 63 | { 64 | "id": "2", 65 | "name": "Bolivia" 66 | }, 67 | { 68 | "id": "3", 69 | "name": "Armenia" 70 | }, 71 | { 72 | "id": "4", 73 | "name": "American Samoa" 74 | }, 75 | { 76 | "id": "5", 77 | "name": "Aruba" 78 | } 79 | ], 80 | "cities": [ 81 | { 82 | "id": "1", 83 | "name": "El Tarter" 84 | }, 85 | { 86 | "id": "2", 87 | "name": "La Massana" 88 | }, 89 | { 90 | "id": "3", 91 | "name": "Canillo" 92 | }, 93 | { 94 | "id": "4", 95 | "name": "Andorra la Vella" 96 | }, 97 | { 98 | "id": "5", 99 | "name": "Jorochito" 100 | }, 101 | { 102 | "id": "6", 103 | "name": "Tupiza" 104 | }, 105 | { 106 | "id": "7", 107 | "name": "Puearto Pailas" 108 | }, 109 | { 110 | "id": "8", 111 | "name": "Capinota" 112 | }, 113 | { 114 | "id": "9", 115 | "name": "Camargo" 116 | }, 117 | { 118 | "id": "10", 119 | "name": "Villa Serrano" 120 | }, 121 | { 122 | "id": "11", 123 | "name": "Voskevaz" 124 | }, 125 | { 126 | "id": "12", 127 | "name": "Gavarr" 128 | }, 129 | { 130 | "id": "13", 131 | "name": "Nizami" 132 | }, 133 | { 134 | "id": "14", 135 | "name": "Metsavan" 136 | }, 137 | { 138 | "id": "15", 139 | "name": "Hnaberd" 140 | }, 141 | { 142 | "id": "16", 143 | "name": "Tāfuna" 144 | }, 145 | { 146 | "id": "17", 147 | "name": "Aūa" 148 | }, 149 | { 150 | "id": "18", 151 | "name": "Malaeimi" 152 | }, 153 | { 154 | "id": "19", 155 | "name": "Taulaga" 156 | }, 157 | { 158 | "id": "20", 159 | "name": "Fagatogo" 160 | }, 161 | { 162 | "id": "21", 163 | "name": "Oranjestad" 164 | }, 165 | { 166 | "id": "51", 167 | "name": "Paradera" 168 | } 169 | ] 170 | }; 171 | 172 | countryData.countryId = { 173 | "country": { 174 | "id": "1", 175 | "name": "Andorra", 176 | "capital": "Andorra la Vella" 177 | } 178 | }; 179 | 180 | countryData.countryIdWithCities = { 181 | "country": { 182 | "id": "1", 183 | "name": "Andorra", 184 | "cities": [ 185 | { 186 | "id": "1", 187 | "name": "El Tarter", 188 | "population": 1052 189 | }, 190 | { 191 | "id": "2", 192 | "name": "La Massana", 193 | "population": 7211 194 | }, 195 | { 196 | "id": "3", 197 | "name": "Canillo", 198 | "population": 3292 199 | }, 200 | { 201 | "id": "4", 202 | "name": "Andorra la Vella", 203 | "population": 20430 204 | } 205 | ] 206 | } 207 | }; 208 | 209 | countryData.countryIdWithCitiesAndCities = { 210 | "country": { 211 | "id": "1", 212 | "name": "Andorra", 213 | "cities": [ 214 | { 215 | "id": "1", 216 | "name": "El Tarter", 217 | "population": 1052 218 | }, 219 | { 220 | "id": "2", 221 | "name": "La Massana", 222 | "population": 7211 223 | }, 224 | { 225 | "id": "3", 226 | "name": "Canillo", 227 | "population": 3292 228 | }, 229 | { 230 | "id": "4", 231 | "name": "Andorra la Vella", 232 | "population": 20430 233 | } 234 | ] 235 | }, 236 | "cities": [ 237 | { 238 | "id": "1", 239 | "name": "El Tarter" 240 | }, 241 | { 242 | "id": "2", 243 | "name": "La Massana" 244 | }, 245 | { 246 | "id": "3", 247 | "name": "Canillo" 248 | }, 249 | { 250 | "id": "4", 251 | "name": "Andorra la Vella" 252 | }, 253 | { 254 | "id": "5", 255 | "name": "Jorochito" 256 | }, 257 | { 258 | "id": "6", 259 | "name": "Tupiza" 260 | }, 261 | { 262 | "id": "7", 263 | "name": "Puearto Pailas" 264 | }, 265 | { 266 | "id": "8", 267 | "name": "Capinota" 268 | }, 269 | { 270 | "id": "9", 271 | "name": "Camargo" 272 | }, 273 | { 274 | "id": "10", 275 | "name": "Villa Serrano" 276 | }, 277 | { 278 | "id": "11", 279 | "name": "Voskevaz" 280 | }, 281 | { 282 | "id": "12", 283 | "name": "Gavarr" 284 | }, 285 | { 286 | "id": "13", 287 | "name": "Nizami" 288 | }, 289 | { 290 | "id": "14", 291 | "name": "Metsavan" 292 | }, 293 | { 294 | "id": "15", 295 | "name": "Hnaberd" 296 | }, 297 | { 298 | "id": "16", 299 | "name": "Tāfuna" 300 | }, 301 | { 302 | "id": "17", 303 | "name": "Aūa" 304 | }, 305 | { 306 | "id": "18", 307 | "name": "Malaeimi" 308 | }, 309 | { 310 | "id": "19", 311 | "name": "Taulaga" 312 | }, 313 | { 314 | "id": "20", 315 | "name": "Fagatogo" 316 | }, 317 | { 318 | "id": "21", 319 | "name": "Oranjestad" 320 | }, 321 | { 322 | "id": "51", 323 | "name": "Paradera" 324 | } 325 | ] 326 | }; -------------------------------------------------------------------------------- /quell-server/test-config/test-server.ts: -------------------------------------------------------------------------------- 1 | // const express = require('express'); 2 | import express from 'express'; 3 | 4 | const app = express(); 5 | 6 | app.use(express.json()); 7 | 8 | export = app; -------------------------------------------------------------------------------- /quell-server/test-config/testSchema.ts: -------------------------------------------------------------------------------- 1 | import db from "./countriesModel"; 2 | import dbBooks from "./booksModel"; 3 | 4 | import { 5 | GraphQLSchema, 6 | GraphQLObjectType, 7 | GraphQLList, 8 | GraphQLID, 9 | GraphQLString, 10 | GraphQLInt, 11 | GraphQLNonNull, 12 | } from "graphql"; 13 | 14 | // =========================== // 15 | // ===== TYPE DEFINITIONS ==== // 16 | // =========================== // 17 | 18 | /* 19 | Generally corresponds with table we're pulling from 20 | */ 21 | 22 | type Parent = { 23 | [key: string]: string; 24 | }; 25 | 26 | type Arg = { 27 | [key: string]: string; 28 | }; 29 | 30 | const BookShelfType = new GraphQLObjectType({ 31 | name: "BookShelf", 32 | fields: () => ({ 33 | id: { type: GraphQLID }, 34 | name: { type: GraphQLString }, 35 | books: { 36 | type: new GraphQLList(BookType), 37 | async resolve(parent: Parent, args: Arg) { 38 | const booksList = await dbBooks.query( 39 | ` 40 | SELECT * FROM books WHERE shelf_id = $1`, 41 | [Number(parent.id)] 42 | ); 43 | 44 | return booksList.rows; 45 | }, 46 | }, 47 | }), 48 | }); 49 | 50 | const BookType = new GraphQLObjectType({ 51 | name: "Book", 52 | fields: () => ({ 53 | id: { type: GraphQLID }, 54 | name: { type: GraphQLString }, 55 | author: { type: GraphQLString }, 56 | shelf_id: { type: GraphQLString }, 57 | }), 58 | }); 59 | 60 | const CountryType = new GraphQLObjectType({ 61 | name: "Country", 62 | fields: () => ({ 63 | id: { type: GraphQLID }, 64 | name: { type: GraphQLString }, 65 | capital: { type: GraphQLString }, 66 | cities: { 67 | type: new GraphQLList(CityType), 68 | async resolve(parent: Parent, args: Arg) { 69 | const citiesList = await db.query( 70 | `SELECT * FROM cities WHERE country_id = $1`, 71 | [Number(parent.id)] 72 | ); 73 | return citiesList.rows; 74 | }, 75 | }, 76 | }), 77 | }); 78 | 79 | const CityType = new GraphQLObjectType({ 80 | name: "City", 81 | fields: () => ({ 82 | country_id: { type: GraphQLString }, 83 | id: { type: GraphQLID }, 84 | name: { type: GraphQLString }, 85 | population: { type: GraphQLInt }, 86 | }), 87 | }); 88 | 89 | // ADD LANGUAGES TYPE HERE 90 | 91 | // ================== // 92 | // ===== QUERIES ==== // 93 | // ================== // 94 | 95 | const RootQuery = new GraphQLObjectType({ 96 | name: "RootQueryType", 97 | fields: { 98 | // GET COUNTRY BY ID 99 | country: { 100 | type: CountryType, 101 | args: { id: { type: GraphQLID } }, 102 | async resolve(parent: Parent, args: Arg) { 103 | const country = await db.query( 104 | ` 105 | SELECT * FROM countries WHERE id = $1`, 106 | [Number(args.id)] 107 | ); 108 | 109 | return country.rows[0]; 110 | }, 111 | }, 112 | // GET ALL COUNTRIES 113 | countries: { 114 | type: new GraphQLList(CountryType), 115 | async resolve(parent: Parent, args: Arg) { 116 | const countriesFromDB = await db.query(` 117 | SELECT * FROM countries 118 | `); 119 | 120 | return countriesFromDB.rows; 121 | }, 122 | }, 123 | // GET ALL CITIES IN A COUNTRY 124 | citiesByCountry: { 125 | type: new GraphQLList(CityType), 126 | args: { country_id: { type: GraphQLID } }, 127 | async resolve(parent: Parent, args: Arg) { 128 | const citiesList = await db.query( 129 | ` 130 | SELECT * FROM cities WHERE country_id = $1`, 131 | [Number(args.country_id)] 132 | ); // need to dynamically resolve this 133 | 134 | return citiesList.rows; 135 | }, 136 | }, 137 | // GET CITY BY ID 138 | city: { 139 | type: CityType, 140 | args: { id: { type: GraphQLID } }, 141 | async resolve(parent: Parent, args: Arg) { 142 | const city = await db.query( 143 | ` 144 | SELECT * FROM cities WHERE id = $1`, 145 | [Number(args.id)] 146 | ); 147 | 148 | return city.rows[0]; 149 | }, 150 | }, 151 | // GET ALL CITIES 152 | cities: { 153 | type: new GraphQLList(CityType), 154 | async resolve(parent: Parent, args: Arg) { 155 | const citiesList = await db.query(` 156 | SELECT * FROM cities`); 157 | 158 | return citiesList.rows; 159 | }, 160 | }, 161 | // GET ALL BOOKS 162 | books: { 163 | type: new GraphQLList(BookType), 164 | async resolve(parent: Parent, args: Arg) { 165 | const books = await dbBooks.query(`SELECT * FROM books`); 166 | return books.rows; 167 | }, 168 | }, 169 | // GET BOOK BY ID 170 | book: { 171 | type: BookType, 172 | args: { id: { type: GraphQLID } }, 173 | async resolve(parent: Parent, args: Arg) { 174 | const book = await dbBooks.query(`SELECT * FROM books WHERE id = $1`, [ 175 | Number(args.id), 176 | ]); 177 | return book.rows[0]; 178 | }, 179 | }, 180 | // GET ALL BOOKSHELVES 181 | bookShelves: { 182 | type: new GraphQLList(BookShelfType), 183 | async resolve(parent: Parent, args: Arg) { 184 | const shelvesList = await dbBooks.query(` 185 | SELECT * FROM bookShelves`); 186 | 187 | return shelvesList.rows; 188 | }, 189 | }, 190 | // GET SHELF BY ID 191 | bookShelf: { 192 | type: BookShelfType, 193 | args: { id: { type: GraphQLID } }, 194 | async resolve(parent: Parent, args: Arg) { 195 | const bookShelf = await dbBooks.query( 196 | `SELECT * FROM bookShelves WHERE id = $1`, 197 | [Number(args.id)] 198 | ); 199 | 200 | return bookShelf.rows[0]; 201 | }, 202 | }, 203 | }, 204 | }); 205 | 206 | // ================== // 207 | // ===== MUTATIONS ==== // 208 | // ================== // 209 | 210 | const RootMutation = new GraphQLObjectType({ 211 | name: "RootMutationType", 212 | fields: { 213 | // add book 214 | addBook: { 215 | type: BookType, 216 | args: { 217 | name: { type: new GraphQLNonNull(GraphQLString) }, 218 | author: { type: GraphQLString }, 219 | shelf_id: { type: new GraphQLNonNull(GraphQLString) }, 220 | }, 221 | async resolve(parent: Parent, args: Arg) { 222 | const author = args.author || ""; 223 | 224 | const newBook = await dbBooks.query( 225 | `INSERT INTO books (name, author, shelf_id) VALUES ($1, $2, $3) RETURNING *`, 226 | [args.name, author, Number(args.shelf_id)] 227 | ); 228 | return newBook.rows[0]; 229 | }, 230 | }, 231 | // change book 232 | changeBook: { 233 | type: BookType, 234 | args: { 235 | id: { type: GraphQLID }, 236 | author: { type: GraphQLString }, 237 | }, 238 | async resolve(parent: Parent, args: Arg) { 239 | const updatedBook = await dbBooks.query( 240 | `UPDATE books SET author = $2 WHERE id = $1 RETURNING *`, 241 | [args.id, args.author] 242 | ); 243 | return updatedBook.rows[0]; 244 | }, 245 | }, 246 | // ADD SHELF 247 | addBookShelf: { 248 | type: BookShelfType, 249 | args: { 250 | name: { type: new GraphQLNonNull(GraphQLString) }, 251 | }, 252 | async resolve(parent: Parent, args: Arg) { 253 | const newBookShelf = await dbBooks.query( 254 | `INSERT INTO bookShelves (name) VALUES ($1) RETURNING *`, 255 | [args.name] 256 | ); 257 | return newBookShelf.rows[0]; 258 | }, 259 | }, 260 | // UPDATE SHELF 261 | //ADD COUNTRY (check functionality) 262 | addCountry: { 263 | type: CountryType, 264 | args: { 265 | capital: { type: GraphQLString }, 266 | cities: { type: GraphQLString }, 267 | id: { type: GraphQLID }, 268 | name: { type: GraphQLString }, 269 | }, 270 | async resolve(parent: Parent, args: Arg) { 271 | const countriesFromDB = await db.query( 272 | `INSERT INTO countries (capital, cities, id, name) VALUES($1, $2, $3, $4) RETURNING *`, 273 | [args.capital, args.cities, args.id, args.name] 274 | ); 275 | return countriesFromDB.rows[0]; 276 | }, 277 | }, 278 | }, 279 | }); 280 | 281 | // imported into server.js 282 | export default new GraphQLSchema({ 283 | query: RootQuery, 284 | mutation: RootMutation, 285 | types: [CountryType, CityType, BookType, BookShelfType], 286 | }); 287 | -------------------------------------------------------------------------------- /quell-server/test-config/testSchemaWithoutFields.ts: -------------------------------------------------------------------------------- 1 | import db from './countriesModel'; 2 | import dbBooks from './booksModel'; 3 | 4 | const graphqlNodeModule = 5 | process.env.NODE_ENV === 'development' 6 | ? '../../../quell-server/node_modules/graphql' 7 | : 'graphql'; 8 | 9 | import { 10 | GraphQLSchema, 11 | GraphQLObjectType, 12 | GraphQLList, 13 | GraphQLID, 14 | GraphQLString, 15 | GraphQLInt, 16 | GraphQLNonNull 17 | } from 'graphql' 18 | 19 | // =========================== // 20 | // ===== TYPE DEFINITIONS ==== // 21 | // =========================== // 22 | 23 | /* 24 | Generally corresponds with table we're pulling from 25 | */ 26 | 27 | const BookShelfType = new GraphQLObjectType({ 28 | name: 'BookShelf', 29 | fields: () => ({}) 30 | }); 31 | 32 | const BookType = new GraphQLObjectType({ 33 | name: 'Book', 34 | fields: () => ({}) 35 | }); 36 | 37 | const CountryType = new GraphQLObjectType({ 38 | name: 'Country', 39 | fields: () => ({}) 40 | }); 41 | 42 | const CityType = new GraphQLObjectType({ 43 | name: 'City', 44 | fields: () => ({}) 45 | }); 46 | 47 | // ADD LANGUAGES TYPE HERE 48 | 49 | // ================== // 50 | // ===== QUERIES ==== // 51 | // ================== // 52 | 53 | const RootQuery = new GraphQLObjectType({ 54 | name: 'RootQueryType', 55 | fields: {} 56 | }); 57 | 58 | // ================== // 59 | // ===== MUTATIONS ==== // 60 | // ================== // 61 | 62 | const RootMutation = new GraphQLObjectType({ 63 | name: 'RootMutationType', 64 | fields: {} 65 | }); 66 | 67 | // imported into server.js 68 | export default new GraphQLSchema({ 69 | query: RootQuery, 70 | mutation: RootMutation, 71 | types: [CountryType, CityType, BookType, BookShelfType] 72 | }); 73 | -------------------------------------------------------------------------------- /quell-server/test-config/testSchemaWithoutMuts.ts: -------------------------------------------------------------------------------- 1 | import db from './countriesModel'; 2 | import dbBooks from './booksModel'; 3 | 4 | import { 5 | GraphQLSchema, 6 | GraphQLObjectType, 7 | GraphQLList, 8 | GraphQLID, 9 | GraphQLString, 10 | GraphQLInt, 11 | GraphQLNonNull 12 | } from 'graphql'; 13 | 14 | // =========================== // 15 | // ===== TYPE DEFINITIONS ==== // 16 | // =========================== // 17 | 18 | /* 19 | Generally corresponds with table we're pulling from 20 | */ 21 | 22 | type Parent = { 23 | [ key: string ]: string 24 | } 25 | 26 | type Arg = { 27 | [ key: string ]: string 28 | } 29 | 30 | const BookShelfType = new GraphQLObjectType({ 31 | name: 'BookShelf', 32 | fields: () => ({ 33 | id: { type: GraphQLID }, 34 | name: { type: GraphQLString }, 35 | books: { 36 | type: new GraphQLList(BookType), 37 | async resolve(parent: Parent, args: Arg) { 38 | const booksList = await dbBooks.query( 39 | ` 40 | SELECT * FROM books WHERE shelf_id = $1`, 41 | [Number(parent.id)] 42 | ); 43 | 44 | return booksList.rows; 45 | } 46 | } 47 | }) 48 | }); 49 | 50 | const BookType = new GraphQLObjectType({ 51 | name: 'Book', 52 | fields: () => ({ 53 | id: { type: GraphQLID }, 54 | name: { type: GraphQLString }, 55 | author: { type: GraphQLString }, 56 | shelf_id: { type: GraphQLString } 57 | }) 58 | }); 59 | 60 | const CountryType = new GraphQLObjectType({ 61 | name: 'Country', 62 | fields: () => ({ 63 | id: { type: GraphQLID }, 64 | name: { type: GraphQLString }, 65 | capital: { type: GraphQLString }, 66 | cities: { 67 | type: new GraphQLList(CityType), 68 | async resolve(parent: Parent, args: Arg) { 69 | const citiesList = await db.query( 70 | `SELECT * FROM cities WHERE country_id = $1`, 71 | [Number(parent.id)] 72 | ); 73 | 74 | return citiesList.rows; 75 | } 76 | } 77 | }) 78 | }); 79 | 80 | const CityType = new GraphQLObjectType({ 81 | name: 'City', 82 | fields: () => ({ 83 | country_id: { type: GraphQLString }, 84 | id: { type: GraphQLID }, 85 | name: { type: GraphQLString }, 86 | population: { type: GraphQLInt } 87 | }) 88 | }); 89 | 90 | // ADD LANGUAGES TYPE HERE 91 | 92 | // ================== // 93 | // ===== QUERIES ==== // 94 | // ================== // 95 | 96 | const RootQuery = new GraphQLObjectType({ 97 | name: 'RootQueryType', 98 | fields: { 99 | // GET COUNTRY BY ID 100 | country: { 101 | type: CountryType, 102 | args: { id: { type: GraphQLID } }, 103 | async resolve(parent: Parent, args: Arg) { 104 | const country = await db.query( 105 | ` 106 | SELECT * FROM countries WHERE id = $1`, 107 | [Number(args.id)] 108 | ); 109 | 110 | return country.rows[0]; 111 | } 112 | }, 113 | // GET ALL COUNTRIES 114 | countries: { 115 | type: new GraphQLList(CountryType), 116 | async resolve(parent: Parent, args: Arg) { 117 | const countriesFromDB = await db.query(` 118 | SELECT * FROM countries 119 | `); 120 | 121 | return countriesFromDB.rows; 122 | } 123 | }, 124 | // GET ALL CITIES IN A COUNTRY 125 | citiesByCountry: { 126 | type: new GraphQLList(CityType), 127 | args: { country_id: { type: GraphQLID } }, 128 | async resolve(parent: Parent, args: Arg) { 129 | const citiesList = await db.query( 130 | ` 131 | SELECT * FROM cities WHERE country_id = $1`, 132 | [Number(args.country_id)] 133 | ); // need to dynamically resolve this 134 | 135 | return citiesList.rows; 136 | } 137 | }, 138 | // GET CITY BY ID 139 | city: { 140 | type: CityType, 141 | args: { id: { type: GraphQLID } }, 142 | async resolve(parent: Parent, args: Arg) { 143 | const city = await db.query( 144 | ` 145 | SELECT * FROM cities WHERE id = $1`, 146 | [Number(args.id)] 147 | ); 148 | 149 | return city.rows[0]; 150 | } 151 | }, 152 | // GET ALL CITIES 153 | cities: { 154 | type: new GraphQLList(CityType), 155 | async resolve(parent: Parent, args: Arg) { 156 | const citiesList = await db.query(` 157 | SELECT * FROM cities`); 158 | 159 | return citiesList.rows; 160 | } 161 | }, 162 | // GET ALL BOOKS 163 | books: { 164 | type: new GraphQLList(BookType), 165 | async resolve(parent: Parent, args: Arg) { 166 | const books = await dbBooks.query(`SELECT * FROM books`); 167 | return books.rows; 168 | } 169 | }, 170 | // GET BOOK BY ID 171 | book: { 172 | type: BookType, 173 | args: { id: { type: GraphQLID } }, 174 | async resolve(parent: Parent, args: Arg) { 175 | const book = await dbBooks.query(`SELECT * FROM books WHERE id = $1`, [ 176 | Number(args.id) 177 | ]); 178 | return book.rows[0]; 179 | } 180 | }, 181 | // GET ALL BOOKSHELVES 182 | bookShelves: { 183 | type: new GraphQLList(BookShelfType), 184 | async resolve(parent: Parent, args: Arg) { 185 | const shelvesList = await dbBooks.query(` 186 | SELECT * FROM bookShelves`); 187 | 188 | return shelvesList.rows; 189 | } 190 | }, 191 | // GET SHELF BY ID 192 | bookShelf: { 193 | type: BookShelfType, 194 | args: { id: { type: GraphQLID } }, 195 | async resolve(parent: Parent, args: Arg) { 196 | const bookShelf = await dbBooks.query( 197 | `SELECT * FROM bookShelves WHERE id = $1`, 198 | [Number(args.id)] 199 | ); 200 | 201 | return bookShelf.rows[0]; 202 | } 203 | } 204 | } 205 | }); 206 | 207 | // imported into server.js 208 | 209 | export default new GraphQLSchema({ 210 | query: RootQuery, 211 | types: [CountryType, CityType, BookType, BookShelfType] 212 | }); -------------------------------------------------------------------------------- /quell-server/test-config/testSchemaWithoutQueries.ts: -------------------------------------------------------------------------------- 1 | import db from './countriesModel'; 2 | import dbBooks from './booksModel'; 3 | 4 | import { 5 | GraphQLSchema, 6 | GraphQLObjectType, 7 | GraphQLList, 8 | GraphQLID, 9 | GraphQLString, 10 | GraphQLInt, 11 | GraphQLNonNull 12 | 13 | } from 'graphql'; 14 | import { QueryResult } from 'pg'; 15 | 16 | 17 | // =========================== // 18 | // ===== TYPE DEFINITIONS ==== // 19 | // =========================== // 20 | 21 | /* 22 | Generally corresponds with table we're pulling from 23 | */ 24 | 25 | type Parent = { 26 | [ key: string ]: string 27 | } 28 | 29 | type Arg = { 30 | [ key: string ]: string 31 | } 32 | 33 | const BookShelfType = new GraphQLObjectType({ 34 | name: 'BookShelf', 35 | fields: () => ({ 36 | id: { type: GraphQLID }, 37 | name: { type: GraphQLString }, 38 | books: { 39 | type: new GraphQLList(BookType), 40 | 41 | async resolve(parent: Parent, args: Arg) { 42 | const booksList: QueryResult = await dbBooks.query( 43 | 44 | ` 45 | SELECT * FROM books WHERE shelf_id = $1`, 46 | [Number(parent.id)] 47 | ); 48 | 49 | return booksList.rows; 50 | } 51 | } 52 | }) 53 | }); 54 | 55 | const BookType = new GraphQLObjectType({ 56 | name: 'Book', 57 | fields: () => ({ 58 | id: { type: GraphQLID }, 59 | name: { type: GraphQLString }, 60 | author: { type: GraphQLString }, 61 | shelf_id: { type: GraphQLString } 62 | }) 63 | }); 64 | 65 | const CountryType = new GraphQLObjectType({ 66 | name: 'Country', 67 | fields: () => ({ 68 | id: { type: GraphQLID }, 69 | name: { type: GraphQLString }, 70 | capital: { type: GraphQLString }, 71 | cities: { 72 | type: new GraphQLList(CityType), 73 | 74 | async resolve(parent: Parent, args: Arg) { 75 | const citiesList = await db.query( 76 | `SELECT * FROM cities WHERE country_id = $1`, 77 | [Number(parent.id)] 78 | ); 79 | 80 | return citiesList.rows; 81 | } 82 | } 83 | }) 84 | }); 85 | 86 | const CityType = new GraphQLObjectType({ 87 | name: 'City', 88 | fields: () => ({ 89 | country_id: { type: GraphQLString }, 90 | id: { type: GraphQLID }, 91 | name: { type: GraphQLString }, 92 | population: { type: GraphQLInt } 93 | }) 94 | }); 95 | 96 | // ADD LANGUAGES TYPE HERE 97 | // ================== // 98 | // ===== MUTATIONS ==== // 99 | // ================== // 100 | 101 | const RootMutation = new GraphQLObjectType({ 102 | name: 'RootMutationType', 103 | fields: { 104 | // add book 105 | addBook: { 106 | type: BookType, 107 | args: { 108 | name: { type: new GraphQLNonNull(GraphQLString) }, 109 | author: { type: GraphQLString }, 110 | shelf_id: { type: new GraphQLNonNull(GraphQLString) } 111 | }, 112 | 113 | async resolve(parent: Parent, args: Arg) { 114 | const author = args.author || ''; 115 | 116 | const newBook = await dbBooks.query( 117 | `INSERT INTO books (name, author, shelf_id) VALUES ($1, $2, $3) RETURNING *`, 118 | [args.name, author, Number(args.shelf_id)] 119 | ); 120 | return newBook.rows[0]; 121 | } 122 | }, 123 | // change book 124 | changeBook: { 125 | type: BookType, 126 | args: { 127 | id: { type: GraphQLID }, 128 | author: { type: GraphQLString } 129 | }, 130 | 131 | async resolve(parent: Parent, args: Arg) { 132 | const updatedBook = await dbBooks.query( 133 | `UPDATE books SET author = $2 WHERE id = $1 RETURNING *`, 134 | [args.id, args.author] 135 | ); 136 | return updatedBook.rows[0]; 137 | } 138 | }, 139 | // ADD SHELF 140 | addBookShelf: { 141 | type: BookShelfType, 142 | args: { 143 | name: { type: new GraphQLNonNull(GraphQLString) } 144 | }, 145 | 146 | async resolve(parent: Parent, args: Arg) { 147 | const newBookShelf = await dbBooks.query( 148 | `INSERT INTO bookShelves (name) VALUES ($1) RETURNING *`, 149 | [args.name] 150 | ); 151 | return newBookShelf.rows[0]; 152 | } 153 | }, 154 | // ADD COUNTRY 155 | addCountry: { 156 | type: CountryType, 157 | args: { name: { type: GraphQLString } }, 158 | 159 | async resolve(parent: Parent, args: Arg) { 160 | const country = await db.create({ name: args.name }); 161 | return country; 162 | } 163 | }, 164 | deleteCity: { 165 | type: CityType, 166 | args: { name: { type: GraphQLString } }, 167 | 168 | async resolve(parent: Parent, args: Arg) { 169 | const findCity = await db.findOne({ name: args.name }); 170 | if (findCity) { 171 | await db.deleteOne({ name: args.name }); 172 | } 173 | } 174 | } 175 | } 176 | }); 177 | 178 | export default new GraphQLSchema({ 179 | mutation: RootMutation, 180 | types: [CountryType, CityType, BookType, BookShelfType] 181 | }); 182 | 183 | --------------------------------------------------------------------------------