├── 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 | {column.Header}
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 |
63 | {cell.render('Cell')}
64 |
65 | );
66 | })}
67 |
68 | );
69 | })}
70 |
71 |
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 |
228 | );
229 | }
230 |
231 | export default QueryForm;
232 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # BuQL
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
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 |
234 |
235 |
236 | ...or reach out to the team:
237 |
238 |
260 |
261 |
262 |
Send something to the whole team here!
263 |
264 |
265 |
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 |
--------------------------------------------------------------------------------