diff --git a/ObsidianWrapper/ObsidianWrapper.jsx b/ObsidianWrapper/ObsidianWrapper.jsx index b424d7b..57933d7 100644 --- a/ObsidianWrapper/ObsidianWrapper.jsx +++ b/ObsidianWrapper/ObsidianWrapper.jsx @@ -1,16 +1,30 @@ -import React from 'https://dev.jspm.io/react'; -import Cache from '../src/Browser/CacheClassBrowser.js'; -import { insertTypenames } from '../src/Browser/insertTypenames.js'; +import React from "https://dev.jspm.io/react"; +import BrowserCache from "../src/Browser/CacheClassBrowser.js"; +import { insertTypenames } from "../src/Browser/insertTypenames.js"; const cacheContext = React.createContext(); function ObsidianWrapper(props) { - const [cache, setCache] = React.useState(new Cache()); + const [cache, setCache] = React.useState(new BrowserCache()); + + // You have to put your Google Chrome Obsidian developer tool extension id to connect Obsidian Wrapper with dev tool + const chromeExtensionId = "dkbfipkapkljpdbhdihnlnbieffhjdmh"; + window.localStorage.setItem("cache", JSON.stringify(cache)); async function query(query, options = {}) { + // dev tool messages + const startTime = Date.now(); + chrome.runtime.sendMessage(chromeExtensionId, { query: query }); + chrome.runtime.sendMessage(chromeExtensionId, { + cache: window.localStorage.getItem("cache"), + }); + console.log( + "Here's the message content: ", + window.localStorage.getItem("cache") + ); // set the options object default properties if not provided const { - endpoint = '/graphql', + endpoint = "/graphql", cacheRead = true, cacheWrite = true, pollInterval = null, @@ -36,9 +50,14 @@ function ObsidianWrapper(props) { // when the developer decides to only utilize whole query for cache if (wholeQuery) resObj = await cache.readWholeQuery(query); else resObj = await cache.read(query); + console.log("query function resObj: ", resObj); // check if query is stored in cache if (resObj) { // returning cached response as a promise + const cacheHitResponseTime = Date.now() - startTime; + chrome.runtime.sendMessage(chromeExtensionId, { + cacheHitResponseTime: cacheHitResponseTime, + }); return new Promise((resolve, reject) => resolve(resObj)); } // execute graphql fetch request if cache miss @@ -55,10 +74,10 @@ function ObsidianWrapper(props) { try { // send fetch request with query const resJSON = await fetch(endpoint, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', + "Content-Type": "application/json", + Accept: "application/json", }, body: JSON.stringify({ query }), }); @@ -69,6 +88,14 @@ function ObsidianWrapper(props) { if (wholeQuery) cache.writeWholeQuery(query, deepResObj); else cache.write(query, deepResObj); } + const cacheMissResponseTime = Date.now() - startTime; + chrome.runtime.sendMessage(chromeExtensionId, { + cacheMissResponseTime: cacheMissResponseTime, + }); + console.log( + "Here's the response time on the front end: ", + cacheMissResponseTime + ); return resObj; } catch (e) { console.log(e); @@ -80,39 +107,86 @@ function ObsidianWrapper(props) { function clearCache() { cache.cacheClear(); } - // mutate method, refer to mutate.js for more info + + // breaking out writethrough logic vs. non-writethrough logic async function mutate(mutation, options = {}) { - // set the options object default properties if not provided + // dev tool messages + chrome.runtime.sendMessage(chromeExtensionId, { + mutation: mutation, + }); + const startTime = Date.now(); mutation = insertTypenames(mutation); const { - endpoint = '/graphql', + endpoint = "/graphql", cacheWrite = true, toDelete = false, update = null, + writeThrough = false, } = options; - // for any mutation a request to the server is made try { - const responseObj = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ query: mutation }), - }).then((resp) => resp.json()); - if (!cacheWrite) return responseObj; - // first behaviour when delete cache is set to true - if (toDelete) { - cache.write(mutation, responseObj, true); + if (writeThrough) { + // if it's a deletion, then delete from cache and return the object + if (toDelete) { + const responseObj = await cache.writeThrough( + mutation, + {}, + true, + endpoint + ); + const deleteMutationResponseTime = Date.now() - startTime; + chrome.runtime.sendMessage(chromeExtensionId, { + deleteMutationResponseTime: deleteMutationResponseTime, + }); + return responseObj; + } else { + // for add mutation + const responseObj = await cache.writeThrough( + mutation, + {}, + false, + endpoint + ); + // for update mutation + if (update) { + // run the update function + update(cache, responseObj); + } + // always write/over-write to cache (add/update) + // GQL call to make changes and synchronize database + console.log("WriteThrough - true ", responseObj); + const addOrUpdateMutationResponseTime = Date.now() - startTime; + chrome.runtime.sendMessage(chromeExtensionId, { + addOrUpdateMutationResponseTime: addOrUpdateMutationResponseTime, + }); + return responseObj; + } + } else { + // copy-paste mutate logic from 4. + + // use cache.write instead of cache.writeThrough + const responseObj = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ query: mutation }), + }).then((resp) => resp.json()); + if (!cacheWrite) return responseObj; + // first behaviour when delete cache is set to true + if (toDelete) { + cache.write(mutation, responseObj, true); + return responseObj; + } + // second behaviour if update function provided + if (update) { + update(cache, responseObj); + } + // third behaviour just for normal update (no-delete, no update function) + cache.write(mutation, responseObj); + console.log("WriteThrough - false ", responseObj); return responseObj; } - // second behaviour if update function provided - if (update) { - update(cache, responseObj); - } - // third behaviour just for normal update (no-delete, no update function) - cache.write(mutation, responseObj); - return responseObj; } catch (e) { console.log(e); } diff --git a/README.md b/README.md index fddb734..8bd88df 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - Configurable caching options, giving you complete control over your cache - Fullstack integration, leveraging client-side and server-side caching to streamline your caching strategy - Support for the full GraphQL convention -- Support for server-side cache invalidation +- Support for client-side and server-side cache invalidation - Optional GraphQL DoS attack mitigation security module ## Overview @@ -151,9 +151,12 @@ const MovieApp = () => { ``` ## Documentation - [obsidian.land](http://obsidian.land) +## Developer Tool +information and instructions on how to use our developer tool can be found here:
+[oslabs-beta/obsidian-developer-tool](https://github.com/oslabs-beta/obsidian-developer-tool) + ## Dockerized Demo working demo to install locally in docker: [oslabs-beta/obsidian-demo-docker](https://github.com/oslabs-beta/obsidian-demo-docker) @@ -164,7 +167,11 @@ github for a demo with some example code to play with: ## Authors - +[Yurii Shchyrba](https://github.com/YuriiShchyrba) +[Linda Zhao](https://github.com/lzhao15) +[Ali Fay](https://github.com/ali-fay) +[Anthony Guan](https://github.com/guananthony) +[Yasir Choudhury](https://github.com/Yasir-Choudhury) [Yogi Paturu](https://github.com/YogiPaturu) [Michael Chin](https://github.com/mikechin37) [Dana Flury](https://github.com/dmflury) diff --git a/src/Browser/CacheClassBrowser.js b/src/Browser/CacheClassBrowser.js index f23ff1b..fb58f3f 100644 --- a/src/Browser/CacheClassBrowser.js +++ b/src/Browser/CacheClassBrowser.js @@ -3,12 +3,14 @@ import normalizeResult from "./normalize.js"; import destructureQueries from "./destructure.js"; -export default class Cache { +export default class BrowserCache { constructor( initialCache = { ROOT_QUERY: {}, ROOT_MUTATION: {}, - } + // match resolvers to types in order to add them in write-through + writeThroughInfo: {}, + }, ) { this.storage = initialCache; this.context = "client"; @@ -16,8 +18,9 @@ export default class Cache { // Main functionality methods async read(queryStr) { - if (typeof queryStr !== "string") + if (typeof queryStr !== "string") { throw TypeError("input should be a string"); + } // destructure the query string into an object const queries = destructureQueries(queryStr).queries; // breaks out of function if queryStr is a mutation @@ -37,7 +40,7 @@ export default class Cache { // invoke populateAllHashes and add data objects to the response object for each input query responseObject[respObjProp] = await this.populateAllHashes( arrayHashes, - queries[query].fields + queries[query].fields, ); if (!responseObject[respObjProp]) return undefined; @@ -49,6 +52,52 @@ export default class Cache { return { data: responseObject }; } + async writeThrough(queryStr, respObj, deleteFlag, endpoint) { + try { + const queryObj = destructureQueries(queryStr); + const mutationName = queryObj.mutations[0].name; + // check if it's a mutation + if (queryObj.mutations) { + // check to see if the mutation/type has been stored in the cache yet + // if so, make the graphQL call + if (!this.storage.writeThroughInfo.hasOwnProperty(mutationName)) { + respObj = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ query: queryStr }), + }).then((resp) => resp.json()); + // store the mutation/type in cache + this.storage.writeThroughInfo[mutationName] = {}; + this.storage.writeThroughInfo[mutationName].type = + respObj.data[mutationName].__typename; + this.storage.writeThroughInfo[mutationName].lastId = + respObj.data[mutationName].id; + // below is for situations when the type is already stored + } else { + // construct the response object ourselves + const dummyResponse = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ query: queryStr }), + }).then((resp) => resp.json()); + this.constructResponseObject(queryObj, respObj, deleteFlag); + } + // same logic for both situations + // normalize the result, invalidate the cache and return the appropriate object + await this.write(queryStr, respObj, deleteFlag); + return respObj; + } + } catch (e) { + console.log(e); + } + } + async write(queryStr, respObj, deleteFlag) { const queryObj = destructureQueries(queryStr); const resFromNormalize = normalizeResult(queryObj, respObj, deleteFlag); @@ -66,6 +115,65 @@ export default class Cache { } } + constructResponseObject(queryObj, respObj, deleteFlag) { + const mutationData = queryObj.mutations[0]; + const mutationName = mutationData.name; + const __typename = this.storage.writeThroughInfo[mutationName].type; + // this.storage.writeThroughInfo[mutationName].type; + respObj.data = {}; + const obj = {}; + respObj.data[mutationName] = obj; + obj.__typename = __typename; + // delete logic + if (deleteFlag) { + // add id and value from the queryObj + let idAndVal = mutationData.arguments; + idAndVal = idAndVal.split(":"); + const id = idAndVal[0].substring(1); + const val = idAndVal[1].substring(0, idAndVal[1].length - 1); + obj[id] = val; + // return out of this function so we don't continue + // onto add/update logic + return respObj; + } + // increment ID for ADD mutations only + obj.id = (++this.storage.writeThroughInfo[mutationName].lastId).toString(); + + // ADD mutation logic + // grab arguments (which is a string) + const argumentsStr = mutationData.arguments; + this.addNonScalarFields(argumentsStr, respObj, mutationData); + this.separateArguments(argumentsStr, respObj, mutationName); + } + + separateArguments(str, respObj, mutationName) { + const startIndex = str.indexOf("{"); + const slicedStr = str.slice(startIndex + 1, str.length - 2); + const argumentPairs = slicedStr.split(","); + for (const argumentPair of argumentPairs) { + const argumentKeyAndValue = argumentPair.split(":"); + const argumentKey = argumentKeyAndValue[0]; + let argumentValue = Number(argumentKeyAndValue[1]) + ? Number(argumentKeyAndValue[1]) + : argumentKeyAndValue[1]; + if (typeof argumentValue === "string") { + argumentValue = argumentValue.replace(/\"/g, ""); + } + respObj.data[mutationName][argumentKey] = argumentValue; + } + } + + addNonScalarFields(respObj, mutationData) { + for (const field in mutationData.fields) { + if ( + mutationData.fields[field] !== "scalar" && + mutationData.fields[field] !== "meta" + ) { + respObj.data[mutationData.name][field] = []; + } + } + } + gc() { // garbageCollection; garbage collection: removes any inaccessible hashes from the cache const badHashes = getBadHashes(); @@ -96,10 +204,11 @@ export default class Cache { rootQuery[key] = rootQuery[key].filter((x) => !badHashes.has(x)); if (rootQuery[key].length === 0) delete rootQuery[key]; for (let el of rootQuery[key]) goodHashes.add(el); - } else + } else { badHashes.has(rootQuery[key]) ? delete rootQuery[key] : goodHashes.add(rootQuery[key]); + } } return goodHashes; } @@ -136,7 +245,7 @@ export default class Cache { for (let i in this.storage[key]) { if (Array.isArray(this.storage[key][i])) { this.storage[key][i] = this.storage[key][i].filter( - (x) => !badHashes.has(x) + (x) => !badHashes.has(x), ); } else if (typeof this.storage[key][i] === "string") { if ( @@ -215,7 +324,7 @@ export default class Cache { // case where the field from the input query is an array of hashes, recursively invoke populateAllHashes dataObj[field] = await this.populateAllHashes( readVal[field], - fields[field] + fields[field], ); if (dataObj[field] === undefined) return undefined; }