UNPKG

@quell/server

Version:

Quell is an open-source NPM package providing a light-weight caching layer implementation and cache invalidation for GraphQL responses on both the client- and server-side. Use Quell to prevent redundant client-side API requests and to minimize costly serv

451 lines (450 loc) 25.7 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.QuellCache = void 0; const parser_1 = require("graphql/language/parser"); const redisConnection_1 = require("./helpers/redisConnection"); const graphql_1 = require("graphql"); const cacheUtils_1 = require("./helpers/cacheUtils"); // import middleware functions const rateLimiter_1 = require("./middleware/rateLimiter"); const costLimit_1 = require("./middleware/costLimit"); const depthLimit_1 = require("./middleware/depthLimit"); // Import cache operations const readCache_1 = require("./cacheOperations/readCache"); const writeCache_1 = require("./cacheOperations/writeCache"); const normalizeCache_1 = require("./cacheOperations/normalizeCache"); const updateCache_1 = require("./cacheOperations/updateCache"); const invalidateCache_1 = require("./cacheOperations/invalidateCache"); const buildCache_1 = require("./cacheOperations/buildCache"); // Import helper functions const quellHelpers_1 = require("./helpers/quellHelpers"); const schemaHelpers_1 = require("./helpers/schemaHelpers"); /* * Note: This file is identical to the main quell-server file, except that the * rateLimiter, depthLimit, and costLimit have been modified to allow the limits * to be set in the request body to allow for demoing these features. */ const defaultCostParams = { maxCost: 5000, mutationCost: 5, objectCost: 2, scalarCost: 1, depthCostFactor: 1.5, maxDepth: 10, ipRate: 3, // requests allowed per second }; let idCache = {}; /** * Creates a QuellCache instance that provides middleware for caching between the graphQL endpoint and * front-end requests, connects to redis cloud store via user-specified parameters. * - If there is no cache expiration provided by the user, cacheExpiration defaults to 14 days in seconds. * - If there are no cost parameters provided by the user, costParameters is given the default values. * - If redisPort, redisHost, and redisPassword are omitted, will use local Redis instance. * See https://redis.io/docs/getting-started/installation/ for instructions on installing Redis and starting a Redis server. * @param {ConstructorOptions} options - The options to use for the cache. * @param {GraphQLSchema} options.schema - GraphQL defined schema that is used to facilitate caching by providing valid queries, * mutations, and fields. * @param {number} [options.cacheExpiration=1209600] - Time in seconds for redis values to be evicted from the cache. Defaults to 14 days. * @param {CostParamsType} [options.costParameters=defaultCostParams] - The cost parameters to use for caching. Defaults to: * - maxCost: 5000 (maximum cost allowed before a request is rejected) * - mutationCost: 5 (cost of a mutation) * - objectCost: 2 (cost of retrieving an object) * - scalarCost: 1 (cost of retrieving a scalar) * - depthCostFactor: 1.5 (multiplicative cost of each depth level) * - maxDepth: 10 (depth limit parameter) * - ipRate: 3 (requests allowed per second) * @param {number} options.redisPort - (optional) The Redis port to connect to. * @param {string} options.redisHost - (optional) The Redis host URI to connect to. * @param {string} options.redisPassword - (optional) The Redis password to the host URI. * @example // Omit redisPort, redisHost, and redisPassword to use a local Redis instance. * const quellCache = new QuellCache({ * schema: schema, * cacheExpiration: 3600, // 1 hour in seconds * }); */ class QuellCache { constructor({ schema, cacheExpiration = 1209600, // Default expiry time is 14 days in seconds costParameters = defaultCostParams, redisPort, redisHost, redisPassword, }) { // Convert schema to a standardized format const standardizedSchema = (0, schemaHelpers_1.anySchemaToQuellSchema)(schema); console.log("+++++++++++++++++++"); // Initialize schema-related properties this.schema = schema; this.queryMap = (0, schemaHelpers_1.getQueryMap)(standardizedSchema); this.mutationMap = (0, schemaHelpers_1.getMutationMap)(standardizedSchema); this.fieldsMap = (0, schemaHelpers_1.getFieldsMap)(standardizedSchema); // Initialize cache properties this.idCache = idCache; this.redisCache = redisConnection_1.redisCacheMain; //connect to Redis if (!this.redisCache.isOpen) { this.redisCache.connect().catch((err) => { console.error('Redis connection error in QuellCache constructor:', err); }); } this.redisReadBatchSize = 10; this.cacheExpiration = cacheExpiration; // Initialize cost parameters this.costParameters = Object.assign(defaultCostParams, costParameters); // Initialize middleware this.costLimit = (0, costLimit_1.createCostLimit)({ costParameters: this.costParameters, schema: this.schema, // if needed }); this.depthLimit = (0, depthLimit_1.createDepthLimit)({ maxDepth: this.costParameters.maxDepth, }); this.rateLimiter = (0, rateLimiter_1.createRateLimiter)({ ipRate: this.costParameters.ipRate, redisCache: this.redisCache, }); // Initialize invalidation operations this.clearCache = (0, invalidateCache_1.createClearCache)({ redisCache: this.redisCache, idCache: this.idCache, }); this.deleteCacheById = (0, invalidateCache_1.createDeleteCacheById)({ redisCache: this.redisCache, }); this.clearAllCaches = (0, invalidateCache_1.createClearAllCaches)({ redisCache: this.redisCache, idCache: this.idCache, }); // Initialize read operations this.generateCacheID = (0, readCache_1.createGenerateCacheID)(); this.buildFromCache = (0, readCache_1.createBuildFromCache)({ redisCache: this.redisCache, redisReadBatchSize: this.redisReadBatchSize, idCache: this.idCache, generateCacheID: this.generateCacheID, }); // Initialize write operations this.updateIdCache = (0, writeCache_1.createUpdateIdCache)({ redisCache: this.redisCache, cacheExpiration: this.cacheExpiration, idCache: this.idCache, }); this.writeToCache = (0, writeCache_1.createWriteToCache)({ redisCache: this.redisCache, cacheExpiration: this.cacheExpiration, idCache: this.idCache, }); this.normalizeForCache = (0, normalizeCache_1.createNormalizeForCache)({ writeToCache: this.writeToCache, updateIdCache: this.updateIdCache, }); // Initialize update operations this.updateCacheByMutation = (0, updateCache_1.createUpdateCacheByMutation)({ redisCache: this.redisCache, queryMap: this.queryMap, writeToCache: this.writeToCache, deleteCacheById: this.deleteCacheById, }); // Initialize build operations this.buildCacheFromResponse = (0, buildCache_1.createBuildCacheFromResponse)({ queryMap: this.queryMap, cacheExpiration: this.cacheExpiration, writeToCache: this.writeToCache, normalizeForCache: this.normalizeForCache, }); this.buildCacheFromMergedResponse = (0, buildCache_1.createBuildCacheFromMergedResponse)({ queryMap: this.queryMap, cacheExpiration: this.cacheExpiration, writeToCache: this.writeToCache, normalizeForCache: this.normalizeForCache, }); this.handleQueryCaching = (0, buildCache_1.createHandleQueryCaching)({ queryMap: this.queryMap, cacheExpiration: this.cacheExpiration, writeToCache: this.writeToCache, normalizeForCache: this.normalizeForCache, buildCacheFromResponse: this.buildCacheFromResponse, buildCacheFromMergedResponse: this.buildCacheFromMergedResponse, }); this.query = this.query.bind(this); } /** * The class's controller method. It: * - reads the query string from the request object, * - tries to construct a response from cache, * - reformulates a query for any data not in cache, * - passes the reformulated query to the graphql library to resolve, * - joins the cached and uncached responses, * - decomposes and caches the joined query, and * - attaches the joined response to the response object before passing control to the next middleware. * @param {Request} req - Express request object, including request body with GraphQL query string. * @param {Response} res - Express response object, will carry query response to next middleware. * @param {NextFunction} next - Express next middleware function, invoked when QuellCache completes its work. */ query(req, res, next) { var _a; return __awaiter(this, void 0, void 0, function* () { // console.log('***RUNNING QUERY***'); // Return an error if no query is found on the request. if (!req.body.query) { next((0, cacheUtils_1.createServerError)("Error: no GraphQL query found on request body, inside rateLimiter", 400, "Error in rateLimiter: Bad Request. Check server log for more details.")); return; } // Retrieve GraphQL query string from request body. const queryString = req.body.query; // console.log('+++QUELL+++ QUERY STRING:', queryString); // Create the abstract syntax tree with graphql-js parser. // If depth limit or cost limit were implemented, then we can get the AST and parsed AST from res.locals. const AST = res.locals.AST ? res.locals.AST : (0, parser_1.parse)(queryString); // console.log('+++QUELL+++ AST:', parse(queryString)) // Create response prototype, operation type, and fragments object. // The response prototype is used as a template for most operations in Quell including caching, building modified requests, and more. const { proto, operationType, frags } = (_a = res.locals.parsedAST) !== null && _a !== void 0 ? _a : (0, quellHelpers_1.parseAST)(AST); // console.log('+++QUELL+++ PARSED AST:', parseAST(AST)) // console.log('PROTO', proto) console.log("+++QUELL+++ OPERATION TYPE", operationType); // console.log('FRAGS', frags) // Determine if Quell is able to handle the operation. // Quell can handle mutations and queries. if (operationType === "unQuellable") { /* * If the operation is unQuellable (cannot be cached), execute the operation, * add the result to the response, and return. */ (0, graphql_1.graphql)({ schema: this.schema, source: queryString }) .then((queryResult) => { res.locals.queryResponse = queryResult; return next(); }) .catch((error) => { const err = { log: `Error inside catch block of operationType === unQuellable of query, ${error}`, status: 400, message: { err: `GraphQL query Error: Check server log for more details. Error: ${error}`, }, }; return next(err); }); } else if (operationType === "noID") { /* * If ID was not included in the query, it will not be included in the cache. Execute the GraphQL * operation without writing the result to cache and return. */ // FIXME: Can possibly modify query to ALWAYS have an ID but not necessarily return it back to client // unless they also queried for it. (0, graphql_1.graphql)({ schema: this.schema, source: queryString }) .then((queryResult) => { res.locals.queryResponse = queryResult; return next(); }) .catch((error) => { const err = { log: `Error inside catch block of operationType === noID of query, ${error}`, status: 400, message: { err: `GraphQL query Error: Check server log for more details. Error: ${error}`, }, }; return next(err); }); /* * The code from here to the end of the current if block was left over from a previous * implementation and is not currently being used. * For the previous implementation: if the ID was not included, used the cache result * if the query string was found in the Redis cache. Otherwise, used the result of * executing the operation and stored the result in cache. */ // Check Redis for the query string . // let redisValue: RedisValue = await getFromRedis( // queryString, // this.redisCache // ); // if (redisValue != null) { // // If the query string is found in Redis, add the result to the response and return. // redisValue = JSON.parse(redisValue); // res.locals.queryResponse = redisValue; // return next(); // } else { // // Execute the operation, add the result to the response, write the query string and result to cache, and return. // graphql({ schema: this.schema, source: queryString }) // .then((queryResult: ExecutionResult): void => { // res.locals.queryResponse = queryResult; // this.writeToCache(queryString, queryResult); // return next(); // }) // .catch((error: Error): void => { // const err: ServerErrorType = { // log: `Error inside catch block of operationType === noID of query, graphQL query failed, ${error}`, // status: 400, // message: { // err: `GraphQL query Error: Check server log for more details. Error: ${error}`, // }, // }; // return next(err); // }); // } } else if (operationType === "mutation") { // TODO: If the operation is a mutation, we are currently clearing the cache because it is stale. // The goal would be to instead have a normalized cache and update the cache following a mutation. this.redisCache.flushAll(); idCache = {}; // Execute the operation and add the result to the response. (0, graphql_1.graphql)({ schema: this.schema, source: queryString }) .then((databaseResponse) => { res.locals.queryResponse = databaseResponse; // Determine if the query string is a valid mutation in the schema. // Loop through the mutations in the mutationMap. for (const mutation in this.mutationMap) { // If any mutation from the mutationMap is found on the proto, the query string includes // a valid mutation. Update the mutation query object, name, type variables. Update the cache with the response. // We don't need to wait until writeToCache is finished. if (Object.prototype.hasOwnProperty.call(proto, mutation)) { const mutationName = mutation; const mutationType = this.mutationMap[mutation]; const mutationQueryObject = proto[mutation]; this.updateCacheByMutation(databaseResponse, mutationName, mutationType, mutationQueryObject); break; } } return next(); }) .catch((error) => { const err = { log: `Error inside catch block of operationType === mutation of query, ${error}`, status: 400, message: { err: "GraphQL query (mutation) Error: Check server log for more details.", }, }; return next(err); }); } else { /* * Otherwise, the operation type is a query. */ // Combine fragments on prototype so we can access fragment values in cache. const prototype = Object.keys(frags).length > 0 ? (0, quellHelpers_1.updateProtoWithFragment)(proto, frags) : proto; // Create a list of the keys on prototype that will be passed to buildFromCache. const prototypeKeys = Object.keys(prototype); // Check the cache for the requested values. // buildFromCache will modify the prototype to mark any values not found in the cache // so that they may later be retrieved from the database. // const cacheResponse: { // data: ItemFromCacheType; // cached?: boolean; // } = await this.buildFromCache(prototype, prototypeKeys); const cacheResponse = yield this.buildFromCache(prototype, prototypeKeys); // Create query object containing the fields that were not found in the cache. // This will be used to create a new GraphQL string. const queryObject = (0, quellHelpers_1.createQueryObj)(prototype); // If the cached response is incomplete, reformulate query, // handoff query, join responses, and cache joined responses. if (Object.keys(queryObject).length > 0) { // Create a new query string that contains only the fields not found in the cache so that we can // request only that information from the database. console.log("QUERY OBJECT BEFORE createQueryStr:", JSON.stringify(queryObject, null, 2)); const newQueryString = (0, quellHelpers_1.createQueryStr)(queryObject, operationType); console.log("NEW QUERY STRING:", newQueryString); // Execute the query using the new query string. (0, graphql_1.graphql)({ schema: this.schema, source: newQueryString }) .then((databaseResponseRaw) => __awaiter(this, void 0, void 0, function* () { // The GraphQL must be parsed in order to join with it with the data retrieved from // the cache before sending back to user. const databaseResponse = JSON.parse(JSON.stringify(databaseResponseRaw)); console.log("DATABASE RESPONSE RAW:", JSON.stringify(databaseResponseRaw)); console.log("DATABASE RESPONSE PARSED:", JSON.stringify(databaseResponse)); // Check cache data console.log("CACHE RESPONSE DATA:", JSON.stringify(cacheResponse.data)); // Determine cache hit status let cacheHitStatus = "none"; // Check if cache has any data let cacheHasData = false; for (const key in cacheResponse.data) { if (Object.keys(cacheResponse.data[key]).length > 0) { cacheHasData = true; break; } } // Check if query needs any database data const needsDatabaseData = Object.keys(queryObject).length > 0; if (!needsDatabaseData) { // Everything found in cache cacheHitStatus = "full"; } else if (cacheHasData) { // Some data from cache, some from database cacheHitStatus = "partial"; } else { // No data from cache, everything from database cacheHitStatus = "none"; } console.log("CACHE HAS DATA:", cacheHasData); console.log("NEEDS DATABASE DATA:", needsDatabaseData); console.log("CACHE HIT STATUS:", cacheHitStatus); // Create merged response object to merge the data from the cache and the data from the database. // If the cache response does not have data then just use the database response. const mergedResponse = cacheHasData ? (0, quellHelpers_1.joinResponses)(cacheResponse.data, databaseResponse.data, prototype) : databaseResponse; console.log("=== ABOUT TO CACHE ==="); console.log("Merged Response:", JSON.stringify(mergedResponse, null, 2)); console.log("Prototype:", JSON.stringify(prototype, null, 2)); // CACHE THE COMPLETE MERGED RESPONSE if (cacheHitStatus === "partial" || cacheHitStatus === "none") { // Determine the correct data to cache based on the response structure const dataToCache = cacheHitStatus === "none" ? mergedResponse.data // Database response has .data wrapper : mergedResponse; // Joined response is already unwrapped yield this.normalizeForCache(dataToCache, this.queryMap, prototype, "root"); } console.log("=== FINISHED CACHING ==="); // const currName = "string it should not be again"; // const test = await this.normalizeForCache( // mergedResponse.data as ResponseDataType, // this.queryMap, // prototype, // currName // ); // The response is given a cached key equal to false to indicate to the front end of the demo site that the // information was *NOT* entirely found in the cache. mergedResponse.cacheHit = cacheHitStatus; res.locals.queryResponse = Object.assign({}, mergedResponse); return next(); })) .catch((error) => { const err = { log: `Error inside catch block of operationType === query of query, ${error}`, status: 400, message: { err: `GraphQL query Error: Check server log for more details. Error: ${error}`, }, }; return next(err); }); } else { // If the query object is empty, there is nothing left to query and we can send the information from cache. // The response is given a cached key equal to true to indicate to the front end of the demo site that the // information was entirely found in the cache. cacheResponse.cacheHit = "full"; res.locals.queryResponse = Object.assign({}, cacheResponse); return next(); } } }); } } exports.QuellCache = QuellCache;