├── npm_package ├── README.md ├── index.js ├── package.json ├── redis.js ├── buql.js └── helpers.js ├── demo ├── public │ ├── BuQL.png │ ├── linkedin2.svg │ ├── twitter2.svg │ ├── medium.svg │ ├── github-mark.svg │ └── vite.svg ├── vite.config.js ├── src │ ├── main.jsx │ ├── components │ │ ├── Home.css │ │ ├── testData.js │ │ ├── Home.jsx │ │ ├── AboutUs.css │ │ ├── QueryTable.jsx │ │ ├── QueryForm.css │ │ ├── Queries.js │ │ ├── AboutUs.jsx │ │ ├── BarChart.jsx │ │ └── QueryForm.jsx │ ├── index.css │ ├── App.jsx │ ├── App.css │ └── assets │ │ └── react.svg ├── server │ ├── controllers │ │ ├── redis.js │ │ ├── buql.js │ │ ├── security.js │ │ └── helpers.js │ ├── models │ │ ├── models.js │ │ └── fake.js │ ├── index.js │ ├── __test__ │ │ └── index.test.js │ └── schema │ │ └── schema.js ├── .gitignore ├── index.html ├── README.md ├── .eslintrc.cjs └── package.json └── README.md /npm_package/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/public/BuQL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/BuQL/HEAD/demo/public/BuQL.png -------------------------------------------------------------------------------- /npm_package/index.js: -------------------------------------------------------------------------------- 1 | const buql = require('./buql'); 2 | 3 | module.exports = { 4 | buql, 5 | }; -------------------------------------------------------------------------------- /demo/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /npm_package/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@buql/buql", 3 | "private": false, 4 | "version": "0.2.0", 5 | "type": "module", 6 | "main": "buql.js", 7 | "dependencies": { 8 | "graphql": "^16.8.1", 9 | "ioredis": "^5.3.2" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /demo/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.jsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /npm_package/redis.js: -------------------------------------------------------------------------------- 1 | // import ioredis 2 | import { Redis } from 'ioredis'; 3 | 4 | // initialize, with config if necessary 5 | const redis = new Redis(); 6 | 7 | // set the default config for redis caching 8 | redis.config('set', 'maxmemory', '100mb'); 9 | redis.config('set', 'maxmemory-policy', 'allkeys-lfu'); 10 | 11 | export default redis; 12 | -------------------------------------------------------------------------------- /demo/public/linkedin2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/server/controllers/redis.js: -------------------------------------------------------------------------------- 1 | // import ioredis 2 | import { Redis } from 'ioredis'; 3 | 4 | // initialize, with config if necessary 5 | const redis = new Redis(); 6 | 7 | // set the default config for redis caching 8 | await redis.config('set', 'maxmemory', '100mb'); 9 | await redis.config('set', 'maxmemory-policy', 'allkeys-lfu'); 10 | 11 | export default redis; -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .env 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | *.lockb -------------------------------------------------------------------------------- /demo/src/components/Home.css: -------------------------------------------------------------------------------- 1 | #home{ 2 | margin-top: 100px; 3 | display: flex; 4 | width: 500px; 5 | /* justify-content: flex-start; */ 6 | align-items: center; 7 | flex-direction: column; 8 | padding: 20px; 9 | background-color: rgb(28, 28, 28); 10 | border: 2px solid pink; 11 | box-shadow: 0px 0px 10px 3px pink; 12 | border-radius: 5px; 13 | margin-bottom: 100px; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | BuQL 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | -------------------------------------------------------------------------------- /demo/server/models/models.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | // connect to mongoose 4 | // const uri = Bun.env.MONGO; 5 | 6 | // await mongoose.connect(uri).then(() => console.log('DB connected.')); 7 | 8 | // define schema 9 | const userSchema = new mongoose.Schema({ 10 | username: String, 11 | email: String, 12 | birthdate: Date, 13 | registeredAt: Date, 14 | }); 15 | 16 | // export schema 17 | export const User = mongoose.model('User', userSchema, 'user'); 18 | -------------------------------------------------------------------------------- /demo/public/twitter2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/src/components/testData.js: -------------------------------------------------------------------------------- 1 | export const UserData = [ 2 | { 3 | fName: 'Jake', 4 | lName: 'Diamond', 5 | age: 26, 6 | country: 'USA', 7 | 8 | }, 9 | { 10 | fName: 'Dylan', 11 | lName: 'Compton', 12 | age: 23, 13 | country: 'USA', 14 | 15 | }, 16 | { 17 | fName: 'Julien', 18 | lName: 'Kerekes', 19 | age: 30, 20 | country: 'Germany', 21 | 22 | }, 23 | { 24 | fName: 'Joe', 25 | lName: 'McGarry', 26 | age: 35, 27 | country: "USA", 28 | 29 | } 30 | ] 31 | -------------------------------------------------------------------------------- /demo/public/medium.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react/jsx-no-target-blank': 'off', 16 | 'react-refresh/only-export-components': [ 17 | 'warn', 18 | { allowConstantExport: true }, 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /demo/src/components/Home.jsx: -------------------------------------------------------------------------------- 1 | import './Home.css' 2 | 3 | function Home(){ 4 | return ( 5 |
6 |
7 |

Explained

8 |

Light-weight NPM package for caching and querying in GraphQL

9 |

BuQL is a lightweight NPM packing for server-side GraphQL caching in the Bun runtime environment. 10 | Boasting ZERO dependencies, our open source product, is designed retrieve queries with lighting speed 11 | from your Redis cache. 12 |

13 |
14 |
15 | ) 16 | } 17 | 18 | 19 | export default Home; -------------------------------------------------------------------------------- /demo/server/models/fake.js: -------------------------------------------------------------------------------- 1 | import { User } from './models'; 2 | import { faker } from '@faker-js/faker'; 3 | 4 | const generateRandomUser = (num) => { 5 | const users = []; 6 | 7 | for (let i = 0; i < num; i++) { 8 | const user = { 9 | username: faker.internet.userName(), 10 | email: faker.internet.email(), 11 | birthdate: faker.date.birthdate(), 12 | registeredAt: faker.date.past(), 13 | }; 14 | 15 | users.push(user); 16 | } 17 | 18 | return users; 19 | }; 20 | 21 | export const addToDb = async (num) => { 22 | // mongoose insert many 23 | const userArray = generateRandomUser(num); 24 | const result = await User.insertMany(userArray); 25 | // return result; 26 | console.log(result); 27 | }; 28 | 29 | // const deleteAllFromDb = async () => { 30 | // // mongoose delete many 31 | // const result = await User.deleteMany({}); 32 | // console.log(result); 33 | // }; -------------------------------------------------------------------------------- /demo/public/github-mark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/src/components/AboutUs.css: -------------------------------------------------------------------------------- 1 | #aboutUs{ 2 | display: grid; 3 | flex-direction: column; 4 | align-items: center; 5 | padding: 20px; 6 | } 7 | 8 | .team-container { 9 | display: grid; 10 | grid-template-columns: repeat(4, 1fr); /* Creates 2 columns */ 11 | gap: 100px; /* Space between grid items */ 12 | /* padding: 20px; */ 13 | } 14 | 15 | .team-member { 16 | background-color: rgb(28, 28, 28); 17 | border: 2px solid pink; 18 | box-shadow: 0px 0px 10px 2px pink; 19 | border-radius: 15px; 20 | padding: 15px; 21 | display: flex; 22 | justify-content: center; 23 | flex-direction: column; 24 | align-items: center; 25 | text-align: center; 26 | } 27 | 28 | .team-member:hover { 29 | transform: scale(1.2); 30 | filter: drop-shadow(0px 0px 5px hotpink); 31 | } 32 | 33 | #linked-git{ 34 | display: flex; 35 | justify-content: space-between; 36 | align-items: center; 37 | width: 50%; 38 | min-width: 20px; 39 | } -------------------------------------------------------------------------------- /demo/server/controllers/buql.js: -------------------------------------------------------------------------------- 1 | // import redis from './redis'; 2 | import {handleQuery} from './helpers'; 3 | 4 | const buql = {}; 5 | 6 | buql.cache = async (req, res, next) => { 7 | const {query} = req.body; 8 | 9 | // check if query is a mutation 10 | if (query.includes('mutation')) { 11 | // send query to graphql route 12 | const data = await fetch(`http://localhost:8080/graphql`, { 13 | method: 'POST', 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | }, 17 | body: JSON.stringify({query: query}), 18 | }); 19 | 20 | // clear the redis cache 21 | // await redis.flushdb(); 22 | 23 | // parse and return the response 24 | const parsed = await data.json(); 25 | const mutationResponse = {source: 'mutation', response: parsed}; 26 | res.locals.response = mutationResponse; 27 | 28 | return next(); 29 | } 30 | 31 | // if query, proceed as normal 32 | const queryRes = await handleQuery(query); 33 | res.locals.response = queryRes; 34 | return next(); 35 | }; 36 | 37 | buql.clearCache = async (req, res, next) => { 38 | // clear the cache 39 | // await redis.flushdb(); 40 | return next(); 41 | }; 42 | 43 | export default buql; 44 | -------------------------------------------------------------------------------- /demo/server/index.js: -------------------------------------------------------------------------------- 1 | // typical imports 2 | import express from 'express'; 3 | const app = express(); 4 | const port = 8080;//Bun.env.PORT; 5 | 6 | // import graphql and schema 7 | import {graphqlHTTP} from 'express-graphql'; 8 | import {schema} from './schema/schema'; 9 | 10 | app.use(express.json()); 11 | 12 | // import buql 13 | import buql from '@buql/buql'; 14 | 15 | app.use('/buql', buql.security, buql.cache, (req, res) => { 16 | return res.status(200).send(res.locals.response); 17 | }); 18 | 19 | app.use('/clearCache', buql.clearCache, (req, res) => { 20 | return res.status(200).send('cache cleared'); 21 | }); 22 | 23 | // Standalone graphql route 24 | app.use( 25 | '/graphql', 26 | graphqlHTTP({ 27 | schema, 28 | graphiql: true, 29 | }) 30 | ); 31 | 32 | app.use('*', (req, res) => res.status(404).send('Page not found')); 33 | 34 | app.use((err, req, res) => { 35 | const defaultErr = { 36 | log: 'Express error handler caught an unknown middlware error', 37 | status: 500, 38 | message: {err: 'An error occurred'}, 39 | }; 40 | const errorObj = Object.assign({}, defaultErr, err); 41 | console.log(errorObj); 42 | return res.status(errorObj.status).send(errorObj.message); 43 | }); 44 | 45 | app.listen(port, () => console.log(`Listening on port ${port}...`)); 46 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-demo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "bunx --bun vite", 8 | "backend": "bun --hot run ./server/index.js", 9 | "build": "vite build", 10 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@buql/buql": "^0.2.0", 15 | "@faker-js/faker": "^8.4.1", 16 | "chart.js": "^4.4.1", 17 | "cors": "^2.8.5", 18 | "express": "^4.18.2", 19 | "express-graphql": "^0.12.0", 20 | "graphql": "^16.8.1", 21 | "graphql-depth-limit": "1.1.0", 22 | "graphql-http": "^1.22.0", 23 | "graphql-validation-complexity": "^0.4.2", 24 | "ioredis": "^5.3.2", 25 | "mongoose": "^8.1.1", 26 | "path": "^0.12.7", 27 | "react": "^18.2.0", 28 | "react-chartjs-2": "^5.2.0", 29 | "react-dom": "^18.2.0", 30 | "react-json-pretty": "^2.2.0", 31 | "react-router-dom": "^6.22.1", 32 | "react-table": "^7.8.0" 33 | }, 34 | "devDependencies": { 35 | "@types/react": "^18.2.55", 36 | "@types/react-dom": "^18.2.19", 37 | "@vitejs/plugin-react": "^4.2.1", 38 | "eslint": "^8.56.0", 39 | "eslint-plugin-react": "^7.33.2", 40 | "eslint-plugin-react-hooks": "^4.6.0", 41 | "eslint-plugin-react-refresh": "^0.4.5", 42 | "vite": "^5.1.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /demo/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/server/__test__/index.test.js: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, /* beforeAll, beforeEach */ } from 'bun:test'; 2 | 3 | describe('GraphQL route', () => { 4 | describe('POST', () => { 5 | test('adding user is successful', async () => { 6 | const data = await fetch('http://localhost:8080/graphql', { 7 | method: 'POST', 8 | headers: { 9 | 'Content-Type': 'application/json', 10 | }, 11 | body: JSON.stringify({ query: 'mutation { addRandomUsers(num: 1) { id username email birthdate registeredAt } }' }) 12 | }) 13 | expect(data.json()).resolves.toEqual(expect.objectContaining({ 14 | data: expect.any(Object), 15 | })); 16 | }) 17 | test('adding random user returns a successful status code', async () => { 18 | const data = await fetch('http://localhost:8080/graphql', { 19 | method: 'POST', 20 | headers: { 21 | 'Content-Type': 'application/json', 22 | }, 23 | body: JSON.stringify({ query: 'mutation { addRandomUsers(num: 1) { id username email birthdate registeredAt } }' }) 24 | }) 25 | expect(data.status).toBe(200); 26 | }) 27 | test('rejects queries with forbidden characters', async () => { 28 | const data = await fetch('http://localhost:8080/graphql', { 29 | method: 'POST', 30 | headers: { 31 | 'Content-Type': 'application/json', 32 | }, 33 | body: JSON.stringify({ query: '{ getUserById(id: "65cdf731aeda2e240baec9fd" OR 1=1) { id username email }' }) 34 | }) 35 | expect(data.status).toBe(403); 36 | }) 37 | }) 38 | }) -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Anta&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap'); 3 | 4 | :root { 5 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 6 | line-height: 1.5; 7 | font-weight: 400; 8 | 9 | color-scheme: light dark; 10 | color: rgba(255, 255, 255, 0.87); 11 | /* background-color: #242424; */ 12 | background-image: linear-gradient(to bottom, black, rgb(97, 97, 97), black); 13 | 14 | font-synthesis: none; 15 | text-rendering: optimizeLegibility; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | } 19 | 20 | a { 21 | font-weight: 500; 22 | color: #646cff; 23 | 24 | text-decoration: inherit; 25 | } 26 | a:hover { 27 | color: #535bf2; 28 | } 29 | 30 | body { 31 | margin: 0; 32 | display: flex; 33 | place-items: center; 34 | min-width: 320px; 35 | min-height: 100vh; 36 | } 37 | 38 | h1 { 39 | font-size: 2.6em; 40 | line-height: 1.1; 41 | } 42 | 43 | button { 44 | border-radius: 8px; 45 | border: 1px solid transparent; 46 | padding: 0.6em 1.2em; 47 | font-size: 1em; 48 | font-weight: 500; 49 | font-family: inherit; 50 | background-color: #1a1a1a; 51 | cursor: pointer; 52 | transition: border-color 0.25s; 53 | } 54 | button:hover { 55 | /* border-color: #646cff; */ 56 | border-color: #f077bc; 57 | color: #faefdf; 58 | box-shadow: 0px 0px 7px 3px #f077bc; 59 | } 60 | button:focus, 61 | button:focus-visible { 62 | outline: 4px auto -webkit-focus-ring-color; 63 | } 64 | 65 | @media (prefers-color-scheme: light) { 66 | :root { 67 | color: #213547; 68 | background-color: #ffffff; 69 | } 70 | a:hover { 71 | color: #747bff; 72 | } 73 | button { 74 | background-color: #f9f9f9; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /npm_package/buql.js: -------------------------------------------------------------------------------- 1 | import redis from './redis'; 2 | import {handleQuery} from './helpers'; 3 | 4 | const buql = {}; 5 | 6 | buql.cache = async (req, res, next) => { 7 | const {query} = req.body; 8 | console.log(req.body); 9 | 10 | // check if query is a mutation 11 | if (query.includes('mutation')) { 12 | // send query to graphql route 13 | const data = await fetch(`http://localhost:${Bun.env.PORT}/graphql`, { 14 | method: 'POST', 15 | headers: { 16 | 'Content-Type': 'application/json', 17 | }, 18 | body: JSON.stringify({query: query}), 19 | }); 20 | 21 | // clear the redis cache 22 | await redis.flushdb(); 23 | 24 | // parse and return the response 25 | const parsed = await data.json(); 26 | const mutationResponse = {source: 'mutation', response: parsed}; 27 | res.locals.response = mutationResponse; 28 | 29 | return next(); 30 | } 31 | 32 | // if query, proceed as normal 33 | const queryRes = await handleQuery(query); 34 | res.locals.response = queryRes; 35 | return next(); 36 | }; 37 | 38 | buql.clearCache = async (req, res, next) => { 39 | // clear the cache 40 | await redis.flushdb(); 41 | return next(); 42 | }; 43 | 44 | buql.security = (req, res, next) => { 45 | //declare an allow list (more secure than a block list) 46 | const allowedCharacters = /^[a-zA-Z0-9_{}(),":$\s]+$/; //this allows all letters, white spaces, numbers, curly braces, parantheses, underscores, colons, commas, and dollar signs 47 | //if any character in the query is not defined in the allow list, return an error 48 | if (!allowedCharacters.test(req.body.query)){ 49 | return next({ 50 | log:'Invalid character detected in the request body in securityController', 51 | status: 403, 52 | message: 'Invalid character, try again.' 53 | }) 54 | } 55 | //otherwise, move on to the next middleware 56 | return next(); 57 | } 58 | 59 | export default buql; 60 | -------------------------------------------------------------------------------- /demo/src/components/QueryTable.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useTable} from 'react-table'; 3 | 4 | function QueryTable(props) { 5 | const {data} = props; 6 | 7 | // defining the table columns 8 | const columns = React.useMemo( 9 | () => [ 10 | { 11 | Header: '#', 12 | accessor: 'id', 13 | }, 14 | { 15 | Header: 'Query', 16 | accessor: 'query', 17 | }, 18 | { 19 | Header: 'Response Source', 20 | accessor: 'source', 21 | }, 22 | { 23 | Header: 'Response Time', 24 | accessor: 'time', 25 | }, 26 | ], 27 | [] 28 | ); 29 | 30 | // reverse data to populate table backwards 31 | const reversedData = [...data].reverse(); 32 | 33 | // define table instance using useTable hook 34 | const {getTableProps, rows, prepareRow} = useTable({ 35 | columns, 36 | data: reversedData, 37 | }); 38 | 39 | // render the table 40 | return ( 41 | 42 | 43 | 44 | {/* create the table headers based on the columns defined previously */} 45 | {columns.map((column) => ( 46 | 47 | ))} 48 | 49 | 50 | 51 | {rows.map((row) => { 52 | prepareRow(row); 53 | // render each row 54 | return ( 55 | 56 | {row.cells.map((cell, cellIndex) => { 57 | // render each cell for the current row 58 | return ( 59 | 65 | ); 66 | })} 67 | 68 | ); 69 | })} 70 | 71 |
{column.Header}
63 | {cell.render('Cell')} 64 |
72 | ); 73 | } 74 | 75 | export default QueryTable; 76 | -------------------------------------------------------------------------------- /demo/server/schema/schema.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLSchema, 3 | GraphQLObjectType, 4 | GraphQLString, 5 | GraphQLInt, 6 | // GraphQLNonNull, 7 | GraphQLList, 8 | // GraphQLID, 9 | } from 'graphql'; 10 | 11 | import mongoose from 'mongoose'; 12 | 13 | // Import data model for users 14 | import { User } from '../models/models'; 15 | import { addToDb } from '../models/fake'; 16 | 17 | // Create user-defined data types for GraphQL 18 | const UserType = new GraphQLObjectType({ 19 | name: 'User', 20 | fields: () => ({ 21 | id: { type: GraphQLString }, 22 | username: { type: GraphQLString }, 23 | email: { type: GraphQLString }, 24 | birthdate: { type: GraphQLString }, 25 | registeredAt: { type: GraphQLString }, 26 | }), 27 | }); 28 | 29 | // Define what data can be queried 30 | const RootQuery = new GraphQLObjectType({ 31 | name: 'RootQueryType', 32 | fields: { 33 | getAllUsers: { 34 | type: new GraphQLList(UserType), 35 | args: { id: { type: GraphQLString } }, 36 | async resolve() { 37 | const userList = await User.find({}); 38 | return userList; 39 | }, 40 | }, 41 | getUserById: { 42 | type: UserType, 43 | args: { id: { type: GraphQLString } }, 44 | async resolve(parent, args) { 45 | const userId = new mongoose.Types.ObjectId(args.id); 46 | const user = await User.findById(userId); 47 | return user; 48 | }, 49 | }, 50 | }, 51 | }); 52 | 53 | // Define mutations 54 | const Mutation = new GraphQLObjectType({ 55 | name: 'Mutation', 56 | fields: { 57 | addRandomUsers: { 58 | type: new GraphQLList(UserType), 59 | args: { 60 | num: { type: GraphQLInt }, 61 | }, 62 | async resolve(parents, args) { 63 | await addToDb(args.num); 64 | const userList = await User.find({}); 65 | return userList; 66 | }, 67 | }, 68 | deleteById: { 69 | type: UserType, 70 | args: { 71 | id: { type: GraphQLString }, 72 | }, 73 | async resolve(parents, args) { 74 | const userId = new mongoose.Types.ObjectId(args.id); 75 | const result = await User.findByIdAndDelete(userId); 76 | return result; 77 | }, 78 | }, 79 | }, 80 | }); 81 | 82 | export const schema = new GraphQLSchema({ 83 | query: RootQuery, 84 | mutation: Mutation, 85 | }); 86 | -------------------------------------------------------------------------------- /demo/src/components/QueryForm.css: -------------------------------------------------------------------------------- 1 | #queryform { 2 | display: flex; 3 | align-items: center; 4 | flex-direction: column; 5 | padding: 75px; 6 | 7 | /* button { 8 | margin-top: 20px; 9 | } */ 10 | } 11 | 12 | #queryanalytics { 13 | display: flex; 14 | gap: 40px; 15 | } 16 | 17 | #queryselector, 18 | #querylabels label { 19 | width: 650px; 20 | } 21 | 22 | #querylabels, 23 | #queryboxes, 24 | #querybuttons { 25 | flex-direction: row; 26 | display: flex; 27 | padding: 10px; 28 | gap: 40px; 29 | } 30 | 31 | #querybuttons { 32 | gap: 130px; 33 | margin-bottom: 15px; 34 | } 35 | 36 | #queryselector select, 37 | #querylabels label, 38 | #barchart label { 39 | font-size: 18px; 40 | margin-bottom: 5px; 41 | } 42 | 43 | #querylabels pre, 44 | #queryboxes pre { 45 | width: 650px; 46 | height: 330px; 47 | background-color: rgb(25, 25, 25); 48 | border-radius: 3px; 49 | border: 2px solid pink; 50 | font-size: 14px; 51 | text-align: left; 52 | padding: 5px; 53 | overflow: auto; 54 | box-shadow: 0px 0px 5px 1px pink; 55 | } 56 | 57 | #querytable { 58 | border-collapse: collapse; 59 | width: 660px; 60 | height: 410px; 61 | table { 62 | width: 100%; 63 | margin-right: 0; 64 | margin-left: auto; 65 | } 66 | th, 67 | td { 68 | border: 1px solid transparent; 69 | padding: 8px; 70 | } 71 | th { 72 | text-align: center; 73 | background-color: rgb(20, 20, 20); 74 | padding: 8px; 75 | } 76 | tr:nth-child(even) { 77 | background-color: rgb(25, 25, 25); 78 | } 79 | 80 | tr:hover { 81 | background-color: rgb(55, 55, 55); 82 | } 83 | } 84 | 85 | #querytable, 86 | #barchart { 87 | width: 660px; 88 | overflow: auto; 89 | border: 2px solid pink; 90 | border-radius: 2px; 91 | box-shadow: 0px 0px 5px 1px pink; 92 | } 93 | 94 | #barchart { 95 | padding-top: 10px; 96 | color: white; 97 | height: 400px; 98 | 99 | canvas { 100 | margin-top: 20px; 101 | padding: 10px; 102 | height: 350px !important; 103 | } 104 | } 105 | 106 | /* Vertical Layout on a narrow screen */ 107 | @media screen and (max-width: 1000px) { 108 | #querylabels, 109 | #queryboxes, 110 | #queryanalytics { 111 | flex-direction: column; 112 | } 113 | #querybuttons { 114 | gap: 30px; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /demo/src/components/Queries.js: -------------------------------------------------------------------------------- 1 | // define queries that will be available to choose from in the demo. might put in a different file? 2 | const queries = [ 3 | /* Working set of queries before schema change 4 | { 5 | label: 'Query: Grab all users with the userId 1', 6 | query: 'query { user(userId: 1) { name } }', 7 | }, 8 | { 9 | label: 'Query: Hello World Query!', 10 | query: 'query { hello }', 11 | }, 12 | { 13 | label: 'Query: Grab all users and hello world', 14 | query: 'query { getAllUsers { id username password } hello }', 15 | }, 16 | { 17 | label: 'Query: Grab just the username of all users', 18 | query: 'query { getAllUsers { username } }', 19 | }, 20 | { 21 | label: 'Mutation: Create User with name and password', 22 | query: 23 | 'mutation { createUser (username: "Filip", password: "aGx75C6hz2!_") { id username password } }', 24 | }, */ 25 | { 26 | label: 'Grab all users', 27 | query: 'query { getAllUsers { id username email birthdate registeredAt } }', 28 | }, 29 | { 30 | label: 'Grab a specific user by its userId', 31 | query: 32 | 'query { getUserById(id: "65dfa2ed403abded4f2565f0") { id username email birthdate registeredAt } }', 33 | }, 34 | { 35 | label: 'Delete a specific user by its userId', 36 | query: 37 | 'mutation { deleteById(id: "65dfa2ed403abded4f2565f0") { id username email birthdate registeredAt } }', 38 | }, 39 | { 40 | label: 'Add a random new user', 41 | query: 42 | 'mutation { addRandomUsers(num: 1) { id username email birthdate registeredAt } }', 43 | }, 44 | { 45 | label: 'Bad Query', 46 | query: 'query { getAccount { id username email } }', 47 | }, 48 | { 49 | label: 'Query with illegal character', 50 | query: 51 | 'query { getUserById(id: "65dfa2ed403abded4f2565f0" OR 1=1) { id username email } }', 52 | }, 53 | { 54 | label: 'Test-Full', 55 | query: 56 | 'query { getUserById (id: "65ce7765820192dc7a289543") { id username email birthdate registeredAt } }', 57 | }, 58 | { 59 | label: 'Test-Part-1', 60 | query: 61 | 'query { getUserById (id: "65ce7765820192dc7a289543") { id username } }', 62 | }, 63 | { 64 | label: 'Test-Part-2', 65 | query: 66 | 'query { getUserById (id: "65ce7765820192dc7a289543") { email birthdate registeredAt } }', 67 | }, 68 | ]; 69 | 70 | export default queries; 71 | -------------------------------------------------------------------------------- /demo/src/App.jsx: -------------------------------------------------------------------------------- 1 | import {useState} from 'react'; 2 | import './App.css'; 3 | import QueryForm from './components/QueryForm'; 4 | import Home from './components/Home.jsx'; 5 | import AboutUs from './components/AboutUs.jsx'; 6 | 7 | function App() { 8 | // State to track the current page 9 | const [currentPage, setCurrentPage] = useState('home'); 10 | 11 | // Render component based on current page 12 | const renderPage = () => { 13 | switch (currentPage) { 14 | case 'demo': 15 | return ; 16 | case 'about': 17 | return ; 18 | default: 19 | case 'home': 20 | return ; 21 | } 22 | }; 23 | 24 | return ( 25 | <> 26 | 49 | 50 |
51 | {renderPage()} 52 |
53 | 56 | 57 | ); 58 | } 59 | 60 | 61 | export default App; 62 | -------------------------------------------------------------------------------- /demo/src/components/AboutUs.jsx: -------------------------------------------------------------------------------- 1 | import './AboutUs.css' 2 | 3 | function AboutUs(){ 4 | return( 5 |
6 |
7 | 8 |

Dylan Compton

9 | 17 |
18 |
19 |

Jake Diamond

20 | 28 |
29 |
30 |

Julien Kerekes

31 | 39 |
40 |
41 |

Joseph McGarry

42 | 50 |
51 |
52 |
53 | ); 54 | } 55 | 56 | 57 | 58 | 59 | 60 | export default AboutUs; -------------------------------------------------------------------------------- /demo/server/controllers/security.js: -------------------------------------------------------------------------------- 1 | // import {createComplexityLimitRule} from 'graphql-validation-complexity'; 2 | import depthLimit from 'graphql-depth-limit'; 3 | 4 | const security = {}; 5 | 6 | //example query: "query { getAllUsers {id username password } hello }" 7 | 8 | //define a checkChars method to ensure the characters used dont resemble injection attacks (i.e. 1=1, , etc.) 9 | security.checkChars = (req, res, next) => { 10 | //declare an allow list (more secure than a block list) 11 | const allowedCharacters = /^[a-zA-Z0-9_{}(),":$\s]+$/; //this allows all letters, white spaces, numbers, curly braces, parantheses, underscores, colons, commas, and dollar signs 12 | //if any character in the query is not defined in the allow list, return an error 13 | if (!allowedCharacters.test(req.body.query)){ 14 | return next({ 15 | log:'Invalid character detected in the request body in securityController', 16 | status: 403, 17 | message: 'Invalid character, try again.' 18 | }) 19 | } 20 | //otherwise, move on to the next middleware 21 | return next(); 22 | } 23 | 24 | /* an example timeout function 25 | 26 | request.incrementResolverCount = function () { 27 | var runTime = Date.now() - startTime; 28 | if (runTime > 10000) { // a timeout of 10 seconds 29 | if (request.logTimeoutError) { 30 | logger('ERROR', `Request ${request.uuid} query execution timeout`); 31 | } 32 | request.logTimeoutError = false; 33 | throw 'Query execution has timeout. Field resolution aborted'; 34 | } 35 | this.resolverCount++; 36 | }; 37 | 38 | */ 39 | 40 | /* this will be an optional method on the BuQL object as a whole; 41 | user will pull it from the created BuQL object (const security = BuQL.security) 42 | then, when passing it in as middleware, invoke it with custom options if they need ('/endpoint', security(custom options), (req, res) => ... )*/ 43 | security.RulesCreator = (givenLimit = 10, costLimit = 1000, customRules) => { 44 | //define the default list of rules; source: https://github.com/4Catalyzer/graphql-validation-complexity 45 | const defaultRules = { 46 | scalarCost: 1, 47 | objectCost: 0, 48 | listFactor: 10, 49 | introspectionListFactor: 2, 50 | }; 51 | //check if user has passed in custom options 52 | if (customRules) { 53 | //verify that custom options is an object (but not an array) 54 | if (typeof customRules !== 'object' || Array.isArray(customRules)) { 55 | throw new Error('customRules should be an object'); 56 | } 57 | //combine defaultRules with the custom rules 58 | Object.assign(defaultRules, customRules); 59 | } 60 | //return the costlimit and default rules 61 | return [depthLimit(givenLimit), costLimit, defaultRules]; 62 | } 63 | /* there's two ways this can go: 64 | 1. set this up as a ternary, checking if the user has called it or something (what i have right now) 65 | 2. make sure the user pulls and invokes it, whether or not they use the options */ 66 | 67 | export default security; -------------------------------------------------------------------------------- /demo/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Nunito', sans-serif; 3 | font-optical-sizing: auto; 4 | font-style: normal; 5 | } 6 | 7 | #root { 8 | max-width: 1280px; 9 | min-height: 100%; 10 | /* margin was 100px */ 11 | margin: 0px auto 0px; 12 | padding: 2rem; 13 | text-align: center; 14 | } 15 | 16 | .logo { 17 | margin-top: auto; 18 | margin-bottom: auto; 19 | height: 60%; 20 | will-change: filter; 21 | transition: filter 300ms; 22 | line-height: 80px; 23 | } 24 | .logo:hover { 25 | filter: drop-shadow(0 0 2em white); 26 | } 27 | .logo.react:hover { 28 | filter: drop-shadow(0 0 2em pink); 29 | } 30 | 31 | @keyframes logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | 40 | @media (prefers-reduced-motion: no-preference) { 41 | a:nth-of-type(2) .logo { 42 | animation: logo-spin infinite 20s linear; 43 | } 44 | } 45 | 46 | .card { 47 | padding: 2em; 48 | } 49 | 50 | .read-the-docs { 51 | color: #888; 52 | } 53 | 54 | #header { 55 | top: 0px; 56 | position: fixed; 57 | text-align: center; 58 | border-bottom: 10px solid #f077bc; 59 | /* was 'pink' */ 60 | gap: 20px; 61 | h1 { 62 | font-size: 52px; 63 | } 64 | } 65 | 66 | #header, 67 | #footer { 68 | display: flex; 69 | margin: 0; 70 | left: 0; 71 | background-color: rgb(26, 26, 26); 72 | /* background-color: #faefdf; */ 73 | width: 100%; 74 | height: 80px; 75 | justify-content: center; 76 | align-content: center; 77 | box-shadow: 0px 0px 20px 1px #f077bc; 78 | } 79 | 80 | #socials { 81 | position: fixed; 82 | left: 0; 83 | top: 0; 84 | /* top right bottom left */ 85 | margin: 25px 0 auto 15px; 86 | display: flex; 87 | flex-direction: row; /* set logos side-by-side */ 88 | gap: 15px; 89 | line-height: 80px; 90 | } 91 | 92 | .sociallogo:hover { 93 | /* border-color: #646cff; */ 94 | /* color: #faefdf; */ 95 | /* box-shadow: 0px 0px 7px 3px #f077bc; */ 96 | filter: drop-shadow(0px 0px 5px white); 97 | } 98 | 99 | .sociallogo { 100 | height: 32px; 101 | width: 32px; 102 | /* filter to make svg logos white */ 103 | filter: invert(100%) sepia(13%) saturate(7500%) hue-rotate(200deg) 104 | brightness(112%) contrast(110%); 105 | } 106 | 107 | #pages { 108 | position: fixed; 109 | right: 0; 110 | top: 0; 111 | margin-top: 30px; 112 | margin-right: 15px; 113 | display: flex; 114 | flex-direction: row; /* set buttons side-by-side */ 115 | align-items: flex-end; /* align buttons to the right */ 116 | gap: 10px; 117 | } 118 | .buql:hover { 119 | transform: scale(1.1); 120 | /* filter: drop-shadow(0px 0px 20px white) */ 121 | } 122 | 123 | #header h1, 124 | #footer h2 { 125 | margin: 0; 126 | line-height: 80px; 127 | /* color: blanchedalmond; */ 128 | color: #faefdf; 129 | font-family: 'Anta', sand-serif; 130 | } 131 | 132 | #footer { 133 | position: fixed; 134 | border-top: 5px solid #f077bc; 135 | bottom: 0; 136 | /* was 'pink' */ 137 | margin-top: auto; 138 | margin-bottom: 0; 139 | } 140 | -------------------------------------------------------------------------------- /demo/src/components/BarChart.jsx: -------------------------------------------------------------------------------- 1 | // We installed 'chart.js', 'react-chartjs-2' for creating our chart 2 | // It is important to note that without 'chart.js/auto' that chart will * NOT * render 3 | 4 | // import React from 'react'; 5 | 6 | import {Bar} from 'react-chartjs-2'; 7 | import {Chart as ChartJS} from 'chart.js/auto'; 8 | 9 | // defining the chart legend 10 | const legendItems = [ 11 | { 12 | text: 'Cache', 13 | fillStyle: '#faefdf', //green 14 | fontColor: 'white', 15 | }, 16 | { 17 | text: 'Partial', 18 | fillStyle: 'pink', //yellow 19 | fontColor: 'white', 20 | }, 21 | { 22 | text: 'Database', 23 | fillStyle: '#f077bc', //red 24 | fontColor: 'white', 25 | }, 26 | { 27 | text: 'Mutation', 28 | fillStyle: 'purple', 29 | fontColor: 'white', 30 | }, 31 | { 32 | text: 'Error', 33 | fillStyle: 'black', 34 | fontColor: 'white', 35 | }, 36 | ]; 37 | 38 | // This creates a bar chart 39 | function BarChart({rawData}) { 40 | // process raw data to chartData 41 | const chartData = { 42 | // labels: the query id that is also shown in the table 43 | labels: rawData.responseCount, 44 | // datasets: array of bars 45 | datasets: [ 46 | { 47 | // data: the height of each bar (response time of each query invokation) 48 | data: rawData.responseTimes, 49 | // backgroundColor: the color of each bar (based on the source the reply came from) 50 | backgroundColor: rawData.responseSources.map((source) => { 51 | switch (source) { 52 | case 'database': 53 | return '#f077bc'; 54 | case 'cache': 55 | return '#faefdf'; // bun color 56 | case 'mutation': 57 | return 'purple'; 58 | default: 59 | if (source.includes('%')) { 60 | return 'pink'; 61 | } 62 | return 'black'; 63 | } 64 | }), 65 | }, 66 | ], 67 | }; 68 | 69 | // styling for the bar chart legend 70 | const options = { 71 | plugins: { 72 | legend: { 73 | labels: { 74 | font: { 75 | size: 16, 76 | }, 77 | // creates the labels we defined for the legend previously 78 | generateLabels: function () { 79 | return legendItems; 80 | }, 81 | }, 82 | }, 83 | }, 84 | scales: { 85 | // x-axis styles 86 | x: { 87 | grid: { 88 | color: 'hotpink', 89 | }, 90 | ticks: { 91 | color: 'white', // Font color of the x-axis labels 92 | }, 93 | }, 94 | // y-axis styles 95 | y: { 96 | grid: { 97 | color: 'hotpink', 98 | }, 99 | ticks: { 100 | color: 'white', // Font color of the y-axis labels 101 | }, 102 | }, 103 | }, 104 | }; 105 | // Returns the BarChart with its data and styling options 106 | return ; 107 | } 108 | 109 | //exports the BarChart 110 | export default BarChart; 111 | -------------------------------------------------------------------------------- /demo/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/server/controllers/helpers.js: -------------------------------------------------------------------------------- 1 | // Import adjustments 2 | import { parse } from 'graphql/language/parser'; 3 | import redis from './redis'; 4 | 5 | // Asynchronous function to process a GraphQL request 6 | export const processRequest = async (input) => { 7 | const parsedData = parse(input); 8 | let cacheHits = 0, 9 | missedCache = 0; 10 | 11 | // Extract definitions from the parsed GraphQL query for processing 12 | const dataDefinitions = parsedData.definitions[0].selectionSet.selections; 13 | const data = {}; 14 | dataDefinitions.forEach((def) => { 15 | const elements = def.selectionSet.selections; 16 | elements.forEach((element) => { 17 | const identifier = JSON.stringify({ 18 | definitionType: def.name.value, 19 | parameters: def.arguments, 20 | element: element.name.value, 21 | }); 22 | data[identifier] = null; 23 | }); 24 | }); 25 | 26 | // Interact with the cache to retrive stored data or mark as missed 27 | for (const id in data) { 28 | const cacheResult = await redis.retrieve(id); 29 | if (cacheResult !== null) { 30 | cacheHits++; 31 | data[id] = cacheResult; 32 | } else { 33 | missedCache++; 34 | } 35 | } 36 | 37 | // Prepare response based on cache hits 38 | // If complete data is cached, construct the response directly 39 | let completeCache = !Object.values(data).includes(null); 40 | if (completeCache) { 41 | let response = { cacheHits, missedCache, response: {} }; 42 | const dataType = JSON.parse(Object.keys(data)[0]).definitionType; 43 | response.response[dataType] = {}; 44 | Object.keys(data).forEach((id) => { 45 | const keyDetails = JSON.parse(id); 46 | response.response[dataType][keyDetails.element] = data[id]; 47 | }); 48 | return response; 49 | } else { 50 | // Handling cases where cache misses occur by fetching missing data 51 | let fetchFields = []; 52 | Object.keys(data).forEach((id) => { 53 | if (data[id] === null) fetchFields.push(JSON.parse(id)); 54 | }); 55 | 56 | // Construct a new GraphQL query for missing data 57 | let dataType = '', 58 | params = '', 59 | elements = ''; 60 | if (fetchFields.length) { 61 | dataType += fetchFields[0].definitionType; 62 | if (fetchFields[0].parameters.length) { 63 | params += 64 | '(' + 65 | fetchFields[0].parameters 66 | .map((p) => `${p.name.value}: "${p.value.value}"`) 67 | .join(', ') + 68 | ')'; 69 | } 70 | elements = fetchFields.map((f) => f.element).join(', '); 71 | } 72 | const newQuery = `query { ${dataType} ${params} { ${elements} } }`; 73 | 74 | // Fetching missing data from the server and update the cache 75 | const serverResponse = await fetch( 76 | `http://localhost:8080/graphql`, 77 | { 78 | method: 'POST', 79 | headers: { 'Content-Type': 'application/json' }, 80 | body: JSON.stringify({ query: newQuery }), 81 | } 82 | ); 83 | let fetchedData = await serverResponse.json(); 84 | 85 | // Handle errors in fetched data 86 | if (fetchedData.errors) { 87 | return { 88 | data: { 89 | message: fetchedData.errors[0].message, 90 | errors: fetchedData.errors[0], 91 | }, 92 | }; 93 | } 94 | 95 | // Update cache with newly fetched data and prepare final response 96 | fetchedData = fetchedData.data; 97 | Object.keys(data).forEach((id) => { 98 | const detail = JSON.parse(id); 99 | if (fetchedData[detail.element] !== undefined) { 100 | data[id] = fetchedData[detail.element]; 101 | redis.store(id, fetchedData[detail.element]); 102 | } 103 | }); 104 | 105 | // Construct final response with updated cache data 106 | let finalResponse = { cacheHits, missedCache, response: {} }; 107 | const updatedType = JSON.parse(Object.keys(data)[0]).definitionType; 108 | finalResponse.response[updatedType] = {}; 109 | Object.keys(data).forEach((id) => { 110 | const detail = JSON.parse(id); 111 | finalResponse.response[updatedType][detail.element] = data[id]; 112 | }); 113 | return finalResponse; 114 | } 115 | }; 116 | 117 | export const handleQuery = async (query) => { 118 | const parsedQuery = parse(query); 119 | let cacheHits = 0; 120 | let nonCache = 0; 121 | 122 | const queryTypes = parsedQuery.definitions[0].selectionSet.selections; 123 | const queryObj = {}; 124 | queryTypes.forEach((type) => { 125 | const fields = type.selectionSet.selections; 126 | fields.forEach((field) => { 127 | const key = JSON.stringify({ 128 | type: type.name.value, 129 | args: type.arguments, 130 | field: field.name.value, 131 | }); 132 | queryObj[key] = null; 133 | }); 134 | }); 135 | 136 | for (const keyString in queryObj) { 137 | const result = await redis.get(keyString); 138 | if (result !== null) { 139 | cacheHits++; 140 | queryObj[keyString] = result; 141 | } else { 142 | nonCache++; 143 | } 144 | } 145 | 146 | let allFieldsCached = !Object.values(queryObj).includes(null); 147 | 148 | if (allFieldsCached) { 149 | const gqlResponse = { cacheHits, nonCache, response: {} }; 150 | const type = JSON.parse(Object.keys(queryObj)[0]).type; 151 | gqlResponse.response[type] = {}; 152 | for (let keyString in queryObj) { 153 | const key = JSON.parse(keyString); 154 | gqlResponse.response[type][key.field] = queryObj[keyString]; 155 | } 156 | return gqlResponse; 157 | } else { 158 | const fieldsToFetch = []; 159 | for (const keyString in queryObj) { 160 | if (queryObj[keyString] === null) { 161 | fieldsToFetch.push(JSON.parse(keyString)); 162 | } 163 | } 164 | 165 | let type = '', 166 | args = '', 167 | fields = ''; 168 | if (fieldsToFetch.length > 0) { 169 | type += fieldsToFetch[0].type; 170 | if (fieldsToFetch[0].args.length !== 0) { 171 | args += 172 | '(' + 173 | fieldsToFetch[0].args 174 | .map((el) => `${el.name.value}: "${el.value.value}"`) 175 | .join(', ') + 176 | ')'; 177 | } 178 | fields = fieldsToFetch.map((f) => f.field).join(', '); 179 | } 180 | const fullQuery = `query { ${type} ${args} { ${fields} } }`; 181 | 182 | const gqlResponse = await fetch( 183 | `http://localhost:8080/graphql`, 184 | { 185 | method: 'POST', 186 | headers: { 'Content-Type': 'application/json' }, 187 | body: JSON.stringify({ query: fullQuery }), 188 | } 189 | ); 190 | let parsedResponse = await gqlResponse.json(); 191 | 192 | if (parsedResponse.errors) { 193 | return { 194 | response: { 195 | message: parsedResponse.errors[0].message, 196 | errors: parsedResponse.errors[0], 197 | }, 198 | }; 199 | } 200 | 201 | parsedResponse = parsedResponse.data; 202 | 203 | for (const [key, value] of Object.entries(parsedResponse)) { 204 | if (value === null || value === undefined) { 205 | return { 206 | response: { 207 | message: `Invalid query for ${key}!`, 208 | errors: parsedResponse, 209 | }, 210 | }; 211 | } 212 | } 213 | 214 | const storage = {}; 215 | Object.keys(queryObj).forEach((keyStr) => { 216 | const key = JSON.parse(keyStr); 217 | storage[key.field] = keyStr; // Store stringified key for later reference 218 | }); 219 | 220 | for (const [fields] of Object.entries(parsedResponse)) { 221 | for (const [field, fieldVal] of Object.entries(fields)) { 222 | const newKey = storage[field]; 223 | queryObj[newKey] = fieldVal; 224 | redis.set(newKey, fieldVal); 225 | } 226 | } 227 | 228 | const queryRes = { cacheHits, nonCache, response: {} }; 229 | const newType = JSON.parse(Object.keys(queryObj)[0]).type; 230 | queryRes.response[newType] = {}; 231 | for (let keyStr in queryObj) { 232 | const key = JSON.parse(keyStr); 233 | queryRes.response[newType][key.field] = queryObj[keyStr]; 234 | } 235 | return queryRes; 236 | } 237 | }; 238 | -------------------------------------------------------------------------------- /npm_package/helpers.js: -------------------------------------------------------------------------------- 1 | // Import adjustments 2 | import { parse } from 'graphql/language/parser'; 3 | import redis from './redis'; 4 | 5 | // Asynchronous function to process a GraphQL request 6 | export const processRequest = async (input) => { 7 | const parsedData = parse(input); 8 | let cacheHits = 0, 9 | missedCache = 0; 10 | 11 | // Extract definitions from the parsed GraphQL query for processing 12 | const dataDefinitions = parsedData.definitions[0].selectionSet.selections; 13 | const data = {}; 14 | dataDefinitions.forEach((def) => { 15 | const elements = def.selectionSet.selections; 16 | elements.forEach((element) => { 17 | const identifier = JSON.stringify({ 18 | definitionType: def.name.value, 19 | parameters: def.arguments, 20 | element: element.name.value, 21 | }); 22 | data[identifier] = null; 23 | }); 24 | }); 25 | 26 | // Interact with the cache to retrive stored data or mark as missed 27 | for (const id in data) { 28 | const cacheResult = await redis.retrieve(id); 29 | if (cacheResult !== null) { 30 | cacheHits++; 31 | data[id] = cacheResult; 32 | } else { 33 | missedCache++; 34 | } 35 | } 36 | 37 | // Prepare response based on cache hits 38 | // If complete data is cached, construct the response directly 39 | let completeCache = !Object.values(data).includes(null); 40 | if (completeCache) { 41 | let response = { cacheHits, missedCache, response: {} }; 42 | const dataType = JSON.parse(Object.keys(data)[0]).definitionType; 43 | response.response[dataType] = {}; 44 | Object.keys(data).forEach((id) => { 45 | const keyDetails = JSON.parse(id); 46 | response.response[dataType][keyDetails.element] = data[id]; 47 | }); 48 | return response; 49 | } else { 50 | // Handling cases where cache misses occur by fetching missing data 51 | let fetchFields = []; 52 | Object.keys(data).forEach((id) => { 53 | if (data[id] === null) fetchFields.push(JSON.parse(id)); 54 | }); 55 | 56 | // Construct a new GraphQL query for missing data 57 | let dataType = '', 58 | params = '', 59 | elements = ''; 60 | if (fetchFields.length) { 61 | dataType += fetchFields[0].definitionType; 62 | if (fetchFields[0].parameters.length) { 63 | params += 64 | '(' + 65 | fetchFields[0].parameters 66 | .map((p) => `${p.name.value}: "${p.value.value}"`) 67 | .join(', ') + 68 | ')'; 69 | } 70 | elements = fetchFields.map((f) => f.element).join(', '); 71 | } 72 | const newQuery = `query { ${dataType} ${params} { ${elements} } }`; 73 | 74 | // Fetching missing data from the server and update the cache 75 | const serverResponse = await fetch( 76 | `http://localhost:${Bun.env.PORT}/graphql`, 77 | { 78 | method: 'POST', 79 | headers: { 'Content-Type': 'application/json' }, 80 | body: JSON.stringify({ query: newQuery }), 81 | } 82 | ); 83 | let fetchedData = await serverResponse.json(); 84 | 85 | // Handle errors in fetched data 86 | if (fetchedData.errors) { 87 | return { 88 | data: { 89 | message: fetchedData.errors[0].message, 90 | errors: fetchedData.errors[0], 91 | }, 92 | }; 93 | } 94 | 95 | // Update cache with newly fetched data and prepare final response 96 | fetchedData = fetchedData.data; 97 | Object.keys(data).forEach((id) => { 98 | const detail = JSON.parse(id); 99 | if (fetchedData[detail.element] !== undefined) { 100 | data[id] = fetchedData[detail.element]; 101 | redis.store(id, fetchedData[detail.element]); 102 | } 103 | }); 104 | 105 | // Construct final response with updated cache data 106 | let finalResponse = { cacheHits, missedCache, response: {} }; 107 | const updatedType = JSON.parse(Object.keys(data)[0]).definitionType; 108 | finalResponse.response[updatedType] = {}; 109 | Object.keys(data).forEach((id) => { 110 | const detail = JSON.parse(id); 111 | finalResponse.response[updatedType][detail.element] = data[id]; 112 | }); 113 | return finalResponse; 114 | } 115 | }; 116 | 117 | export const handleQuery = async (query) => { 118 | const parsedQuery = parse(query); 119 | let cacheHits = 0; 120 | let nonCache = 0; 121 | 122 | const queryTypes = parsedQuery.definitions[0].selectionSet.selections; 123 | const queryObj = {}; 124 | queryTypes.forEach((type) => { 125 | const fields = type.selectionSet.selections; 126 | fields.forEach((field) => { 127 | const key = JSON.stringify({ 128 | type: type.name.value, 129 | args: type.arguments, 130 | field: field.name.value, 131 | }); 132 | queryObj[key] = null; 133 | }); 134 | }); 135 | 136 | for (const keyString in queryObj) { 137 | const result = await redis.get(keyString); 138 | if (result !== null) { 139 | cacheHits++; 140 | queryObj[keyString] = result; 141 | } else { 142 | nonCache++; 143 | } 144 | } 145 | 146 | let allFieldsCached = !Object.values(queryObj).includes(null); 147 | 148 | if (allFieldsCached) { 149 | const gqlResponse = { cacheHits, nonCache, response: {} }; 150 | const type = JSON.parse(Object.keys(queryObj)[0]).type; 151 | gqlResponse.response[type] = {}; 152 | for (let keyString in queryObj) { 153 | const key = JSON.parse(keyString); 154 | gqlResponse.response[type][key.field] = queryObj[keyString]; 155 | } 156 | return gqlResponse; 157 | } else { 158 | const fieldsToFetch = []; 159 | for (const keyString in queryObj) { 160 | if (queryObj[keyString] === null) { 161 | fieldsToFetch.push(JSON.parse(keyString)); 162 | } 163 | } 164 | 165 | let type = '', 166 | args = '', 167 | fields = ''; 168 | if (fieldsToFetch.length > 0) { 169 | type += fieldsToFetch[0].type; 170 | if (fieldsToFetch[0].args.length !== 0) { 171 | args += 172 | '(' + 173 | fieldsToFetch[0].args 174 | .map((el) => `${el.name.value}: "${el.value.value}"`) 175 | .join(', ') + 176 | ')'; 177 | } 178 | fields = fieldsToFetch.map((f) => f.field).join(', '); 179 | } 180 | const fullQuery = `query { ${type} ${args} { ${fields} } }`; 181 | 182 | const gqlResponse = await fetch( 183 | `http://localhost:${Bun.env.PORT}/graphql`, 184 | { 185 | method: 'POST', 186 | headers: { 'Content-Type': 'application/json' }, 187 | body: JSON.stringify({ query: fullQuery }), 188 | } 189 | ); 190 | let parsedResponse = await gqlResponse.json(); 191 | 192 | if (parsedResponse.errors) { 193 | return { 194 | response: { 195 | message: parsedResponse.errors[0].message, 196 | errors: parsedResponse.errors[0], 197 | }, 198 | }; 199 | } 200 | 201 | parsedResponse = parsedResponse.data; 202 | 203 | for (const [key, value] of Object.entries(parsedResponse)) { 204 | if (value === null || value === undefined) { 205 | return { 206 | response: { 207 | message: `Invalid query for ${key}!`, 208 | errors: parsedResponse, 209 | }, 210 | }; 211 | } 212 | } 213 | 214 | const storage = {}; 215 | Object.keys(queryObj).forEach((keyStr) => { 216 | const key = JSON.parse(keyStr); 217 | storage[key.field] = keyStr; // Store stringified key for later reference 218 | }); 219 | 220 | for (const [name, fields] of Object.entries(parsedResponse)) { 221 | for (const [field, fieldVal] of Object.entries(fields)) { 222 | const newKey = storage[field]; 223 | queryObj[newKey] = fieldVal; 224 | redis.set(newKey, fieldVal); 225 | } 226 | } 227 | 228 | const queryRes = { cacheHits, nonCache, response: {} }; 229 | const newType = JSON.parse(Object.keys(queryObj)[0]).type; 230 | queryRes.response[newType] = {}; 231 | for (let keyStr in queryObj) { 232 | const key = JSON.parse(keyStr); 233 | queryRes.response[newType][key.field] = queryObj[keyStr]; 234 | } 235 | return queryRes; 236 | } 237 | }; 238 | -------------------------------------------------------------------------------- /demo/src/components/QueryForm.jsx: -------------------------------------------------------------------------------- 1 | import {useState} from 'react'; 2 | import './QueryForm.css'; 3 | import queries from './Queries.js'; 4 | import ReactJson from 'react-json-pretty'; // Import ReactJson 5 | import BarChart from './BarChart'; 6 | import QueryTable from './QueryTable.jsx'; 7 | 8 | function QueryForm() { 9 | // defining state variables (might refactor to redux later on) 10 | 11 | // keeping track of the query that's currently selected for the demo 12 | const [selectedQuery, setSelectedQuery] = useState({}); 13 | // contains the query response 14 | const [queryResponse, setQueryResponse] = useState(); 15 | 16 | // bar chart information --- times: bar sizes, count: bar names, sources = db/cache/etc 17 | const [chartData, setChartData] = useState({ 18 | responseTimes: [], 19 | responseCount: [], 20 | responseSources: [], 21 | }); 22 | 23 | // keep track of the table data (array of table rows) 24 | const [tableData, setTableData] = useState([]); 25 | 26 | // logic that happens when a new query gets selected 27 | const handleQuerySelector = (event) => { 28 | // clearing the query response field 29 | setQueryResponse(); 30 | 31 | // this accesses the selected query name and corresponding code 32 | const selectedIndex = event.target.selectedIndex; 33 | const selectedOption = event.target[selectedIndex]; 34 | const selectedLabel = selectedOption.textContent; 35 | 36 | // declaring an object that will hold onto the query label and its code 37 | const query = {}; 38 | query.label = selectedLabel; 39 | query.query = event.target.value; 40 | 41 | // assign that query object to its state variable 42 | setSelectedQuery(query); 43 | }; 44 | 45 | // functionality for clicking "Send Query" 46 | const sendQueryClick = async () => { 47 | // try-block runs the selected query and calculates the response time 48 | try { 49 | // grab timestamp of when the function was invoked 50 | const timeStart = Date.now(); 51 | 52 | // run the selected query through our backend logic 53 | const buqlResponse = await fetch('http://localhost:8080/buql', { 54 | method: 'POST', 55 | headers: { 56 | 'Content-Type': 'application/json', 57 | }, 58 | body: JSON.stringify({query: selectedQuery.query}), 59 | }); 60 | const responseObj = await buqlResponse.json(); 61 | // grab timestamp of when the function finished 62 | const timeEnd = Date.now(); 63 | // then calculate the time the function ran for in ms 64 | const runTime = timeEnd - timeStart; 65 | 66 | // deconstruct the response object 67 | let {source, cacheHits, nonCache, response} = responseObj; 68 | 69 | // check if response object is an error object and extract its errors if so 70 | // console.log(responseObj); 71 | // console.log(response); 72 | if (Object.hasOwn(response, 'errors')) { 73 | setQueryResponse(response.errors); 74 | // make sure it populates the graph/table as error data 75 | source = 'error'; 76 | } // otherwise extract its response data, assign it to state and determine the source 77 | else { 78 | setQueryResponse(response); 79 | // figure out the source of the data 80 | if (!source) { 81 | if (cacheHits === 0) { 82 | source = 'database'; 83 | } else if (nonCache === 0) { 84 | source = 'cache'; 85 | } else { 86 | source = `${ 87 | (cacheHits / (nonCache + cacheHits)) * 100 88 | }% from cache`; 89 | } 90 | } 91 | // otherwise source is 'mutation' 92 | } 93 | 94 | // generate next id for graph & table 95 | let nextId = 1; 96 | if (tableData.length !== 0) { 97 | nextId = tableData[tableData.length - 1].id + 1; 98 | } 99 | 100 | // update the chart data 101 | setChartData((prevState) => ({ 102 | // bar chart takes in arrays for data so we're utilizing 3 separate arrays 103 | responseTimes: [...prevState.responseTimes, runTime], 104 | responseCount: [...prevState.responseCount, nextId], 105 | responseSources: [...prevState.responseSources, source], 106 | })); 107 | 108 | // add a new row of data to the table 109 | setTableData((prevTableData) => [ 110 | ...prevTableData, 111 | { 112 | id: nextId, 113 | query: selectedQuery.label, 114 | source: source, 115 | time: runTime, 116 | }, 117 | ]); 118 | } catch (error) { 119 | console.error('Error:', error); 120 | setQueryResponse('Query rejected due to security concerns.'); 121 | } 122 | }; 123 | 124 | // functionality for clearing the cache 125 | const clearCacheClick = async () => { 126 | // send a request to the /clearCache route that will handle clearing the cache 127 | try { 128 | await fetch('http://localhost:8080/clearCache', { 129 | method: 'POST', 130 | headers: { 131 | 'Content-Type': 'application/json', 132 | }, 133 | }); 134 | 135 | setQueryResponse('Cache has been cleared!'); 136 | const showClearCache = () => { 137 | setQueryResponse(''); 138 | }; 139 | setTimeout(showClearCache, 2000); 140 | } catch (error) { 141 | console.error('Error:', error); 142 | } 143 | }; 144 | 145 | //functionality for clearing the Response Time Chart 146 | const clearChartClick = async () => { 147 | try { 148 | // set chart state to empty arrays 149 | setChartData({ 150 | responseTimes: [], 151 | responseCount: [], 152 | responseSources: [], 153 | }); 154 | console.log('Chart has been cleared.'); 155 | } catch (error) { 156 | console.error('Error:', error); 157 | } 158 | }; 159 | 160 | //functionality for clearing the Query Table 161 | const clearTableClick = async () => { 162 | try { 163 | // set table state to an empty array (0 rows) 164 | setTableData([]); 165 | console.log('Table has been cleared.'); 166 | } catch (error) { 167 | console.error('Error clearing table:', error); 168 | } 169 | }; 170 | 171 | // render this form back in App.jsx where it was called 172 | // form includes a select box + a code block for the query 173 | // buttons: send query, clear cache, clear table and clear graph 174 | // and a table and a graph to showcase the response times of queries 175 | return ( 176 |
177 |
178 |
179 | {/* select-box that shows "Select a query" by default and has demo queries to choose from */} 180 | 191 |
192 | 193 |
194 | 195 | {/* text-boxes (read-only) that display the selected query and its query response and format them nicely */} 196 |
197 | 198 | 199 |
200 | 201 |
202 | 205 | 208 | 211 | 214 |
215 | 216 |
217 |
218 | {/* feed the table data to the table component and render it */} 219 | 220 |
221 |
222 | 223 | {/* feed the chart data to the bar chart component and render it */} 224 | 225 |
226 |
227 |
228 | ); 229 | } 230 | 231 | export default QueryForm; 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # BuQL 4 | buql logo 5 | 6 |
7 | 8 |
9 |
10 | bun 11 | graphql 12 | redis 13 | Express 14 |
15 | JavaScript 16 | html5 17 | css3 18 |
19 | React 20 | eslint 21 | mongodb 22 | git 23 |
24 | 25 |
26 | 27 |
28 | npm 29 | GitHub license 30 |
31 |
32 | 33 | ## Skip to It 34 | - [The Power of BuQL](https://github.com/oslabs-beta/buql/#Power) 35 | - [From Start to Finish](https://github.com/oslabs-beta/buql/#Execution) 36 | - [BuQLing Up](https://github.com/oslabs-beta/buql/#Using]) 37 | - [Becoming a BuQLer](https://github.com/oslabs-beta/buql/#Contribute) 38 | - [Further Info](https://github.com/oslabs-beta/buql/#Info) 39 | 40 |

The Power of BuQL

41 | Bun is an groundbreaking new runtime. GraphQL is an incredibly efficient query language. Ioredis is an optimized client of Redis, the state-of-the-art caching solution. What could they do when combined? 42 | 43 | Welcome to BuQL, the harmonizing of Bun and GraphQL, with ioredis included for the most optimal query response times. Any developer with a Bun-based codebase can now utilize BuQL as an Express middleware to intercept queries, returning the responses from a cache when possible and caching them otherwise. BuQL is able to bring all of this to the table in an easy-to-use npm package equipped with security features to mitigate risk. 44 | 45 | Optimized response times via enhanced runtime speeds. Lightweight and flexible. Straightforward in use, elegant in performance. Keep extremities in at all times, it's time to BuQL up! 46 | 47 |

BuQLing Up

48 | 49 | 50 | ### 1. Getting started with Bun 51 | 52 | Windows is typically not recommended, but here's how to on other OS's: 53 | 54 | - MacOS and WSL 55 | ``` 56 | $ curl -fsSL https://bun.sh/install | bash 57 | ``` 58 | (or, to install a specific version) 59 | 60 | ``` 61 | $ curl -fsSL https://bun.sh/install | bash -s "bun-v1.0.0" 62 | ``` 63 | - Linux 64 | 65 | - The `unzip` package is required to install Bun. Use `sudo apt install unzip` to install `unzip` package. Kernel version 5.6 or higher is strongly recommended, but the minimum is 5.1. Use `uname -r` to check Kernel version. 66 | - Once `unzip` package is installed, see WSL directions 67 | 68 |
69 | 70 | - Using npm (for the last time!) 71 | 72 | ``` 73 | $ npm install -g bun 74 | ``` 75 |
76 | 77 | - Using Homebrew (macOS and Linux) 78 | ``` 79 | $ brew install oven-sh/bun/bun 80 | ``` 81 | 82 |
83 | 84 | - Using docker 85 | ``` 86 | $ docker pull oven/bun 87 | $ docker run --rm --init --ulimit memlock=-1:-1 oven/bun 88 | ``` 89 | 90 |
91 | 92 | - Using Proto 93 | ``` 94 | $ proto install bun 95 | ``` 96 | 97 |
98 | 99 | ### 2. BuQLing Up 100 | 101 | - Install BuQL 102 | ``` 103 | $ bun install @buql/buql 104 | ``` 105 | 106 | - Import it into the file you'll be working in, usually referred to as 'index.js' 107 | ```javascript 108 | import buql from '@buql/buql'; 109 | ``` 110 | 111 | ### 3. Installing and Connecting to an ioredis server 112 | 113 | ###### (We recommend using ioredis, as that is where we discovered the best performace. However, due to the syntactic similarities between Redis and its client ioredis, either one should work.) 114 | - Install ioredis 115 | ``` 116 | $ bun install ioredis 117 | ``` 118 | - Start redis server 119 | 120 | - For starting the redis server, check out the official documentation and follow the directions for your OS! 121 | 122 | Once installed, your server should reflect the below: 123 | 124 | - Note: The default port is `6379` 125 | 126 | ```javascript 127 | const redisClient = redis.createClient({ 128 | host: "localhost", 129 | port: 6379, 130 | }); 131 | ``` 132 |
133 | 134 | ### 4. Utilizing GraphQL 135 | 136 | - Install GraphQL 137 | ``` 138 | $ bun install graphql 139 | $ bun install express-graphql 140 | ``` 141 | - Import the http function from GraphQL in the same folder as BuQL and ioredis: 142 | ```javascript 143 | import { graphqlHTTP } from 'express-graphql'; 144 | ``` 145 |
146 | 147 | ### 5. Set up GraphQL schemas 148 | 149 | If you're using BuQL, it's likely you've done this already. But just in case, here's some example code to give you an idea. These will likely be in their own schema folder that you will need to import into the same one as BuQL: 150 | 151 | - Import relevant pieces of GraphQL: 152 | ```javascript 153 | import { 154 | GraphQLSchema, 155 | GraphQLObjectType, 156 | GraphQLString, 157 | GraphQLInt, 158 | GraphQLNonNull, 159 | GraphQLList, 160 | GraphQLID, 161 | } from 'graphql'; 162 | ``` 163 | - Define Schemas 164 | ```javascript 165 | const UserType = new GraphQLObjectType({ 166 | name: 'User', 167 | fields: () => ({ 168 | id: { type: GraphQLString }, 169 | username: { type: GraphQLString }, 170 | age: { type: GraphQLInt} 171 | }), 172 | }); 173 | ``` 174 |
175 | 176 | ### 6. Get to Work! 177 | 178 | With everything in place, you’re all BuQLed in! Now you can set up your routes with BuQL. 179 | 180 | For instance, in our demo’s frontend we wrote: 181 | 182 | ```javascript 183 | const buqlResponse = await fetch('http://localhost:8080/buql', { 184 | method: 'POST', 185 | headers: { 186 | 'Content-Type': 'application/json', 187 | }, 188 | body: JSON.stringify({query: selectedQuery.query}), 189 | }); 190 | const responseObj = await buqlResponse.json(); 191 | let { source, cacheHits, nonCache, response } = responseObj; 192 | ``` 193 | From there, it travels to the backend, which has this code, 194 | 195 | ```javascript 196 | app.use('/buql', buql.security, buql.cache, (req, res) => { 197 | return res.status(200).send(res.locals.response); 198 | }); 199 | 200 | app.use('/clearCache', buql.clearCache, (req, res) => { 201 | return res.status(200).send('cache cleared'); 202 | }); 203 | 204 | // Standalone graphql route 205 | app.use( 206 | '/graphql', 207 | graphqlHTTP({ 208 | schema, 209 | graphiql: true, 210 | }) 211 | ); 212 | ``` 213 | 214 |

Becoming a BuQLer

215 | 216 | 217 | From its conception, BuQL was developed to be an open-source product with a never ending journey to perfection! We gladly welcome any and all contributions, whether through iterations, additions, or general feedback! Here are some features we would love to see: 218 | - Client-side caching 219 | - The ability to handle nested queries 220 | - A more agnostic, unopinionated approach, allowing for use beyond just the Express framework. 221 |
222 | 223 | At the end of the day, we welcome any and all ideas. Get creative! 224 | 225 |

Further Info

226 | Feel free to dive deeper into BuQL itself... 227 | 228 |
229 |
230 | medium 231 | website 232 | linked 233 |
234 | 235 |
236 | ...or reach out to the team: 237 | 238 |
239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 |

Dylan Briar

Jacob Diamond

Julien Kerekes

Joseph McGarry

linkedlinkedlinkedlinked
GitHubGitHubGitHubGitHub
259 |
260 | 261 |
262 |
Send something to the whole team here! 263 | 264 |
265 | gmail 266 |
267 |
268 | 269 | ## 270 | 271 | ### Let us know you liked the project by clicking the star in the top right! 272 | 273 | ### Thanks and **BuQL Up**! 274 | 275 | ## 276 |
277 | --------------------------------------------------------------------------------