Skip to content
This repository has been archived by the owner on Dec 9, 2024. It is now read-only.

Commit

Permalink
Merge pull request #10 from zarathustra323/graphql-redis-cache
Browse files Browse the repository at this point in the history
Cache site context and GraphQL responses via Redis
  • Loading branch information
zarathustra323 authored Jan 28, 2021
2 parents 9244927 + 294438d commit dfbf8a0
Show file tree
Hide file tree
Showing 20 changed files with 340 additions and 30 deletions.
12 changes: 12 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ services:
ports:
- "10001:5601"

redis:
tty: true
image: redis:6.0-alpine
ports:
- "10002:6379"

keyword-analysis:
<< : *node
working_dir: /base-cms/services/keyword-analysis
Expand All @@ -87,6 +93,7 @@ services:
<<: *env
<<: *env-mongo
<<: *env-newrelic
REDIS_CACHE_DSN: ${REDIS_CACHE_DSN-redis://redis}
APOLLO_ENGINE_ENABLED: ${APOLLO_ENGINE_ENABLED-false}
APOLLO_ENGINE_API_KEY: ${APOLLO_ENGINE_API_KEY-}
GRAPHQL_CACHE_CONTROL_ENABLED: ${GRAPHQL_CACHE_CONTROL_ENABLED-false}
Expand All @@ -104,6 +111,7 @@ services:
EXPOSED_PORT: 10100
depends_on:
- mongodb
- redis
- google-data-api
ports:
- "10100:80"
Expand All @@ -117,6 +125,7 @@ services:
<<: *env-newrelic
GRAPHQL_PLAYGROUND_ENABLED: 1
MONGO_DSN: ${MONGO_DSN_LEONIS-mongodb://mongodb:27017}
REDIS_CACHE_DSN: ${REDIS_CACHE_DSN_LEONIS-redis://redis}
ENABLE_BASEDB_LOGGING: ${ENABLE_BASEDB_LOGGING-}
ENGINE_API_KEY: ${ENGINE_API_KEY-(unset)}
BASE4_REST_USERNAME: ${BASE4_REST_USERNAME-}
Expand All @@ -140,6 +149,7 @@ services:
<<: *env-newrelic
GRAPHQL_PLAYGROUND_ENABLED: 1
MONGO_DSN: ${MONGO_DSN_TAURON-mongodb://mongodb:27017}
REDIS_CACHE_DSN: ${REDIS_CACHE_DSN_TAURON-redis://redis}
ENABLE_BASEDB_LOGGING: ${ENABLE_BASEDB_LOGGING-}
ENGINE_API_KEY: ${ENGINE_API_KEY-(unset)}
BASE4_REST_USERNAME: ${BASE4_REST_USERNAME-}
Expand All @@ -163,6 +173,7 @@ services:
<<: *env-newrelic
GRAPHQL_PLAYGROUND_ENABLED: 1
MONGO_DSN: ${MONGO_DSN_VIRGON-mongodb://mongodb:27017}
REDIS_CACHE_DSN: ${REDIS_CACHE_DSN_VIRGON-redis://redis}
ENABLE_BASEDB_LOGGING: ${ENABLE_BASEDB_LOGGING-}
ENGINE_API_KEY: ${ENGINE_API_KEY-(unset)}
BASE4_REST_USERNAME: ${BASE4_REST_USERNAME-}
Expand All @@ -173,6 +184,7 @@ services:
EXPOSED_PORT: 10103
depends_on:
- mongodb
- redis
- google-data-api
ports:
- "10103:80"
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
const EJSON = require('mongodb-extended-json');
const BaseDB = require('./basedb');
const MongoDB = require('./mongodb');
const createMongoClient = require('./create-mongo-client');
const createBaseDB = require('./create-basedb');

module.exports = {
EJSON,
BaseDB,
MongoDB,
createMongoClient,
Expand Down
1 change: 1 addition & 0 deletions packages/express-apollo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@parameter1/base-cms-graphql-fragment-types": "^2.0.0",
"apollo-cache-inmemory": "^1.6.6",
"apollo-client": "^2.6.10",
"apollo-link-context": "^1.0.20",
"apollo-link-http": "^1.5.17",
"node-fetch": "^2.6.1"
},
Expand Down
20 changes: 14 additions & 6 deletions packages/express-apollo/src/create-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@ const fetch = require('node-fetch');
const { ApolloClient } = require('apollo-client');
const { InMemoryCache } = require('apollo-cache-inmemory');
const { createHttpLink } = require('apollo-link-http');
const { setContext } = require('apollo-link-context');
const fragmentMatcher = require('@parameter1/base-cms-graphql-fragment-types/fragment-matcher');

const rootConfig = {
connectToDevTools: false,
ssrMode: true,
};

module.exports = (uri, config, linkConfig) => new ApolloClient({
...config,
...rootConfig,
link: createHttpLink({ fetch, ...linkConfig, uri }),
cache: new InMemoryCache({ fragmentMatcher }),
});
module.exports = (uri, config, linkConfig, contextFn) => {
const contextLink = setContext((ctx) => {
if (typeof contextFn === 'function') return contextFn(ctx);
return undefined;
});
const httpLink = createHttpLink({ fetch, ...linkConfig, uri });
return new ApolloClient({
...config,
...rootConfig,
link: contextLink.concat(httpLink),
cache: new InMemoryCache({ fragmentMatcher }),
});
};
7 changes: 5 additions & 2 deletions packages/express-apollo/src/middleware.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
const createClient = require('./create-client');

module.exports = (uri, config, linkConfig) => (req, res, next) => {
const apollo = createClient(uri, config, linkConfig);
module.exports = (uri, config, linkConfig, contextFn) => (req, res, next) => {
const apollo = createClient(uri, config, linkConfig, (ctx) => {
if (typeof contextFn === 'function') return contextFn({ req, res, ctx });
return undefined;
});
req.apollo = apollo;
res.locals.apollo = apollo;
next();
Expand Down
17 changes: 16 additions & 1 deletion packages/marko-web/express/apollo.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
const { apolloClient } = require('@parameter1/base-cms-express-apollo');
const { parseBooleanHeader } = require('@parameter1/base-cms-utils');

module.exports = (app, uri, config = {}) => {
app.use(apolloClient(uri, config, config.link));
/**
* Force sets GraphQL cache headers when cookies are present on the site.
* This can either force enable cache when globally disabled, or force disable cache
* when globally enabled.
*/
const contextFn = ({ req }) => {
const headers = ['x-cache-site-context', 'x-cache-responses'].reduce((o, key) => {
const cookie = req.cookies[key];
if (!cookie) return o; // do nothing if no cookie is set.
// otherwise parse the value and set the GraphQL header to match.
return { ...o, [key]: parseBooleanHeader(cookie) };
}, {});
return { headers };
};
app.use(apolloClient(uri, config, config.link, contextFn));
};
8 changes: 7 additions & 1 deletion packages/marko-web/express/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ module.exports = (config = {}) => {
siteId,
sitePackage,
graphqlUri,
gqlCacheResponses,
gqlCacheSiteContext,
} = config;
const distDir = path.resolve(rootDir, 'dist');
const app = express();
Expand Down Expand Up @@ -70,7 +72,11 @@ module.exports = (config = {}) => {
});

// Register apollo client and server proxy.
const headers = buildRequestHeaders({ tenantKey, siteId });
const headers = {
...buildRequestHeaders({ tenantKey, siteId }),
...(gqlCacheSiteContext && { 'x-cache-site-context': 'true' }),
...(gqlCacheResponses && { 'x-cache-responses': 'true' }),
};
apollo(app, graphqlUri, {
name: sitePackage.name,
version: sitePackage.version,
Expand Down
8 changes: 7 additions & 1 deletion packages/marko-web/start-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ require('marko/node-require');
const http = require('http');
const path = require('path');
const { createTerminus } = require('@godaddy/terminus');
const { isFunction: isFn } = require('@parameter1/base-cms-utils');
const { isFunction: isFn, parseBooleanHeader } = require('@parameter1/base-cms-utils');
const errorHandlers = require('./express/error-handlers');
const express = require('./express');
const loadMore = require('./express/load-more');
Expand Down Expand Up @@ -37,6 +37,10 @@ module.exports = async ({
redirectHandler,
sitemapsHeaders,

// Cache settings.
gqlCacheResponses = parseBooleanHeader(env.CACHE_GQL_RESPONSES),
gqlCacheSiteContext = parseBooleanHeader(env.CACHE_GQL_SITE_CONTEXT),

// Terminus settings.
timeout = 1000,
signals = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGQUIT'],
Expand Down Expand Up @@ -71,6 +75,8 @@ module.exports = async ({
sitePackage,
embeddedMediaHandlers,
sitemapsHeaders,
gqlCacheResponses,
gqlCacheSiteContext,
});

// Await required services here...
Expand Down
2 changes: 2 additions & 0 deletions packages/utils/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const getPublishedContentCriteria = require('./get-published-content-criteria');
const isDev = require('./is-dev');
const isFunction = require('./is-function');
const isObject = require('./is-object');
const parseBooleanHeader = require('./parse-boolean-header');
const parseDelimitedString = require('./parse-delimited-string');
const randomElementId = require('./random-element-id');
const sleep = require('./sleep');
Expand All @@ -32,6 +33,7 @@ module.exports = {
isDev,
isFunction,
isObject,
parseBooleanHeader,
parseDelimitedString,
randomElementId,
sleep,
Expand Down
5 changes: 5 additions & 0 deletions packages/utils/src/parse-boolean-header.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = (value) => {
const falsey = ['false', '0'];
if (falsey.includes(value)) return false;
return Boolean(value);
};
1 change: 1 addition & 0 deletions services/graphql-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"graphql-tools": "^4.0.8",
"graphql-type-json": "^0.2.4",
"helmet": "^3.23.3",
"ioredis": "^4.19.4",
"jsonwebtoken": "^8.5.1",
"micro": "^9.3.4",
"moment": "^2.29.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const hashOptions = {
encoding: 'base64',
replacer: (v) => {
if (v instanceof ObjectID) return `${v}`;
if (typeof v === 'object' && /^[a-f0-9]{24}$/.test(v)) return `${v}`;
return v;
},
};
Expand Down
1 change: 1 addition & 0 deletions services/graphql-server/src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const { nonemptystr } = custom;

module.exports = cleanEnv(process.env, {
MONGO_DSN: nonemptystr({ desc: 'The Base MongoDB connection URL.' }),
REDIS_CACHE_DSN: nonemptystr({ desc: 'The Redis DSN where cache values should be saved.' }),
GRAPHQL_ENDPOINT: nonemptystr({ desc: 'The GraphQL endpoint', default: '/' }),
PORT: port({ desc: 'The internal port to run on.', default: 80 }),
EXPOSED_PORT: port({ desc: 'The external port to run on.', default: 80 }),
Expand Down
144 changes: 144 additions & 0 deletions services/graphql-server/src/graphql/plugins/redis-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
const { createHash } = require('crypto');
const { parseBooleanHeader } = require('@parameter1/base-cms-utils');
const redis = require('../../redis');

/**
*
* @param {GraphQLRequestContext} requestContext
*/
const isGraphQLQuery = (requestContext) => {
const { operation } = requestContext;
return operation && operation.operation === 'query';
};

/**
*
* @param {GraphQLRequestContext} requestContext
*/
const isIntrospectionQuery = (requestContext) => {
const { operation } = requestContext;
return operation.selectionSet.selections.every((selection) => {
const fieldName = selection.name.value;
return fieldName.startsWith('__');
});
};

/**
*
* @param {object} obj The object to use to generate the key.
*/
const stringifyCacheKey = obj => createHash('sha256').update(JSON.stringify(obj)).digest('hex');

/**
*
* @param {string} tenant The tenant key. Is used in the key prefix.
* @param {object} obj The object to use to generate the key.
*/
const createKey = (tenant, obj) => `base_gql:${tenant}:${stringifyCacheKey(obj)}`;

const setHeader = (http, key, value) => {
if (http) http.headers.set(key, `${value}`);
};

/**
* @todo Eventually this should adhere to `@cacheControl` directives.
* @todo Adjust header-based cache enabling to hook function
*/
class RedisCacheGraphQLPlugin {
/**
*
* @param {object} params
* @param {boolean} [params.enabled=true] Whether cache is globally enabled
* @param {function} [params.onCacheError] A function to invoke when a cache-set error is thrown.
*/
constructor({ enabled = true, onCacheError } = {}) {
this.enabled = enabled;
this.redis = redis;
this.onCacheError = onCacheError;
}

/**
*
*/
requestDidStart() {
let cacheKeyObj;
let cacheKey;
let age;

return {
/**
*
* @param {GraphQLRequestContext} requestContext
*/
responseForOperation: async (requestContext) => {
if (!this.canCache(requestContext)) return null;
const { context } = requestContext;

cacheKeyObj = {
source: requestContext.source, // the raw query source string
operationName: requestContext.operationName, // the op name (if set)
variables: { ...(requestContext.request.variables || {}) }, // all query vars
context: { siteId: context.site.id() }, // specific context variables
};

cacheKey = createKey(context.tenant, cacheKeyObj);

const serialized = await this.redis.get(cacheKey);
if (!serialized) return null;

const cacheValue = JSON.parse(serialized);
// eslint-disable-next-line no-param-reassign
requestContext.metrics.responseCacheHit = true;
age = Math.round((Date.now() - cacheValue.cacheTime) / 1000);
return { data: cacheValue.data };
},

/**
*
* @param {GraphQLRequestContext} requestContext
*/
willSendResponse: (requestContext) => {
if (!this.canCache(requestContext)) return;
const { response } = requestContext;
const { http } = response;
const { responseCacheHit: hit } = requestContext.metrics;
setHeader(http, 'x-cache', hit ? 'hit' : 'miss');

if (hit) {
// do not write cache for cached responses. but set the age header.
if (age != null) setHeader(http, 'age', age);
return;
}

const cacheValue = {
data: response.data,
cacheTime: Date.now(),
};
if (!cacheKey) throw new Error('Unable to get cache key from previous hook.');

// set cache but do not await
const { onCacheError } = this;
redis.set(cacheKey, JSON.stringify(cacheValue), 'EX', 30).catch((e) => {
if (typeof onCacheError === 'function') onCacheError(e);
});
},
};
}

/**
*
* @param {GraphQLRequestContext} requestContext
*/
canCache(requestContext) {
if (!this.enabled) return false;
const { request, response } = requestContext;
const { http } = request;
if (!http) return false;
const cacheEnabled = parseBooleanHeader(http.headers.get('x-cache-responses'));
if (!cacheEnabled) return false;
const { errors } = response || {};
return isGraphQLQuery(requestContext) && !isIntrospectionQuery(requestContext) && !errors;
}
}

module.exports = RedisCacheGraphQLPlugin;
1 change: 1 addition & 0 deletions services/graphql-server/src/graphql/types/object-id.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ module.exports = new GraphQLScalarType({
if (typeof value === 'string') {
return value;
}
if (/^[a-f0-9]{24}$/.test(value)) return `${value}`;
throw new Error(`${Object.getPrototypeOf(value).constructor.name} not convertible to string.`);
},
parseLiteral(ast) {
Expand Down
Loading

0 comments on commit dfbf8a0

Please sign in to comment.