diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 6377878..0000000 --- a/.prettierrc +++ /dev/null @@ -1 +0,0 @@ -{ "singleQuote": true } diff --git a/.travis.yml b/.travis.yml index b010097..c7cdaf0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,10 @@ # sudo: required # services: -# - docker +# - docker # before_install: -# - docker build -t open-source-labs/obsidian . +# - docker build -t open-source-labs/obsidian . # script: -# - docker run open-source-labs/obsidian test --allow-net --allow-read --allow-env --unstable deno.test.ts +# - docker run open-source-labs/obsidian test --allow-net --allow-read --allow-env --unstable deno.test.ts # env: # global: # secure: sfaHXoKGXnAkwS/QK2pdTPC1NVqd9+pVWImEcz8W9IXFRsOcHpt9lVsmB0dFvDpVm+9KFpcBwnpfOtiyoj6Q9NGIY71jG58kYHdbcWBlR3onS7/JBvgEu94DC7HZR+rQ4/GW+ROh4avBt6RjDSuLk4qQ73Yc3+SDKAl+M0PTADlVZpkicCID59qcdynbAjXu5W8lW2Hp0hqO72Prx/8hgmchI0I7zSYcPBFSy3WaEPJa52yKesVwsHcFtzOBMrDAdE+R028AzdBAXUoiqh6cTVeLSTL1jnIWbCBtfAROlTR82cZyo4c7PJxYyqT3mhRSZvBN/3hdW7+xMOzq6gmpmcl1UO2Q5i4xXEGnatfuzMVa/8SqJZoG2IFIWZ4mvelwufHVuLgF+6JvK2BKSpjFfSUGo0p9G0bMg+GHwRipTPIq1If3ELkflAM6QJwL7TritwtWzWXfAfoZ3KALdPTiFzJAKyQfFvSwWbfXqAgqZIbLjlzSgOJ4QKWD6CBksU7b4Oky6hr/+R+ZihzQLtWKkk/8cklEG/NJlknS2vPRG8xRRF7/C+vSFPrCkmsakPc8c1iGfai8J3Vc09Pg0UeShJDWkSQ6QP165ub6LEL5nz0Qzp0CD1sSQu5re5/M5ef9V69L2pdYhEj0RaZ241DF5efzYAgLI8SvMr5TcTr06+8= diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1535e13 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": true +} \ No newline at end of file diff --git a/README.md b/README.md index 7e48098..9f2c6f4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ - GraphQL query abstraction and caching in SSR React projects, improving the performance of your app - Normalized caching, optimizing memory management to keep your site lightweight and fast - Fullstack integration, leveraging client-side and server-side caching to streamline your caching strategy +- Support for GraphQL fragments, directives, and variables +- Optional GraphQL DoS attack mitigation security module ## Overview @@ -64,7 +66,7 @@ const PORT = 8000; const app = new Application(); const types = (gql as any)` - // Type definitions + // GraphQL type definitions `; const resolvers = { @@ -193,6 +195,11 @@ const MovieApp = () => { _Lascaux_ Engineers +[Kyung Lee](https://github.com/kyunglee1) +[Justin McKay](https://github.com/justinwmckay) +[Patrick Sullivan](https://github.com/pjmsullivan) +[Cameron Simmons](https://github.com/cssim22) +[Raymond Ahn](https://github.com/raymondcodes) [Alonso Garza](https://github.com/Alonsog66) [Burak Caliskan](https://github.com/CaliskanBurak) [Matt Meigs](https://github.com/mmeigs) diff --git a/src/CacheClassServer.js b/src/CacheClassServer.js index 94133a3..7bfbc55 100644 --- a/src/CacheClassServer.js +++ b/src/CacheClassServer.js @@ -32,19 +32,22 @@ export class Cache { } // Main functionality methods - async read(queryStr) { + async read(queryStr, queryVars) { if (typeof queryStr !== 'string') throw TypeError('input should be a string'); // destructure the query string into an object - const queries = destructureQueries(queryStr).queries; + const queries = destructureQueries(queryStr, queryVars).queries; + // breaks out of function if queryStr is a mutation if (!queries) return undefined; + const responseObject = {}; // iterate through each query in the input queries object for (const query in queries) { // get the entire str query from the name input query and arguments const queryHash = queries[query].name.concat(queries[query].arguments); const rootQuery = await this.cacheRead('ROOT_QUERY'); + // match in ROOT_QUERY if (rootQuery[queryHash]) { // get the hashs to populate from the existent query in the cache @@ -56,6 +59,7 @@ export class Cache { arrayHashes, queries[query].fields ); + if (!responseObject[respObjProp]) return undefined; // no match with ROOT_QUERY return null or ... @@ -66,8 +70,8 @@ export class Cache { return { data: responseObject }; } - async write(queryStr, respObj, deleteFlag) { - const queryObj = destructureQueries(queryStr); + async write(queryStr, respObj, deleteFlag, queryVars) { + const queryObj = destructureQueries(queryStr, queryVars); const resFromNormalize = normalizeResult(queryObj, respObj, deleteFlag); // update the original cache with same reference for (const hash in resFromNormalize) { @@ -85,22 +89,26 @@ export class Cache { // cache read/write helper methods async cacheRead(hash) { - // returns value from either object cache or cache || 'DELETED' || undefined + // returns value from either object cache or cache || 'DELETED' || undefined if (this.context === 'client') { + console.log('context === client HIT'); return this.storage[hash]; } else { // logic to replace these storage keys if they have expired if (hash === 'ROOT_QUERY' || hash === 'ROOT_MUTATION') { const hasRootQuery = await redis.get('ROOT_QUERY'); + if (!hasRootQuery) { await redis.set('ROOT_QUERY', JSON.stringify({})); } const hasRootMutation = await redis.get('ROOT_MUTATION'); + if (!hasRootMutation) { await redis.set('ROOT_MUTATION', JSON.stringify({})); } } let hashedQuery = await redis.get(hash); + // if cacheRead is a miss if (hashedQuery === undefined) return undefined; return JSON.parse(hashedQuery); @@ -158,8 +166,10 @@ export class Cache { async populateAllHashes(allHashesFromQuery, fields) { // include the hashname for each hash if (!allHashesFromQuery.length) return []; + const hyphenIdx = allHashesFromQuery[0].indexOf('~'); const typeName = allHashesFromQuery[0].slice(0, hyphenIdx); + return allHashesFromQuery.reduce(async (acc, hash) => { // for each hash from the input query, build the response object const readVal = await this.cacheRead(hash); @@ -176,11 +186,13 @@ export class Cache { // add the typename for the type if (field === '__typename') { dataObj[field] = typeName; - } else dataObj[field] = readVal[field]; + } else { + dataObj[field] = readVal[field]; + } } else { // case where the field from the input query is an array of hashes, recursively invoke populateAllHashes dataObj[field] = await this.populateAllHashes( - readVal[field], + [readVal[field]], fields[field] ); if (dataObj[field] === undefined) return undefined; diff --git a/src/DoSSecurity.ts b/src/DoSSecurity.ts new file mode 100644 index 0000000..b5c0722 --- /dev/null +++ b/src/DoSSecurity.ts @@ -0,0 +1,59 @@ +import destructureQueries from './destructure.js'; + +// Interface representing shape of query object after destructuring +interface queryObj { + queries?: Array, + mutations?: Array, +} + +/** + * Tests whether a queryString (string representation of query) exceeds the maximum nested depth levels (queryDepthLimit) allowable for the instance of obsidian + * @param {*} queryString the string representation of the graphql query + * @param {*} queryDepthLimit number representation of the maximum query depth limit. Default 0 will return undefined. Root query doesn't count toward limit. + * @returns boolean indicating whether the query depth exceeded maximum allowed query depth + */ +export default function queryDepthLimiter(queryString: string, queryDepthLimit: number = 0): void { + const queryObj = destructureQueries(queryString) as queryObj; + /** + *Function that tests whether the query object debth exceeds maximum depth + * @param {*} qryObj an object representation of the query (after destructure) + * @param {*} qryDepthLim the maximum query depth + * @param {*} depth indicates current depth level + * @returns boolean indicating whether query depth exceeds maximum depth + */ + const queryDepthCheck = (qryObj: queryObj, qryDepthLim: number, depth: number = 0): boolean => { + // Base case 1: check to see if depth exceeds limit, if so, return error (true means depth limit was exceeded) + if (depth > qryDepthLim) return true; + // Recursive case: Iterate through values of queryObj, and check if each value is an object, + for (let value = 0; value < Object.values(qryObj).length; value++) { + // if the value is an object, return invokation queryDepthCheck on nested object and iterate depth + const currentValue = Object.values(qryObj)[value]; + if (typeof currentValue === 'object') { + return queryDepthCheck(currentValue, qryDepthLim, depth + 1); + }; + }; + // Base case 2: reach end of object keys iteration,return false - depth has not been exceeded + return false; + }; + + // Check if queryObj has query or mutation root type, if so, call queryDepthCheck on each element, i.e. each query or mutation + if (queryObj.queries) { + for(let i = 0; i < queryObj.queries.length; i++) { + if(queryDepthCheck(queryObj.queries[i], queryDepthLimit)) { + throw new Error( + 'Security Error: Query depth exceeded maximum query depth limit' + ); + }; + }; + }; + + if (queryObj.mutations){ + for (let i = 0; i < queryObj.mutations.length; i++) { + if (queryDepthCheck(queryObj.mutations[i], queryDepthLimit)) { + throw new Error( + 'Security Error: Query depth exceeded maximum mutation depth limit' + ); + }; + }; + }; +} diff --git a/src/destructure.js b/src/destructure.js index 3e961b4..5ba843c 100644 --- a/src/destructure.js +++ b/src/destructure.js @@ -1,25 +1,38 @@ /** * NOTES: * 1. For now we will record the arguments as a string unless we come up with an alternative argument - * 2. We won't worry about arguments on fields for now * 3. We won't worry about aliases for now - * 4. We won't worry about handling directives for now + * 4. We won't worry about handling MULTIPLE directives for now (both @skip and + * @include) * 5. We won't worry about fragments for now * 6. This function will assume that everything passed in can be a query or a mutation (not both). - * 7. We won't handle variables for now, but we may very well find we need to * 8. We will handle only the meta field "__typename" for now * 9. What edge cases as far as field/query names do we have to worry about: special characters, apostrophes, etc??? + * 10. Directives-implementation doesn't handle fragment inclusion * */ + // this function will destructure a query/mutation operation string into a query/mutation operation object -export function destructureQueries(queryOperationStr) { - queryOperationStr = queryOperationStr.replace(/,/gm, ''); +export function destructureQueries(queryOperationStr, queryOperationVars) { + // Trims blocks of extra white space into a single white space for uniformity + // of incoming queryOperationStrings + queryOperationStr = queryOperationStr.replace(/\s+/g, ' ').trim(); + // check if query has fragments if (queryOperationStr.indexOf('fragment') !== -1) { // reassigns query string to replace fragment references with fragment fields queryOperationStr = destructureQueriesWithFragments(queryOperationStr); } + // check if query has directives + if (queryOperationStr.indexOf('@') !== -1) { + // reassigns query string to handle directives + queryOperationStr = destructureQueriesWithDirectives( + queryOperationStr, + queryOperationVars + ); + } + // ignore operation name by finding the beginning of the query strings const startIndex = queryOperationStr.indexOf('{'); const queryStrings = queryOperationStr.substring(startIndex).trim(); @@ -31,7 +44,11 @@ export function destructureQueries(queryOperationStr) { ? 'mutations' : 'queries'; // create a queries object from array of query strings - const queriesObj = createQueriesObj(arrayOfQueryStrings, typePropName); + const queriesObj = createQueriesObj( + arrayOfQueryStrings, + typePropName, + queryOperationVars + ); return queriesObj; } @@ -72,14 +89,14 @@ export function findQueryStrings(queryStrings) { } // helper function to create a queries object from an array of query strings -export function createQueriesObj(arrayOfQueryStrings, typePropName) { +export function createQueriesObj(arrayOfQueryStrings, typePropName, queryVars) { // define a new empty result object const queriesObj = {}; queriesObj[typePropName] = []; // for each query string arrayOfQueryStrings.forEach((queryStr) => { // split the query string into multiple parts - const queryObj = splitUpQueryStr(queryStr); + const queryObj = splitUpQueryStr(queryStr, queryVars); // recursively convert the fields string to a fields object and update the fields property queryObj.fields = findQueryFields(queryObj.fields); // push the finished query object into the queries/mutations array on the result object @@ -89,7 +106,7 @@ export function createQueriesObj(arrayOfQueryStrings, typePropName) { return queriesObj; } // helper function that returns an object with a query string split into multiple parts -export function splitUpQueryStr(queryStr) { +export function splitUpQueryStr(queryStr, queryVars) { // creates new queryObj const queryObj = {}; let parensPairs = 0; @@ -139,14 +156,61 @@ export function splitUpQueryStr(queryStr) { argsString = argsString.replace(/\s/g, ''); // handles edge case where ther are no arguments inside the argument parens pair. if (argsString === '()') argsString = ''; + + // if variables were passed in with the query, replace the variables in + // the argString with their values + if (queryVars) { + argsString = replaceQueryVariables(argsString, queryVars); + } + queryObj.arguments = argsString; queryObj.fields = queryStr.substring(i + 1).trim(); - return queryObj; } } } +// helper function to manipulate query args string by replacing variables +export function replaceQueryVariables(queryArgs, variables) { + // indexes of start ($) & end of variable name + let varStartIndex; + let varEndIndex; + + for (let i = 0; i < queryArgs.length; i += 1) { + const char = queryArgs[i]; + + // the start of variable names are always indicated by $ + if (char === '$') varStartIndex = i; + // the end of variable names are always indicated by , or ) + if (char === ',' || char === ')') varEndIndex = i; + + // if we have found the start and end positions of a variable in the query + // args string + if (varStartIndex && varEndIndex && variables) { + // find the value of that variable by looking it up against the + // "variables" object + const varName = queryArgs.slice(varStartIndex + 1, varEndIndex); + const varValue = variables[varName]; + + // if the variable was present in the "variables" object, mutate the query + // arg string by replacing the variable with its value + if (varValue !== undefined) { + queryArgs = queryArgs.replace('$' + varName, varValue); + + // reset i after replacing the variable with its value + /* NOTE: the value of the variable COULD be bigger than the variable itself */ + i -= varName.length - varValue.length; + } + + // reset start and end indexes to look for more variables + varStartIndex = undefined; + varEndIndex = undefined; + } + } + + return queryArgs; +} + // helper function to recursively convert the fields string to a fields object export function findQueryFields(fieldsStr) { const fieldsObj = {}; @@ -209,8 +273,8 @@ export function destructureQueriesWithFragments(queryOperationStr) { const fragments = []; // helper function to separate fragment from query/mutation const separateFragments = (queryCopy) => { - let startFragIndex = queryCopy.indexOf('fragment'); - let startFragCurly = queryCopy.indexOf('{', startFragIndex); + const startFragIndex = queryCopy.indexOf('fragment'); + const startFragCurly = queryCopy.indexOf('{', startFragIndex); let endFragCurly; const stack = ['{']; const curlsAndParens = { @@ -218,12 +282,12 @@ export function destructureQueriesWithFragments(queryOperationStr) { ')': '(', }; for (let i = startFragCurly + 1; i < queryCopy.length; i++) { - let char = queryCopy[i]; + const char = queryCopy[i]; if (char === '{' || char === '(') { stack.push(char); } if (char === '}' || char === ')') { - let topOfStack = stack[stack.length - 1]; + const topOfStack = stack[stack.length - 1]; if (topOfStack === curlsAndParens[char]) stack.pop(); } if (!stack[0]) { @@ -232,10 +296,10 @@ export function destructureQueriesWithFragments(queryOperationStr) { } } - let fragment = queryCopy.slice(startFragIndex, endFragCurly + 1); + const fragment = queryCopy.slice(startFragIndex, endFragCurly + 1); fragments.push(fragment); - let newStr = queryCopy.replace(fragment, ''); + const newStr = queryCopy.replace(fragment, ''); return newStr; }; @@ -252,9 +316,9 @@ export function destructureQueriesWithFragments(queryOperationStr) { //! TODO: OPTIMIZE, SHOULD NOT NEED TO ITERATE THROUGH WHOLE QUERY STRING TO FIND THE ONE WORD NAME OF THE FRAGMENT. MAYBE WHILE STRING INDEX< INDEX OF '{' ? // store each fragment name with its corresponding fields in fragmentObj fragments.forEach((fragment) => { - let index = fragment.indexOf('{'); - let words = fragment.split(' '); - let fragmentFields = fragment.slice(index + 1, fragment.length - 1); + const index = fragment.indexOf('{'); + const words = fragment.split(' '); + const fragmentFields = fragment.slice(index + 1, fragment.length - 1); fragmentObj[words[1]] = fragmentFields; }); @@ -268,4 +332,146 @@ export function destructureQueriesWithFragments(queryOperationStr) { return queryCopy; } -export default destructureQueries; \ No newline at end of file +// handles query string with directives (@include, @skip) by keeping or omitting +// fields depending on the value of the variable passed in +export function destructureQueriesWithDirectives(queryStr, queryVars) { + // starting point of iteration over queryStr + let startIndex = queryStr.indexOf('{'); + + // the starting and ending indices of arguments in queryStr + let argStartIndex; + let argEndIndex; + + // iterate over queryStr to replace variables in arguments + for (let i = startIndex; i < queryStr.length; i += 1) { + const char = queryStr[i]; + + if (char === '(') argStartIndex = i; + if (char === ')') argEndIndex = i; + + // if the start and end positions for a query argument have been found, + // replace variables in that argument + if (argStartIndex && argEndIndex) { + const oldQueryArgs = queryStr.slice(argStartIndex, argEndIndex + 1); + const newQueryArgs = replaceQueryVariables(oldQueryArgs, queryVars); + + queryStr = queryStr.replace(oldQueryArgs, newQueryArgs); + + // reset start and end indices to find and replace other arguments + argStartIndex = undefined; + argEndIndex = undefined; + } + } + + // starting point of iteration is now the first directive (indicated by @) + startIndex = queryStr.indexOf('@'); + + // skipFlag will indicate if the directive is @skip, otherwise @include is + // assumed to be the directive + let skipFlag = false; + + if (queryStr[startIndex + 1] === 's') { + skipFlag = true; + } + + // Boolean that indicates whether the field to which a directive is attached + // to should be included in or removed from the query string + let includeQueryField; + + // start and end positions of a directive (e.g. --> @include (if: true) <-- ) + /* NOTE: directives (from '@' to the closest closing paren) will always be + deleted from the query string, regardless of whether the value of the variable is true or false */ + let startDeleteIndex; + let endDeleteIndex; + + // delete directives from queryStr, as well as the field itself depending + // on the value of the variable in the directive + for (let i = startIndex; i < queryStr.length; i += 1) { + const char = queryStr[i]; + + if (char === '@') { + startDeleteIndex = i; + } + if (char === ')') { + endDeleteIndex = i; + } + + // check value of the variable in the directive (to the right of the ':') + if (startDeleteIndex && char === ':') { + // @skip directives will do the opposite of @include directives + if (queryStr.slice(i, i + 6).indexOf('true') !== -1) { + includeQueryField = skipFlag ? false : true; + } else { + includeQueryField = skipFlag ? true : false; + } + } + + // if the start and end positions for a directive is found, delete it + // (from the '@' to the closest closing paren) + if (startDeleteIndex && endDeleteIndex) { + const directive = queryStr.slice(startDeleteIndex, endDeleteIndex + 2); + + queryStr = queryStr.replace(directive, ''); + + // adjust i after deleting the directive from the queryStr + i -= directive.length; + + // if @include directive is false, or if @skip directive is true + if (!includeQueryField) { + // index of the beginning of a fields body (if the field was of + // non-scalar type and has nested fields) + let startBodyIndex = i + 2; + + // boolean indicating whether a field has nested fields (more fields + // within '{' and '}') + const hasNestedFields = queryStr.slice(i, i + 3).indexOf('{') !== -1; + + // if a field has nested fields and the @include was false/@skip was + // true, delete the nested fields as well + if (hasNestedFields) { + // adjust i to be pointing inside the body of the field + i += 2; + + // number of opening curly braces within the body of the field + let numBraces = 1; + + // find the corresponding closing brace for the body of the field with the directive + while (numBraces) { + i++; + const char = queryStr[i]; + + if (char === '{') numBraces++; + if (char === '}') numBraces--; + } + + const endBodyIndex = ++i; + + // delete the body of the field + const fieldBody = queryStr.slice(startBodyIndex, endBodyIndex); + queryStr = queryStr.replace(fieldBody, ''); + } + + // delete the field with the directive attached to it + let startFieldNameIndex = i - 1; + const endFieldNameIndex = i + 1; + + while (queryStr[startFieldNameIndex] !== ' ') { + startFieldNameIndex--; + } + + queryStr = queryStr.replace( + queryStr.slice(startFieldNameIndex, endFieldNameIndex), + '' + ); + } + + // reset start and end positions for a directive to look for more directives + startDeleteIndex = undefined; + endDeleteIndex = undefined; + } + } + + return queryStr; +} + +export default destructureQueries; diff --git a/src/lfuBrowserCache.js b/src/lfuBrowserCache.js index dabf5f1..f5d019e 100644 --- a/src/lfuBrowserCache.js +++ b/src/lfuBrowserCache.js @@ -2,58 +2,58 @@ import normalizeResult from './normalize.js'; import destructureQueries from './destructure.js'; class Node { - constructor(key, value) { - this.key = key; // 'Actor~1 - this.val = value; // {id:1, name:harrison, ....} - this.next = this.prev = null; - this.freq = 1; - } + constructor(key, value) { + this.key = key; // 'Actor~1 + this.val = value; // {id:1, name:harrison, ....} + this.next = this.prev = null; + this.freq = 1; + } } class DoublyLinkedList { - constructor() { - this.head = new Node(null, null); - this.tail = new Node(null, null); - this.head.next = this.tail; - this.tail.prev = this.head; - } - - insertHead(node) { - node.prev = this.head; - node.next = this.head.next; - this.head.next.prev = node; - this.head.next = node; - } - - removeNode(node) { - let prev = node.prev; - let next = node.next; - prev.next = next; - next.prev = prev; - } - - removeTail() { - let node = this.tail.prev; - this.removeNode(node); - return node.key; - } - - isEmpty() { - return this.head.next.val == null; - } + constructor() { + this.head = new Node(null, null); + this.tail = new Node(null, null); + this.head.next = this.tail; + this.tail.prev = this.head; + } + + insertHead(node) { + node.prev = this.head; + node.next = this.head.next; + this.head.next.prev = node; + this.head.next = node; + } + + removeNode(node) { + let prev = node.prev; + let next = node.next; + prev.next = next; + next.prev = prev; + } + + removeTail() { + let node = this.tail.prev; + this.removeNode(node); + return node.key; + } + + isEmpty() { + return this.head.next.val == null; + } } /** * @param {number} capacity */ export default function LFUCache(capacity) { - this.capacity = capacity; - this.currentSize = 0; - this.leastFreq = 0; - this.ROOT_QUERY = {}; - this.ROOT_MUTATION = {}; - this.nodeHash = new Map(); - this.freqHash = new Map(); + this.capacity = capacity; + this.currentSize = 0; + this.leastFreq = 0; + this.ROOT_QUERY = {}; + this.ROOT_MUTATION = {}; + this.nodeHash = new Map(); + this.freqHash = new Map(); } /** @@ -61,18 +61,18 @@ export default function LFUCache(capacity) { * @return {object} */ LFUCache.prototype.get = function (key) { - let node = this.nodeHash.get(key); - // if node is not found return undefined so that Obsidian will pull new data from graphQL - if (!node) return undefined; - this.freqHash.get(node.freq).removeNode(node); - if (node.freq == this.leastFreq && this.freqHash.get(node.freq).isEmpty()) - this.leastFreq++; - node.freq++; - // freqHash housekeeping - if (this.freqHash.get(node.freq) == null) - this.freqHash.set(node.freq, new DoublyLinkedList()); - this.freqHash.get(node.freq).insertHead(node); - return node.val; + let node = this.nodeHash.get(key); + // if node is not found return undefined so that Obsidian will pull new data from graphQL + if (!node) return undefined; + this.freqHash.get(node.freq).removeNode(node); + if (node.freq == this.leastFreq && this.freqHash.get(node.freq).isEmpty()) + this.leastFreq++; + node.freq++; + // freqHash housekeeping + if (this.freqHash.get(node.freq) == null) + this.freqHash.set(node.freq, new DoublyLinkedList()); + this.freqHash.get(node.freq).insertHead(node); + return node.val; }; /** @@ -81,155 +81,155 @@ LFUCache.prototype.get = function (key) { * @return {void} */ LFUCache.prototype.put = function (key, value) { - if (this.capacity == 0) return; - let node = this.nodeHash.get(key); - if (!node) { - // new node - this.currentSize++; - if (this.currentSize > this.capacity) { - let tailKey = this.freqHash.get(this.leastFreq).removeTail(); - this.nodeHash.delete(tailKey); - this.currentSize--; - } - let newNode = new Node(key, value); - // freqHash housekeeping - if (this.freqHash.get(1) == null) - this.freqHash.set(1, new DoublyLinkedList()); - this.freqHash.get(1).insertHead(newNode); - - this.nodeHash.set(key, newNode); - this.leastFreq = 1; - } else { - // existed node - node.val = value; - this.freqHash.get(node.freq).removeNode(node); - if (node.freq == this.leastFreq && this.freqHash.get(node.freq).isEmpty()) - this.leastFreq++; - node.freq++; - // freqHash housekeeping - if (this.freqHash.get(node.freq) == null) - this.freqHash.set(node.freq, new DoublyLinkedList()); - this.freqHash.get(node.freq).insertHead(node); - } + if (this.capacity == 0) return; + let node = this.nodeHash.get(key); + if (!node) { + // new node + this.currentSize++; + if (this.currentSize > this.capacity) { + let tailKey = this.freqHash.get(this.leastFreq).removeTail(); + this.nodeHash.delete(tailKey); + this.currentSize--; + } + let newNode = new Node(key, value); + // freqHash housekeeping + if (this.freqHash.get(1) == null) + this.freqHash.set(1, new DoublyLinkedList()); + this.freqHash.get(1).insertHead(newNode); + + this.nodeHash.set(key, newNode); + this.leastFreq = 1; + } else { + // existed node + node.val = value; + this.freqHash.get(node.freq).removeNode(node); + if (node.freq == this.leastFreq && this.freqHash.get(node.freq).isEmpty()) + this.leastFreq++; + node.freq++; + // freqHash housekeeping + if (this.freqHash.get(node.freq) == null) + this.freqHash.set(node.freq, new DoublyLinkedList()); + this.freqHash.get(node.freq).insertHead(node); + } }; LFUCache.prototype.read = async function (queryStr) { - 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 - if (!queries) return undefined; - const responseObject = {}; - // iterate through each query in the input queries object - for (const query in queries) { - // get the entire str query from the name input query and arguments - const queryHash = queries[query].name.concat(queries[query].arguments); - const rootQuery = this.ROOT_QUERY; - // match in ROOT_QUERY - if (rootQuery[queryHash]) { - // get the hashs to populate from the existent query in the cache - const arrayHashes = rootQuery[queryHash]; - // Determines responseObject property labels - use alias if applicable, otherwise use name - const respObjProp = queries[query].alias ?? queries[query].name; - // invoke populateAllHashes and add data objects to the response object for each input query - responseObject[respObjProp] = await this.populateAllHashes( - arrayHashes, - queries[query].fields - ); - if (!responseObject[respObjProp]) return undefined; - - // no match with ROOT_QUERY return null or ... - } else { - return undefined; - } - } - return { data: responseObject }; + 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 + if (!queries) return undefined; + const responseObject = {}; + // iterate through each query in the input queries object + for (const query in queries) { + // get the entire str query from the name input query and arguments + const queryHash = queries[query].name.concat(queries[query].arguments); + const rootQuery = this.ROOT_QUERY; + // match in ROOT_QUERY + if (rootQuery[queryHash]) { + // get the hashs to populate from the existent query in the cache + const arrayHashes = rootQuery[queryHash]; + // Determines responseObject property labels - use alias if applicable, otherwise use name + const respObjProp = queries[query].alias ?? queries[query].name; + // invoke populateAllHashes and add data objects to the response object for each input query + responseObject[respObjProp] = await this.populateAllHashes( + arrayHashes, + queries[query].fields + ); + + if (!responseObject[respObjProp]) return undefined; + + // no match with ROOT_QUERY return null or ... + } else { + return undefined; + } + } + return { data: responseObject }; }; LFUCache.prototype.write = async function (queryStr, respObj, deleteFlag) { - const queryObj = destructureQueries(queryStr); - const resFromNormalize = normalizeResult(queryObj, respObj, deleteFlag); - // update the original cache with same reference - for (const hash in resFromNormalize) { - const resp = await this.get(hash); - if (hash === 'ROOT_QUERY' || hash === 'ROOT_MUTATION') { - this[hash] = Object.assign(this[hash], resFromNormalize[hash]); - } else if (resFromNormalize[hash] === 'DELETED') { - // Should we delete directly or do we still need to flag as DELETED - await this.put(hash, 'DELETED'); - } else if (resp) { - const newObj = Object.assign(resp, resFromNormalize[hash]); - await this.put(hash, newObj); - } else { - await this.put(hash, resFromNormalize[hash]); - } - } + const queryObj = destructureQueries(queryStr); + const resFromNormalize = normalizeResult(queryObj, respObj, deleteFlag); + // update the original cache with same reference + for (const hash in resFromNormalize) { + const resp = await this.get(hash); + if (hash === 'ROOT_QUERY' || hash === 'ROOT_MUTATION') { + this[hash] = Object.assign(this[hash], resFromNormalize[hash]); + } else if (resFromNormalize[hash] === 'DELETED') { + // Should we delete directly or do we still need to flag as DELETED + await this.put(hash, 'DELETED'); + } else if (resp) { + const newObj = Object.assign(resp, resFromNormalize[hash]); + await this.put(hash, newObj); + } else { + await this.put(hash, resFromNormalize[hash]); + } + } }; LFUCache.prototype.cacheDelete = async function (hash) { - let node = this.nodeHash.get(hash); - this.freqHash.get(node.freq).removeNode(node); - this.nodeHash.delete(hash); + let node = this.nodeHash.get(hash); + this.freqHash.get(node.freq).removeNode(node); + this.nodeHash.delete(hash); }; LFUCache.prototype.cacheClear = async function () { - this.currentSize = 0; - this.leastFreq = 0; - this.ROOT_QUERY = {}; - this.ROOT_MUTATION = {}; - this.nodeHash = new Map(); - this.freqHash = new Map(); + this.currentSize = 0; + this.leastFreq = 0; + this.ROOT_QUERY = {}; + this.ROOT_MUTATION = {}; + this.nodeHash = new Map(); + this.freqHash = new Map(); }; LFUCache.prototype.writeWholeQuery = function (queryStr, respObj) { - const hash = queryStr.replace(/\s/g, ''); - this.put(this.ROOT_QUERY[hash], respObj); - return respObj; + const hash = queryStr.replace(/\s/g, ''); + this.put(this.ROOT_QUERY[hash], respObj); + return respObj; }; LFUCache.prototype.readWholeQuery = function (queryStr) { - const hash = queryStr.replace(/\s/g, ''); - if (this.ROOT_QUERY[hash]) return this.get(this.ROOT_QUERY[hash]); - return undefined; + const hash = queryStr.replace(/\s/g, ''); + if (this.ROOT_QUERY[hash]) return this.get(this.ROOT_QUERY[hash]); + return undefined; }; LFUCache.prototype.populateAllHashes = async function ( - allHashesFromQuery, - fields + allHashesFromQuery, + fields ) { - // include the hashname for each hash - if (!allHashesFromQuery.length) return []; - const hyphenIdx = allHashesFromQuery[0].indexOf('~'); - const typeName = allHashesFromQuery[0].slice(0, hyphenIdx); - return allHashesFromQuery.reduce(async (acc, hash) => { - // for each hash from the input query, build the response object - const readVal = await this.get(hash); - if (readVal === 'DELETED') return acc; - if (!readVal) return undefined; - const dataObj = {}; - for (const field in fields) { - if (readVal[field] === 'DELETED') continue; - // for each field in the fields input query, add the corresponding value from the cache if the field is not another array of hashs - if (readVal[field] === undefined && field !== '__typename') { - return undefined; - } - if (typeof fields[field] !== 'object') { - // add the typename for the type - if (field === '__typename') { - dataObj[field] = typeName; - } else dataObj[field] = readVal[field]; - } else { - // 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] - ); - if (dataObj[field] === undefined) return undefined; - } - } - // acc is an array of response object for each hash - const resolvedProm = await Promise.resolve(acc); - resolvedProm.push(dataObj); - return resolvedProm; - }, []); + if (!allHashesFromQuery.length) return []; + const hyphenIdx = allHashesFromQuery[0].indexOf('~'); + const typeName = allHashesFromQuery[0].slice(0, hyphenIdx); + return allHashesFromQuery.reduce(async (acc, hash) => { + // for each hash from the input query, build the response object + const readVal = await this.get(hash); + if (readVal === 'DELETED') return acc; + if (!readVal) return undefined; + const dataObj = {}; + for (const field in fields) { + if (readVal[field] === 'DELETED') continue; + // for each field in the fields input query, add the corresponding value from the cache if the field is not another array of hashs + if (readVal[field] === undefined && field !== '__typename') { + return undefined; + } + if (typeof fields[field] !== 'object') { + // add the typename for the type + if (field === '__typename') { + dataObj[field] = typeName; + } else dataObj[field] = readVal[field]; + } else { + // 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] + ); + if (dataObj[field] === undefined) return undefined; + } + } + // acc is an array of response object for each hash + const resolvedProm = await Promise.resolve(acc); + resolvedProm.push(dataObj); + return resolvedProm; + }, []); }; diff --git a/src/normalize.js b/src/normalize.js index d27419d..0fafc37 100644 --- a/src/normalize.js +++ b/src/normalize.js @@ -80,6 +80,7 @@ function createRootQuery(queryObjArr, resultObj) { const args = query.arguments; const queryHash = name + args; const result = resultObj.data[alias] ?? resultObj.data[name]; + // iterate thru the array of current query response // and store the hash of that response in an array diff --git a/src/obsidian.ts b/src/obsidian.ts index 9ee6a83..d9d491c 100644 --- a/src/obsidian.ts +++ b/src/obsidian.ts @@ -3,6 +3,7 @@ import { renderPlaygroundPage } from 'https://deno.land/x/oak_graphql@0.6.2/grap import { makeExecutableSchema } from 'https://deno.land/x/oak_graphql@0.6.2/graphql-tools/schema/makeExecutableSchema.ts'; import LFUCache from './lfuBrowserCache.js'; import { Cache } from './CacheClassServer.js'; +import queryDepthLimiter from './DoSSecurity.ts'; interface Constructable { new (...args: any): T & OakRouter; @@ -25,6 +26,7 @@ export interface ObsidianRouterOptions { redisPort?: number; policy?: string; maxmemory?: string; + maxQueryDepth?: number; } export interface ResolversProps { @@ -47,6 +49,7 @@ export async function ObsidianRouter({ redisPort = 6379, policy, maxmemory, + maxQueryDepth = 0, }: ObsidianRouterOptions): Promise { redisPortExport = redisPort; const router = new Router(); @@ -65,25 +68,32 @@ export async function ObsidianRouter({ // set redis configurations if (policy || maxmemory) { - console.log('inside if'); cache.configSet('maxmemory-policy', policy); cache.configSet('maxmemory', maxmemory); } await router.post(path, async (ctx: any) => { var t0 = performance.now(); + const { response, request } = ctx; + if (request.hasBody) { try { const contextResult = context ? await context(ctx) : undefined; const body = await request.body().value; + + // If a securty limit is set for maxQueryDepth, invoke queryDepthLimiter + // which throws error if query depth exceeds maximum + if (maxQueryDepth) queryDepthLimiter(body.query, maxQueryDepth); + // Variable to block the normalization of mutations // let toNormalize = true; if (useCache) { // Send query off to be destructured and found in Redis if possible // - const obsidianReturn = await cache.read(body.query); - console.log('retrieved from cache', obsidianReturn); + const obsidianReturn = await cache.read(body.query, body.variables); + console.log('Retrieved from cache: \n\t', obsidianReturn); + if (obsidianReturn) { response.status = 200; response.body = obsidianReturn; @@ -112,8 +122,9 @@ export async function ObsidianRouter({ response.body = result; // Normalize response and store in cache // - if (useCache && toNormalize && !result.errors) - cache.write(body.query, result, false); + if (useCache && toNormalize && !result.errors) { + cache.write(body.query, result, false, body.variables); + } var t1 = performance.now(); console.log( 'Obsidian received new data and took ' + (t1 - t0) + ' milliseconds.' @@ -129,6 +140,7 @@ export async function ObsidianRouter({ }, ], }; + console.error('Error: ', error.message); return; } } diff --git a/test_files/rhum_test_files/DoSSecurity_test.ts b/test_files/rhum_test_files/DoSSecurity_test.ts new file mode 100644 index 0000000..0ad62f7 --- /dev/null +++ b/test_files/rhum_test_files/DoSSecurity_test.ts @@ -0,0 +1,72 @@ +import { Rhum } from 'https://deno.land/x/rhum@v1.1.4/mod.ts'; +import queryDepthLimiter from '../../src/DoSSecurity.ts'; +import { test } from '../test_variables/DoSSecurity_variables.ts'; + + +Rhum.testPlan('DoSSecurity.ts', () => { + Rhum.testSuite('Query depth limit NOT exceeded tests', () => { + Rhum.testCase('Test query depth of 2 does not exceed allowable depth 2', () => { + const results = queryDepthLimiter(test.DEPTH_2_QUERY, 2); + Rhum.asserts.assertEquals(undefined, results); + }); + Rhum.testCase('Test mutation depth of 2 does not exceed allowable depth of 2', () => { + const results = queryDepthLimiter(test.DEPTH_2_MUTATION, 2); + Rhum.asserts.assertEquals(undefined, results); + }); + }); + + Rhum.testSuite('Query/mutation depth limit IS EXCEEDED tests', () => { + Rhum.testCase('Test query depth 2 should exceed depth limit of 1', () => { + Rhum.asserts.assertThrows( + () => { + queryDepthLimiter(test.DEPTH_2_QUERY, 1) + }, + Error, + "Security Error: Query depth exceeded maximum query depth limit", + ) + }); + Rhum.testCase('Test mutation depth 2 should exceed depth limit of 1', () => { + Rhum.asserts.assertThrows( + () => { + queryDepthLimiter(test.DEPTH_2_MUTATION, 1) + }, + Error, + "Security Error: Query depth exceeded maximum mutation depth limit", + ) + }); + }); + + Rhum.testSuite('Query depth limit NOT exceeded, multiple query tests', () => { + Rhum.testCase('Test multiple queries of depth 2 should not exceed allowable depth 2', () => { + const results = queryDepthLimiter(test.MULTIPLE_DEPTH_2_QUERY, 2); + Rhum.asserts.assertEquals(undefined, results); + }); + Rhum.testCase('Test multiple mutations of depth 2 should not exceed allowable depth 2', () => { + const results = queryDepthLimiter(test.MULTIPLE_DEPTH_2_MUTATION, 2); + Rhum.asserts.assertEquals(undefined, results); + }); + }); + + Rhum.testSuite('Multiple query/mutation depth limit IS EXCEEDED tests', () => { + Rhum.testCase('Test multiple query depth should be exceeded', () => { + Rhum.asserts.assertThrows( + () => { + queryDepthLimiter(test.MULTIPLE_DEPTH_2_QUERY, 1) + }, + Error, + "Security Error: Query depth exceeded maximum query depth limit", + ) + }); + Rhum.testCase('Test multiple mutation depth should be exceeded', () => { + Rhum.asserts.assertThrows( + () => { + queryDepthLimiter(test.MULTIPLE_DEPTH_2_MUTATION, 1) + }, + Error, + "Security Error: Query depth exceeded maximum mutation depth limit", + ) + }); + }); +}); + +Rhum.run(); \ No newline at end of file diff --git a/test_files/rhum_test_files/destructure_test.ts b/test_files/rhum_test_files/destructure_test.ts index 8dbc076..d2f0d8c 100644 --- a/test_files/rhum_test_files/destructure_test.ts +++ b/test_files/rhum_test_files/destructure_test.ts @@ -1,6 +1,5 @@ import { Rhum } from 'https://deno.land/x/rhum@v1.1.4/mod.ts'; -import { - destructureQueries, +import destructureQueries, { findQueryStrings, createQueriesObj, splitUpQueryStr, @@ -74,6 +73,76 @@ Rhum.testPlan('destructure.ts', () => { Rhum.asserts.assertEquals(test.fragmentResultData3, result); }); }); + + // single variable test + Rhum.testSuite('destructure single variable query tests', () => { + Rhum.testCase('destructure single variable query string', () => { + const result = destructureQueries( + test.singleVariableTestData, + test.singleVariableTestValue + ); + Rhum.asserts.assertEquals(test.singleVariableTestResult, result); + }); + }); + + // multi variable test + Rhum.testSuite('destructure multi variable query tests', () => { + Rhum.testCase('destructure multi variable query', () => { + const result = destructureQueries( + test.multiVariableTestData, + test.multiVariableTestValue + ); + Rhum.asserts.assertEquals(test.multiVariableTestResult, result); + }); + }); + + // single directive test - @include: true + Rhum.testSuite('destructure @include directive query tests', () => { + Rhum.testCase('destructure @include directive (true) query', () => { + const result = destructureQueries( + test.includeDirectiveTestData, + test.includeDirectiveTrueValues + ); + Rhum.asserts.assertEquals(test.includeDirectiveTrueResult, result); + }); + }); + + // single directive test - @include: false + Rhum.testSuite('destructure @include directive query tests', () => { + Rhum.testCase('destructure @include directive (false) query', () => { + const result = destructureQueries( + test.includeDirectiveTestData, + test.includeDirectiveFalseValues + ); + + Rhum.asserts.assertEquals(test.includeDirectiveFalseResult, result); + }); + }); +}); + +// single directive test - @skip: true +Rhum.testSuite('destructure @skip directive query tests', () => { + Rhum.testCase('destructure @skip directive (true) query', () => { + const result = destructureQueries( + test.skipDirectiveTestData, + test.skipDirectiveTrueValues + ); + Rhum.asserts.assertEquals(test.skipDirectiveTrueResult, result); + }); }); +// single directive test - @skip: false +Rhum.testSuite('destructure @skip directive query tests', () => { + Rhum.testCase('destructure @skip directive (false) query', () => { + const result = destructureQueries( + test.skipDirectiveTestData, + test.skipDirectiveFalseValues + ); + + Rhum.asserts.assertEquals(test.skipDirectiveFalseResult, result); + }); +}); + +// TO-DO: queries with multiple directives (not just one @include/@skip) + Rhum.run(); diff --git a/test_files/test_variables/DoSSecurity_variables.ts b/test_files/test_variables/DoSSecurity_variables.ts new file mode 100644 index 0000000..6c9dc3b --- /dev/null +++ b/test_files/test_variables/DoSSecurity_variables.ts @@ -0,0 +1,94 @@ +export const test = { + DEPTH_2_QUERY: ` + query AllActionMovies { + movies(input: { genre: ACTION }) { + __typename + id + title + genre + actors { + id + firstName + lastName + } + } + }`, + + DEPTH_2_MUTATION: ` + mutation AllActionMoviesAndAllActors { + movies(input: { genre: ACTION }) { + id + title + genre + actors { + id + firstName + lastName + } + } + }`, + + MULTIPLE_DEPTH_2_QUERY: ` + query AllActionMovies { + movies(input: { genre: ACTION }) { + __typename + id + title + genre + }, + movies(input: { genre: ACTION }) { + __typename + id + title + genre + actors { + id + firstName + lastName + } + }, + movies(input: { genre: ACTION }) { + __typename + id + title + genre + actors { + id + firstName + lastName + } + } + }`, + + MULTIPLE_DEPTH_2_MUTATION: ` + mutation AllActionMovies { + movies(input: { genre: ACTION }) { + __typename + id + title + genre + }, + movies(input: { genre: ACTION }) { + __typename + id + title + genre + actors { + id + firstName + lastName + } + }, + movies(input: { genre: ACTION }) { + __typename + id + title + genre + actors { + id + firstName + lastName + } + } + }`, +}; \ No newline at end of file diff --git a/test_files/test_variables/destructure_variables.ts b/test_files/test_variables/destructure_variables.ts index 463eedb..7d6ebce 100644 --- a/test_files/test_variables/destructure_variables.ts +++ b/test_files/test_variables/destructure_variables.ts @@ -224,23 +224,30 @@ export const test = { harrisonActor: actors(id: 00) { firstName lastName`, - findTwoActorsAliasTestResult: { queries: [ { - name: "actors", - alias: "harrisonActor", - arguments: "(id:00)", - fields: { firstName: "scalar", lastName: "scalar", films: { __typename: 'meta', id: 'scalar', title: 'scalar' } } + name: 'actors', + alias: 'harrisonActor', + arguments: '(id:00)', + fields: { + firstName: 'scalar', + lastName: 'scalar', + films: { __typename: 'meta', id: 'scalar', title: 'scalar' }, + }, }, { - name: "actors", - alias: "hammelActor", - arguments: "(id:01)", - fields: { firstName: "scalar", lastName: "scalar", films: { __typename: 'meta', id: 'scalar', title: 'scalar' } } - } - ] + name: 'actors', + alias: 'hammelActor', + arguments: '(id:01)', + fields: { + firstName: 'scalar', + lastName: 'scalar', + films: { __typename: 'meta', id: 'scalar', title: 'scalar' }, + }, + }, + ], }, newAliasTestQuery: ` @@ -253,24 +260,24 @@ export const test = { } }`, - newAliasTestResult:{ + newAliasTestResult: { queries: [ { - name: "hero", - alias: "empireHero", - arguments: "(episode:EMPIRE)", - fields: { name: "scalar" } + name: 'hero', + alias: 'empireHero', + arguments: '(episode:EMPIRE)', + fields: { name: 'scalar' }, }, { - name: "hero", - alias: "jediHero", - arguments: "(episode:JEDI)", - fields: { name: "scalar" } - } - ] - }, + name: 'hero', + alias: 'jediHero', + arguments: '(episode:JEDI)', + fields: { name: 'scalar' }, + }, + ], + }, -fragmentTestData: `query { + fragmentTestData: `query { movies(input: { genre: ACTION }) { __typename id @@ -293,21 +300,31 @@ fragmentTestData: `query { fragment firstAndLast on Actors { firstName lastName - }` , + }`, - fragmentResultData: { + fragmentResultData: { queries: [ { - name: "movies", - arguments: "(input:{genre:ACTION})", - fields: { __typename: "meta", id: "scalar", title: "scalar", genre: "scalar" } + name: 'movies', + arguments: '(input:{genre:ACTION})', + fields: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + genre: 'scalar', + }, }, { - name: "actors", - arguments: "", - fields: { id: "scalar", films: { __typename: 'meta', id: 'scalar', title: 'scalar' }, firstName: "scalar", lastName: "scalar" } - } - ] + name: 'actors', + arguments: '', + fields: { + id: 'scalar', + films: { __typename: 'meta', id: 'scalar', title: 'scalar' }, + firstName: 'scalar', + lastName: 'scalar', + }, + }, + ], }, fragmentTestData2: `query { @@ -334,24 +351,30 @@ fragmentTestData: `query { fragment firstAndLast on Actors { firstName lastName - }` , + }`, - fragmentResultData2: { + fragmentResultData2: { queries: [ { - name: "movies", - arguments: "(input:{genre:ACTION})", - fields: { __typename: "meta", id: "scalar", title: "scalar", genre: "scalar" , actors:{ - id: "scalar", - films: { __typename: "meta", id: "scalar", title: "scalar" }, - firstName: "scalar", - lastName: "scalar" - }} - }, - ] + name: 'movies', + arguments: '(input:{genre:ACTION})', + fields: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + genre: 'scalar', + actors: { + id: 'scalar', + films: { __typename: 'meta', id: 'scalar', title: 'scalar' }, + firstName: 'scalar', + lastName: 'scalar', + }, + }, + }, + ], }, - fragmentTestData3:` + fragmentTestData3: ` query AllActionMovies { movies(input: { genre: ACTION }) { __typename @@ -371,7 +394,7 @@ fragmentTestData: `query { firstName lastName }`, - + fragmentResultData3: { queries: [ { @@ -390,7 +413,312 @@ fragmentTestData: `query { }, }, ], + }, + + singleVariableTestData: ` + query AllActionMoviesAndAllActors ($movieGenre: String) { + movies(genre: $movieGenre) { + __typename + id + title + genre + actors { + id + firstName + lastName + } + } + actors { + id + firstName + lastName + films { + __typename + id + title + } + } } -}; + } + `, + + singleVariableTestResult: { + queries: [ + { + name: 'movies', + arguments: '(genre:ACTION)', + fields: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + genre: 'scalar', + actors: { + id: 'scalar', + firstName: 'scalar', + lastName: 'scalar', + }, + }, + }, + { + name: 'actors', + arguments: '', + fields: { + id: 'scalar', + firstName: 'scalar', + lastName: 'scalar', + films: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + }, + }, + }, + ], + }, + singleVariableTestValue: { + movieGenre: 'ACTION', + }, + multiVariableTestData: ` + query AllActionMoviesAndAllActors ($movieGenre: String, $actorID: ID) { + movies(genre: $movieGenre) { + __typename + id + title + genre + actors { + id + firstName + lastName + } + } + actors (actor: $actorID) { + id + firstName + lastName + films { + __typename + id + title + } + } + } + } + `, + + multiVariableTestResult: { + queries: [ + { + name: 'movies', + arguments: '(genre:ACTION)', + fields: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + genre: 'scalar', + actors: { + id: 'scalar', + firstName: 'scalar', + lastName: 'scalar', + }, + }, + }, + { + name: 'actors', + arguments: '(actor:1)', + fields: { + id: 'scalar', + firstName: 'scalar', + lastName: 'scalar', + films: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + }, + }, + }, + ], + }, + + multiVariableTestValue: { + movieGenre: 'ACTION', + actorID: '1', + }, + + includeDirectiveTestData: `query AllActionMoviesAndAllActors ($movieGenre: String, $withActors: Boolean!) { + movies(genre: $movieGenre) { + __typename + id + title + genre + actors { + id + firstName + lastName + } + } + actors @include (if: $withActors) { + id + firstName + lastName + films { + __typename + id + title + } + } + }`, + + includeDirectiveFalseResult: { + queries: [ + { + name: 'movies', + arguments: '(genre:ACTION)', + fields: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + genre: 'scalar', + actors: { + id: 'scalar', + firstName: 'scalar', + lastName: 'scalar', + }, + }, + }, + ], + }, + + includeDirectiveFalseValues: { + movieGenre: 'ACTION', + withActors: false, + }, + + includeDirectiveTrueResult: { + queries: [ + { + name: 'movies', + arguments: '(genre:ACTION)', + fields: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + genre: 'scalar', + actors: { + id: 'scalar', + firstName: 'scalar', + lastName: 'scalar', + }, + }, + }, + { + name: 'actors', + arguments: '', + fields: { + id: 'scalar', + firstName: 'scalar', + lastName: 'scalar', + films: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + }, + }, + }, + ], + }, + + includeDirectiveTrueValues: { + movieGenre: 'ACTION', + withActors: true, + }, + + skipDirectiveTestData: `query AllActionMoviesAndAllActors ($movieGenre: String, $withActors: Boolean!) { + movies(genre: $movieGenre) { + __typename + id + title + genre + actors { + id + firstName + lastName + } + } + actors @skip (if: $withActors) { + id + firstName + lastName + films { + __typename + id + title + } + } +}`, + + skipDirectiveTrueResult: { + queries: [ + { + name: 'movies', + arguments: '(genre:ACTION)', + fields: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + genre: 'scalar', + actors: { + id: 'scalar', + firstName: 'scalar', + lastName: 'scalar', + }, + }, + }, + ], + }, + + skipDirectiveTrueValues: { + movieGenre: 'ACTION', + withActors: true, + }, + + skipDirectiveFalseResult: { + queries: [ + { + name: 'movies', + arguments: '(genre:ACTION)', + fields: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + genre: 'scalar', + actors: { + id: 'scalar', + firstName: 'scalar', + lastName: 'scalar', + }, + }, + }, + { + name: 'actors', + arguments: '', + fields: { + id: 'scalar', + firstName: 'scalar', + lastName: 'scalar', + films: { + __typename: 'meta', + id: 'scalar', + title: 'scalar', + }, + }, + }, + ], + }, + + skipDirectiveFalseValues: { + movieGenre: 'ACTION', + withActors: false, + }, +}; diff --git a/test_files/test_variables/writeCache_variables.ts b/test_files/test_variables/writeCache_variables.ts index 880cc60..f677713 100644 --- a/test_files/test_variables/writeCache_variables.ts +++ b/test_files/test_variables/writeCache_variables.ts @@ -203,19 +203,18 @@ export const test = { } } `, - aliasResponse: - { - "data": { - "jediHero": { - "__typename": "Hero", - "id": 2, - "name": "R2-D2", - }, - "empireHero": { - "__typename": "Hero", - "id": 1, - "name": "Luke Skywalker", - } - } -}, + aliasResponse: { + data: { + jediHero: { + __typename: 'Hero', + id: 2, + name: 'R2-D2', + }, + empireHero: { + __typename: 'Hero', + id: 1, + name: 'Luke Skywalker', + }, + }, + }, }; diff --git a/tsconfig.json b/tsconfig.json index c6ae093..f995a9b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,18 @@ { + "compileOnSave": true, "compilerOptions": { "allowJs": true, "target": "ES2017", "jsx": "react", "noImplicitAny": false, - "module": "esnext", + "module": "CommonJS", "strict": true, + "lib": ["dom"] }, - "include":["../src"] + "include": ["./**/*"], + "exclude": [ + "./plugins/**/*", + "./typings/**/*", + "./built/**/*" // This is what fixed it! + ] } - -