├── .npmignore ├── .npmrc ├── src ├── hooks │ ├── index.ts │ └── invalidateCacheHook.ts ├── middlewares │ ├── index.ts │ ├── helpers.ts │ ├── cacheMiddleware.ts │ └── helpers.spec.ts ├── adapters │ ├── crypto.ts │ ├── logger.ts │ ├── jwtHelpers.ts │ ├── redis.ts │ ├── jwtHelpers.spec.ts │ ├── redis.spec.ts │ ├── cacheHelpers.ts │ └── cacheHelpers.spec.ts ├── index.ts ├── mocks │ ├── jwtHelpers.js │ ├── crypto.js │ ├── cacheHelpers.js │ └── redis.js ├── types.ts ├── webpack.ts └── cache.ts ├── .husky ├── pre-commit └── pre-push ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── dev ├── nodemon.json ├── .env.example ├── docker-compose.yml ├── src │ ├── collections │ │ ├── Users.ts │ │ └── Examples.ts │ ├── server.ts │ └── payload.config.ts ├── tsconfig.json └── package.json ├── .prettierignore ├── .prettierrc ├── tsconfig.json ├── license.md ├── package.json ├── README.md ├── .gitignore └── jest.config.ts /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './invalidateCacheHook' 2 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cacheMiddleware' 2 | -------------------------------------------------------------------------------- /src/adapters/crypto.ts: -------------------------------------------------------------------------------- 1 | import aCrypto from 'crypto' 2 | 3 | export const crypto = aCrypto 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn pretty-quick --staged 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { invalidateCache } from './adapters/cacheHelpers' 2 | export * from './cache' 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["orta.vscode-jest", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | export CI=TRUE 3 | . "$(dirname "$0")/_/husky.sh" 4 | yarn test 5 | yarn build 6 | -------------------------------------------------------------------------------- /dev/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext": "ts", 3 | "exec": "node -r ts-node/register --inspect -- src/server.ts" 4 | } 5 | -------------------------------------------------------------------------------- /src/mocks/jwtHelpers.js: -------------------------------------------------------------------------------- 1 | export const extractToken = ()=> { 2 | } 3 | 4 | export const getTokenPayload = () => { 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | node_modules 5 | template 6 | 7 | # Ignore files 8 | *.js 9 | *.yml 10 | *.md -------------------------------------------------------------------------------- /src/adapters/logger.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | 3 | export const logger = pino({ 4 | transport: { 5 | target: 'pino-pretty' 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /src/mocks/crypto.js: -------------------------------------------------------------------------------- 1 | export class cryptoClass { 2 | createHash() { 3 | return '' 4 | } 5 | 6 | } 7 | 8 | export const crypto = new cryptoClass() -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "jsxSingleQuote": true, 5 | "tabWidth": 2, 6 | "proseWrap": "preserve", 7 | "printWidth": 100, 8 | "trailingComma": "none" 9 | } 10 | -------------------------------------------------------------------------------- /dev/.env.example: -------------------------------------------------------------------------------- 1 | MONGODB_URI=mongodb://localhost:27017/test 2 | REDIS_URI=redis://localhost:6379 3 | PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000 4 | PAYLOAD_SECRET=XXXX 5 | PAYLOAD_CONFIG_PATH=src/payload.config.ts -------------------------------------------------------------------------------- /src/mocks/cacheHelpers.js: -------------------------------------------------------------------------------- 1 | export const generateCacheHash = () => {} 2 | 3 | export const getCacheItem = async () => {} 4 | 5 | export const setCacheItem = () => {} 6 | 7 | export const invalidateCacheAfterChangeHook = async () => {} 8 | 9 | export const invalidateCacheAfterDeleteHook = async () => {} 10 | 11 | export const getCollectionName = () => {} 12 | -------------------------------------------------------------------------------- /dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mongo: 5 | image: mongo:latest 6 | ports: 7 | - "27017:27017" 8 | command: 9 | - --storageEngine=wiredTiger 10 | volumes: 11 | - data:/data/db 12 | logging: 13 | driver: none 14 | redis: 15 | image: redis:latest 16 | ports: 17 | - "6379:6379" 18 | 19 | volumes: 20 | data: 21 | node_modules: 22 | -------------------------------------------------------------------------------- /dev/src/collections/Users.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload/types' 2 | 3 | const Users: CollectionConfig = { 4 | slug: 'users', 5 | auth: { 6 | useAPIKey: true 7 | }, 8 | admin: { 9 | useAsTitle: 'email' 10 | }, 11 | access: { 12 | read: () => true 13 | }, 14 | fields: [ 15 | // Email added by default 16 | // Add more fields as needed 17 | ] 18 | } 19 | 20 | export default Users 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach Debug Plugin", 9 | "type": "node", 10 | "request": "attach", 11 | "cwd": "${workspaceFolder}/dev" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": false, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": "./src", 11 | "declaration": true 12 | }, 13 | "include": ["src"], 14 | "exclude": ["node_modules", "dist", "build", "**/*.spec.ts"], 15 | "ts-node": { 16 | "transpileOnly": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": false, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": "./", 11 | "jsx": "react", 12 | "sourceMap": true 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules", "dist", "build"], 16 | "ts-node": { 17 | "transpileOnly": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/adapters/jwtHelpers.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'cookie' 2 | import decode from 'jwt-decode' 3 | import { isString } from 'lodash' 4 | import { JwtToken } from '../types' 5 | 6 | export const extractToken = (cookies: string): string | null | undefined => { 7 | const parsedCookies = parse(cookies) 8 | if (isString(parsedCookies)) { 9 | return null 10 | } 11 | return parsedCookies['payload-token'] 12 | } 13 | 14 | export const getTokenPayload = (token: string): JwtToken => { 15 | return decode(token) 16 | } 17 | -------------------------------------------------------------------------------- /src/hooks/invalidateCacheHook.ts: -------------------------------------------------------------------------------- 1 | import { CollectionAfterDeleteHook } from 'payload/types' 2 | import { invalidateCache } from '../adapters/cacheHelpers' 3 | 4 | /* Explicit type as CollectionAfterChangeHook | GlobalAfterChangeHook 5 | can lead to a type error in the payload configuration. */ 6 | export const invalidateCacheAfterChangeHook = ({ doc }) => { 7 | // invalidate cache 8 | invalidateCache() 9 | return doc 10 | } 11 | 12 | export const invalidateCacheAfterDeleteHook: CollectionAfterDeleteHook = ({ doc }) => { 13 | // invalidate cache 14 | invalidateCache() 15 | return doc 16 | } 17 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface RedisInitOptions { 2 | redisUrl: string 3 | redisNamespace?: string 4 | redisIndexesName?: string 5 | } 6 | 7 | export interface PluginOptions { 8 | excludedCollections?: string[] 9 | excludedGlobals?: string[] 10 | includedPaths?: string[] 11 | } 12 | 13 | export interface JwtToken { 14 | id: string 15 | collection: string 16 | email: string 17 | } 18 | 19 | export const DEFAULT_USER_COLLECTION = 'loggedout' 20 | 21 | export interface cacheMiddlewareArgs { 22 | includedCollections: string[] 23 | includedGlobals: string[] 24 | includedPaths: string[] 25 | apiBaseUrl: string 26 | } 27 | -------------------------------------------------------------------------------- /src/mocks/redis.js: -------------------------------------------------------------------------------- 1 | class RedisContext { 2 | redisClient = null 3 | namespace = null 4 | indexesName = null 5 | 6 | init(params) { 7 | const { url, namespace, indexesName } = params 8 | 9 | this.namespace = namespace 10 | this.indexesName = indexesName 11 | 12 | } 13 | //getter 14 | getRedisClient() { 15 | return this.redisClient 16 | } 17 | getNamespace() { 18 | return this.namespace 19 | } 20 | getIndexesName() { 21 | return this.indexesName 22 | } 23 | } 24 | 25 | export const redisContext = new RedisContext() 26 | export const initRedisContext = (params) => { 27 | redisContext.init(params) 28 | } 29 | -------------------------------------------------------------------------------- /dev/src/collections/Examples.ts: -------------------------------------------------------------------------------- 1 | import { AccessResult } from 'payload/config' 2 | import { CollectionConfig } from 'payload/types' 3 | 4 | // Example Collection - For reference only, this must be added to payload.config.ts to be used. 5 | const Examples: CollectionConfig = { 6 | slug: 'examples', 7 | admin: { 8 | useAsTitle: 'someField' 9 | }, 10 | access: { 11 | read: ({ req }): AccessResult => { 12 | const { user } = req 13 | return !!user 14 | } 15 | }, 16 | // access: { 17 | // read: () => true 18 | // }, 19 | hooks: { 20 | afterRead: [ 21 | () => { 22 | console.log('>> Reading from DB') 23 | } 24 | ] 25 | }, 26 | fields: [ 27 | { 28 | name: 'someField', 29 | type: 'text' 30 | } 31 | ] 32 | } 33 | 34 | export default Examples 35 | -------------------------------------------------------------------------------- /dev/src/server.ts: -------------------------------------------------------------------------------- 1 | import { initRedis } from '@aengz/payload-redis-cache' 2 | import * as dotenv from 'dotenv' 3 | import express from 'express' 4 | import payload from 'payload' 5 | dotenv.config() 6 | 7 | const start = async () => { 8 | const app = express() 9 | 10 | // Init resid connection 11 | initRedis({ 12 | redisUrl: process.env.REDIS_URI 13 | }) 14 | 15 | // Redirect root to Admin panel 16 | app.get('/', (_, res) => { 17 | res.redirect('/admin') 18 | }) 19 | 20 | // Initialize Payload 21 | payload.init({ 22 | secret: process.env.PAYLOAD_SECRET || '', 23 | express: app, 24 | onInit: () => { 25 | console.log(`Payload Admin URL: ${payload.getAdminURL()}`) 26 | } 27 | }) 28 | 29 | // Add your own express routes here 30 | 31 | app.listen(3000) 32 | } 33 | 34 | start() 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "node_modules/": true, 4 | "packages/": true 5 | }, 6 | "jest.autoRun": "off", 7 | "jest.disabledWorkspaceFolders": ["root"], 8 | "editor.defaultFormatter": "esbenp.prettier-vscode", 9 | "editor.formatOnSave": true, 10 | "editor.codeActionsOnSave": { 11 | "source.organizeImports": true 12 | }, 13 | "[typescriptreact]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[typescript]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | }, 19 | "[javascript]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | "[javascriptreact]": { 23 | "editor.defaultFormatter": "esbenp.prettier-vscode" 24 | }, 25 | "[jsonc]": { 26 | "editor.defaultFormatter": "esbenp.prettier-vscode" 27 | }, 28 | "[json]": { 29 | "editor.defaultFormatter": "esbenp.prettier-vscode" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2022-2022 AEngz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /dev/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { cachePlugin } from '@aengz/payload-redis-cache' 2 | import { webpackBundler } from '@payloadcms/bundler-webpack' 3 | import { mongooseAdapter } from '@payloadcms/db-mongodb' 4 | import { slateEditor } from '@payloadcms/richtext-slate' 5 | import path from 'path' 6 | import { Config, buildConfig } from 'payload/config' 7 | import Examples from './collections/Examples' 8 | import Users from './collections/Users' 9 | 10 | const config: Config = { 11 | serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL, 12 | admin: { 13 | user: Users.slug 14 | }, 15 | bundler: webpackBundler(), 16 | collections: [Users, Examples], 17 | editor: slateEditor({}), 18 | db: mongooseAdapter({ 19 | url: process.env.MONGODB_URI || '' 20 | }), 21 | typescript: { 22 | outputFile: path.resolve(__dirname, 'payload-types.ts') 23 | }, 24 | graphQL: { 25 | schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql') 26 | }, 27 | plugins: [ 28 | cachePlugin({ excludedCollections: ['users'] }) // ADD HERE 29 | ] 30 | } 31 | 32 | export default buildConfig(config) 33 | -------------------------------------------------------------------------------- /src/middlewares/helpers.ts: -------------------------------------------------------------------------------- 1 | import { cacheMiddlewareArgs } from '../types' 2 | 3 | interface canUseCacheArgs extends cacheMiddlewareArgs { 4 | originalUrl: string 5 | } 6 | 7 | export const getEntityName = (apiBaseUrl: string, url: string, namespace?: string) => { 8 | const regex = namespace 9 | ? new RegExp(`^${apiBaseUrl}\/${namespace}\/([^\?\/]*)`, 'i') 10 | : new RegExp(`^${apiBaseUrl}\/([^\?\/]*)`, 'i') 11 | const match = url.match(regex) 12 | return match ? match[1] : null 13 | } 14 | 15 | export const canUseCache = ({ 16 | apiBaseUrl, 17 | originalUrl, 18 | includedCollections, 19 | includedGlobals, 20 | includedPaths 21 | }: canUseCacheArgs) => { 22 | const collectionsEntityName = getEntityName(apiBaseUrl, originalUrl, '') 23 | const globalsEntityName = getEntityName(apiBaseUrl, originalUrl, 'globals') 24 | const pathEntityName = originalUrl.replace(apiBaseUrl, '') 25 | 26 | return ( 27 | !originalUrl.includes('_preferences') && 28 | (includedCollections.includes(collectionsEntityName) || 29 | includedGlobals.includes(globalsEntityName) || 30 | includedPaths.includes(pathEntityName)) 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/webpack.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import type { Config } from 'payload/config' 3 | import type { Configuration as WebpackConfig } from 'webpack' 4 | import type { PluginOptions } from './types' 5 | 6 | interface ExtendWebpackConfigArgs { 7 | config: Config 8 | options?: PluginOptions 9 | } 10 | 11 | export const extendWebpackConfig = 12 | (args: ExtendWebpackConfigArgs) => 13 | (webpackConfig: WebpackConfig): WebpackConfig => { 14 | const { config: originalConfig } = args 15 | const existingWebpackConfig = 16 | typeof originalConfig.admin?.webpack === 'function' 17 | ? originalConfig.admin.webpack(webpackConfig) 18 | : webpackConfig 19 | 20 | const adaptersPath = path.resolve(__dirname, 'adapters') 21 | const adaptersMock = path.resolve(__dirname, 'mocks') 22 | 23 | const config: WebpackConfig = { 24 | ...existingWebpackConfig, 25 | resolve: { 26 | ...(existingWebpackConfig.resolve || {}), 27 | alias: { 28 | ...(existingWebpackConfig.resolve?.alias || {}), 29 | [adaptersPath]: adaptersMock 30 | } 31 | } 32 | } 33 | 34 | return config 35 | } 36 | -------------------------------------------------------------------------------- /dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-redis-cache-dev", 3 | "version": "1.0.0", 4 | "main": "dist/server.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon", 8 | "build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build", 9 | "build:server": "tsc", 10 | "build": "yarn copyfiles && yarn build:payload && yarn build:server", 11 | "serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js", 12 | "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/", 13 | "generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types", 14 | "generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema" 15 | }, 16 | "dependencies": { 17 | "@aengz/payload-redis-cache": "^1.0.4", 18 | "@payloadcms/bundler-webpack": "^1.0.5", 19 | "@payloadcms/db-mongodb": "^1.0.6", 20 | "@payloadcms/richtext-slate": "^1.1.0", 21 | "dotenv": "^16.0.3", 22 | "express": "^4.17.1", 23 | "payload": "2.0.15", 24 | "redis": "^4.5.1" 25 | }, 26 | "devDependencies": { 27 | "@types/express": "^4.17.9", 28 | "copyfiles": "^2.4.1", 29 | "cross-env": "^7.0.3", 30 | "nodemon": "^2.0.6", 31 | "ts-node": "^9.1.1", 32 | "typescript": "^4.8.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/adapters/redis.ts: -------------------------------------------------------------------------------- 1 | import { createClient, RedisClientType } from 'redis' 2 | import { logger } from './logger' 3 | 4 | export interface IRedisContext { 5 | getRedisClient: () => RedisClientType 6 | } 7 | 8 | export interface InitRedisContextParams { 9 | url: string 10 | namespace: string 11 | indexesName: string 12 | } 13 | 14 | export class RedisContext implements IRedisContext { 15 | private redisClient: RedisClientType | null = null 16 | private namespace: string | null = null 17 | private indexesName: string | null = null 18 | 19 | public init(params: InitRedisContextParams) { 20 | const { url, namespace, indexesName } = params 21 | 22 | this.namespace = namespace 23 | this.indexesName = indexesName 24 | try { 25 | this.redisClient = createClient({ url }) 26 | this.redisClient.connect() 27 | logger.info('Connected to Redis successfully!') 28 | 29 | this.redisClient.on('error', (error) => { 30 | logger.error(error) 31 | }) 32 | } catch (e) { 33 | this.redisClient = null 34 | logger.info('Unable to connect to Redis!') 35 | } 36 | } 37 | 38 | //getter 39 | public getRedisClient(): RedisClientType { 40 | return this.redisClient 41 | } 42 | public getNamespace(): string { 43 | return this.namespace 44 | } 45 | public getIndexesName(): string { 46 | return this.indexesName 47 | } 48 | } 49 | 50 | export const redisContext = new RedisContext() 51 | export const initRedisContext = (params: InitRedisContextParams) => { 52 | redisContext.init(params) 53 | } 54 | -------------------------------------------------------------------------------- /src/adapters/jwtHelpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'cookie' 2 | import { extractToken, getTokenPayload } from './jwtHelpers' 3 | 4 | jest.mock('cookie') 5 | 6 | describe('jwtHelpers', () => { 7 | describe('extractToken', () => { 8 | describe('extractToken', () => { 9 | it('should return null if the cookies string is invalid', () => { 10 | ;(parse).mockReturnValue('invalid') 11 | expect(extractToken('invalid_cookies')).toBeNull() 12 | }) 13 | 14 | it('should return the payload-token value if the cookies string is valid', () => { 15 | ;(parse).mockReturnValue({ 'payload-token': 'abc123' }) 16 | expect(extractToken('payload-token=abc123; other_cookie=def456')).toEqual('abc123') 17 | }) 18 | 19 | it('should return undefined if the payload-token cookie is not present', () => { 20 | ;(parse).mockReturnValue({ other_cookie: 'abc123' }) 21 | expect(extractToken('other_cookie=abc123')).toBeUndefined() 22 | }) 23 | }) 24 | 25 | describe('getTokenPayload', () => { 26 | it('should return the token payload when given a valid token', () => { 27 | const token = 28 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' 29 | const expectedPayload = { 30 | sub: '1234567890', 31 | name: 'John Doe', 32 | iat: 1516239022 33 | } 34 | 35 | expect(getTokenPayload(token)).toEqual(expectedPayload) 36 | }) 37 | 38 | it('should throw an error when given an invalid token', () => { 39 | const token = 'invalid.token' 40 | 41 | expect(() => getTokenPayload(token)).toThrowError() 42 | }) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@aengz/payload-redis-cache", 3 | "description": "Redis cache plugin for Payload CMS", 4 | "engines": { 5 | "node": ">=14", 6 | "yarn": ">=1.22 <2" 7 | }, 8 | "author": { 9 | "email": "info@aengz.com", 10 | "name": "AEngz" 11 | }, 12 | "maintainers": [ 13 | { 14 | "email": "info@aengz.com", 15 | "name": "AEngz" 16 | } 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/Aengz/payload-redis-cache" 21 | }, 22 | "keywords": [ 23 | "payload", 24 | "plugin", 25 | "cache", 26 | "redis" 27 | ], 28 | "version": "1.1.1", 29 | "main": "dist/index.js", 30 | "types": "dist/index.d.ts", 31 | "license": "MIT", 32 | "scripts": { 33 | "dev": "cd ./dev && yarn dev", 34 | "build": "rm -Rf ./dist && tsc", 35 | "dev:build": "yarn build && yarn link && cd dev && yarn link --force @aengz/payload-redis-cache", 36 | "build:publish": "yarn build && yarn publish", 37 | "test": "jest" 38 | }, 39 | "peerDependencies": { 40 | "cookie": "0.5.0", 41 | "jwt-decode": "^3.1.2", 42 | "payload": "^1.5.9", 43 | "pino": "^8.8.0", 44 | "redis": "^4.5.1" 45 | }, 46 | "files": [ 47 | "dist" 48 | ], 49 | "devDependencies": { 50 | "@types/cookie": "^0.5.1", 51 | "@types/express": "^4.17.9", 52 | "@types/jest": "^29.2.4", 53 | "cookie": "0.5.0", 54 | "copyfiles": "^2.4.1", 55 | "cross-env": "^7.0.3", 56 | "dotenv": "^8.2.0", 57 | "express": "^4.17.1", 58 | "husky": "^8.0.3", 59 | "jest": "^29.3.1", 60 | "jwt-decode": "^3.1.2", 61 | "nodemon": "^2.0.6", 62 | "payload": "^2.0.0", 63 | "pino": "^8.8.0", 64 | "pino-pretty": "^9.1.1", 65 | "pretty-quick": "^3.1.3", 66 | "redis": "^4.5.1", 67 | "ts-jest": "^29.0.3", 68 | "ts-node": "^9.1.1", 69 | "typescript": "^4.9.4", 70 | "webpack": "^5.78.0" 71 | }, 72 | "postinstall": "husky install" 73 | } 74 | -------------------------------------------------------------------------------- /src/middlewares/cacheMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from 'express' 2 | import { PayloadRequest } from 'payload/types' 3 | import { getCacheItem, setCacheItem } from '../adapters/cacheHelpers' 4 | import { extractToken, getTokenPayload } from '../adapters/jwtHelpers' 5 | import { cacheMiddlewareArgs, DEFAULT_USER_COLLECTION } from '../types' 6 | import { canUseCache } from './helpers' 7 | 8 | export const cacheMiddleware = 9 | ({ includedCollections, includedGlobals, includedPaths, apiBaseUrl }: cacheMiddlewareArgs) => 10 | async (req: PayloadRequest, res: Response, next: NextFunction) => { 11 | // try to match the cache and return immediately 12 | const { 13 | originalUrl, 14 | headers: { cookie, authorization = '' } 15 | } = req 16 | 17 | // If the collection name cannot be detected or the method is not "GET" then call next() 18 | const useCache = canUseCache({ 19 | apiBaseUrl, 20 | originalUrl, 21 | includedCollections, 22 | includedGlobals, 23 | includedPaths 24 | }) 25 | 26 | if (!useCache || req.method !== 'GET') { 27 | return next() 28 | } 29 | 30 | let userCollection: string = DEFAULT_USER_COLLECTION 31 | // check if there is a cookie and extract data 32 | if (cookie) { 33 | const token = extractToken(cookie) 34 | if (token) { 35 | const tokenData = getTokenPayload(token) 36 | userCollection = tokenData.collection 37 | } 38 | } 39 | 40 | // TODO find a better way 41 | const json = res.json 42 | res.json = (body) => { 43 | res.json = json 44 | setCacheItem({ 45 | userCollection, 46 | requestedUrl: originalUrl, 47 | body, 48 | authorization 49 | }) 50 | return res.json(body) 51 | } 52 | 53 | // Try to get the cached item 54 | const cacheData = await getCacheItem({ 55 | userCollection, 56 | requestedUrl: originalUrl, 57 | authorization 58 | }) 59 | if (cacheData) { 60 | return res.setHeader('Content-Type', 'application/json').send(cacheData) 61 | } 62 | // route to controllers 63 | return next() 64 | } 65 | -------------------------------------------------------------------------------- /src/adapters/redis.spec.ts: -------------------------------------------------------------------------------- 1 | import { createClient, RedisClientType } from 'redis' 2 | import { RedisContext } from './redis' 3 | 4 | jest.mock('redis', () => ({ 5 | createClient: jest.fn() 6 | })) 7 | 8 | describe('RedisContext', () => { 9 | let redisClient: RedisClientType 10 | 11 | beforeEach(() => { 12 | redisClient = { 13 | connect: jest.fn(), 14 | on: jest.fn() 15 | } as any 16 | }) 17 | 18 | describe('initRedisContext', () => { 19 | it('should call the init method of the RedisContext instance with the provided params', () => { 20 | ;(createClient as jest.Mock).mockReturnValue(redisClient) 21 | 22 | const params = { 23 | url: 'redis://localhost', 24 | namespace: 'namespace', 25 | indexesName: 'indexesName' 26 | } 27 | const redisContext = new RedisContext() 28 | redisContext.init(params) 29 | 30 | expect(createClient).toHaveBeenCalledWith({ url: 'redis://localhost' }) 31 | expect(redisClient.connect).toHaveBeenCalled() 32 | }) 33 | }) 34 | 35 | describe('redisContext', () => { 36 | it('After init should get redis context', () => { 37 | ;(createClient as jest.Mock).mockReturnValue(redisClient) 38 | 39 | const params = { 40 | url: 'redis://localhost', 41 | namespace: 'namespace', 42 | indexesName: 'indexesName' 43 | } 44 | const redisContext = new RedisContext() 45 | redisContext.init(params) 46 | 47 | expect(redisContext.getRedisClient()).toBe(redisClient) 48 | expect(redisContext.getNamespace()).toBe('namespace') 49 | expect(redisContext.getIndexesName()).toBe('indexesName') 50 | }) 51 | it('Should set redisClient to null if there is an error creating the client', () => { 52 | ;(createClient as jest.Mock).mockImplementation(() => { 53 | throw new Error('Error creating client') 54 | }) 55 | 56 | const params = { 57 | url: 'redis://localhost', 58 | namespace: 'namespace', 59 | indexesName: 'indexesName' 60 | } 61 | const redisContext = new RedisContext() 62 | redisContext.init(params) 63 | 64 | expect(redisContext.getRedisClient()).toBeNull() 65 | expect(redisContext.getNamespace()).toBe('namespace') 66 | expect(redisContext.getIndexesName()).toBe('indexesName') 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /src/adapters/cacheHelpers.ts: -------------------------------------------------------------------------------- 1 | import { crypto } from './crypto' 2 | import { logger } from './logger' 3 | import { redisContext } from './redis' 4 | 5 | interface cacheBaseArgs { 6 | userCollection: string 7 | requestedUrl: string 8 | authorization: string 9 | } 10 | 11 | interface cacheExtendedArgs extends cacheBaseArgs { 12 | body: unknown 13 | } 14 | 15 | export const generateCacheHash = ({ 16 | userCollection, 17 | requestedUrl, 18 | authorization 19 | }: cacheBaseArgs): string => { 20 | const requestUrlAndUserCollection = `${userCollection}-${requestedUrl}-${authorization}` 21 | const pathHash = crypto.createHash('sha256').update(requestUrlAndUserCollection).digest('hex') 22 | const namespace = redisContext.getNamespace() 23 | return `${namespace}:${pathHash}` 24 | } 25 | 26 | export const getCacheItem = async ({ 27 | userCollection, 28 | requestedUrl, 29 | authorization 30 | }: cacheBaseArgs): Promise => { 31 | const redisClient = redisContext.getRedisClient() 32 | if (!redisClient) { 33 | logger.info(`Unable to get cache for ${requestedUrl}`) 34 | return null 35 | } 36 | 37 | const hash = generateCacheHash({ userCollection, requestedUrl, authorization }) 38 | const jsonData = await redisClient.GET(hash) 39 | if (!jsonData) { 40 | logger.info(`<< Get Cache [MISS] - URL:[${requestedUrl}] User:[${userCollection}]`) 41 | return null 42 | } 43 | logger.info(`<< Get Cache [OK] - URL:[${requestedUrl}] User:[${userCollection}]`) 44 | return jsonData 45 | } 46 | 47 | export const setCacheItem = ({ 48 | userCollection, 49 | requestedUrl, 50 | authorization, 51 | body 52 | }: cacheExtendedArgs): void => { 53 | const redisClient = redisContext.getRedisClient() 54 | if (!redisClient) { 55 | logger.info(`Unable to set cache for ${requestedUrl}`) 56 | return 57 | } 58 | 59 | const hash = generateCacheHash({ userCollection, requestedUrl, authorization }) 60 | logger.info(`>> Set Cache Item - URL:[${requestedUrl}] User:[${userCollection}]`) 61 | 62 | try { 63 | const data = JSON.stringify(body) 64 | redisClient.SET(hash, data) 65 | 66 | const indexesName = redisContext.getIndexesName() 67 | redisClient.SADD(indexesName, hash) 68 | } catch (e) { 69 | logger.info(`Unable to set cache for ${requestedUrl}`) 70 | } 71 | } 72 | 73 | export const invalidateCache = async (): Promise => { 74 | const redisClient = redisContext.getRedisClient() 75 | if (!redisClient) { 76 | logger.info('Unable to invalidate cache') 77 | return 78 | } 79 | 80 | const indexesName = redisContext.getIndexesName() 81 | const indexes = await redisClient.SMEMBERS(indexesName) 82 | indexes.forEach((index) => { 83 | redisClient.DEL(index) 84 | redisClient.SREM(indexesName, index) 85 | }) 86 | 87 | logger.info('Cache Invalidated') 88 | } 89 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import type { Config, Plugin } from 'payload/config' 2 | import { CollectionConfig, GlobalConfig } from 'payload/types' 3 | import { initRedisContext } from './adapters/redis' 4 | import { invalidateCacheAfterChangeHook, invalidateCacheAfterDeleteHook } from './hooks' 5 | import { cacheMiddleware } from './middlewares' 6 | import { PluginOptions, RedisInitOptions } from './types' 7 | import { extendWebpackConfig } from './webpack' 8 | 9 | export const initRedis = (params: RedisInitOptions) => { 10 | const { 11 | redisUrl: url, 12 | redisNamespace: namespace = 'payload', 13 | redisIndexesName: indexesName = 'payload-cache-index' 14 | } = params 15 | initRedisContext({ url, namespace, indexesName }) 16 | } 17 | 18 | export const cachePlugin = 19 | (pluginOptions: PluginOptions): Plugin => 20 | (config: Config): Config | Promise => { 21 | const includedCollections: string[] = [] 22 | const includedGlobals: string[] = [] 23 | // Merge incoming plugin options with the default ones 24 | const { excludedCollections = [], excludedGlobals = [], includedPaths = [] } = pluginOptions 25 | 26 | const collections = config?.collections 27 | ? config.collections?.map((collection): CollectionConfig => { 28 | const { hooks } = collection 29 | 30 | if (!excludedCollections.includes(collection.slug)) { 31 | includedCollections.push(collection.slug) 32 | } 33 | 34 | const afterChange = [...(hooks?.afterChange || []), invalidateCacheAfterChangeHook] 35 | const afterDelete = [...(hooks?.afterDelete || []), invalidateCacheAfterDeleteHook] 36 | 37 | return { 38 | ...collection, 39 | hooks: { 40 | ...hooks, 41 | afterChange, 42 | afterDelete 43 | } 44 | } 45 | }) 46 | : [] 47 | 48 | const globals = config?.globals 49 | ? config.globals?.map((global): GlobalConfig => { 50 | const { hooks } = global 51 | 52 | if (!excludedGlobals.includes(global.slug)) { 53 | includedGlobals.push(global.slug) 54 | } 55 | 56 | const afterChange = [...(hooks?.afterChange || []), invalidateCacheAfterChangeHook] 57 | 58 | return { 59 | ...global, 60 | hooks: { 61 | ...hooks, 62 | afterChange 63 | } 64 | } 65 | }) 66 | : [] 67 | 68 | return { 69 | ...config, 70 | admin: { 71 | ...(config?.admin || {}), 72 | webpack: extendWebpackConfig({ config }) 73 | }, 74 | collections, 75 | globals, 76 | express: { 77 | preMiddleware: [ 78 | ...(config?.express?.preMiddleware || []), 79 | cacheMiddleware({ 80 | includedCollections, 81 | includedGlobals, 82 | includedPaths, 83 | apiBaseUrl: config?.routes?.api || '/api' 84 | }) 85 | ] 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Payload Redis Cache Plugin 2 | 3 | This plugin for [Payload CMS](https://github.com/payloadcms/payload) adds a cache layer to API endpoints. 4 | The cache is based on the combination of the user's collection and the original URL. 5 | 6 | ### Requirements 7 | 8 | - Payload version `1.0.19` or higher is required 9 | - A [Redis](https://redis.io/) instance is required 10 | 11 | ## Installation 12 | To install the plugin, run one of the following commands: 13 | 14 | ```console 15 | yarn add @aengz/payload-redis-cache 16 | ``` 17 | or 18 | ```console 19 | npm install @aengz/payload-redis-cache 20 | ``` 21 | 22 | ### Redis package 23 | You also need to install the redis package if it is not already installed: 24 | 25 | ```console 26 | yarn add redis 27 | ``` 28 | or 29 | ```console 30 | npm install redis 31 | ``` 32 | 33 | ## Usage 34 | 35 | To use the plugin, add it to the Payload config as follows: 36 | 37 | ```js 38 | import { buildConfig } from 'payload/config'; 39 | import { cachePlugin } from '@aengz/payload-redis-cache' 40 | 41 | const config = buildConfig({ 42 | // your config here 43 | 44 | plugins: [ 45 | cachePlugin({ 46 | excludedCollections: ['users'], 47 | // excludedGlobals: ['myglobal'] 48 | }) 49 | ] 50 | }) 51 | ``` 52 | 53 | Add the initializer function in `server.ts` 54 | 55 | ```js 56 | import { cachePlugin } from '@aengz/payload-redis-cache' 57 | 58 | ... 59 | 60 | initRedis({ 61 | redisUrl: process.env.REDIS_URI 62 | }) 63 | ``` 64 | 65 | ## Plugin options 66 | 67 | | Option| Type | Description | 68 | |---|---|---| 69 | | `redisUrl` * | `string` | Redis instance's url. | 70 | | `redisNamespace` | `string` | Choose the prefix to use for cache redis keys. Defaults to `payload`. | 71 | | `redisIndexesName` | `string` | Choose the index key for cache redis indexes. Defaults to `payload-cache-index`. | 72 | | `excludedCollections` | `string[]` | An array of collection names to be excluded. | 73 | | `excludedGlobals` | `string[]` | An array of globals names to be excluded. | 74 | | `includedPaths` | `string[]` | An array of custom routes to be included. | 75 | 76 | A * denotes that the property is required. 77 | 78 | ## Helpers 79 | 80 | This package provides utility functions for managing the cache. Here's an example of how to use the `invalidateCache` function: 81 | 82 | 83 | ```js 84 | import { invalidateCache } from '@aengz/payload-redis-cache' 85 | 86 | ... 87 | 88 | invalidateCache() 89 | ``` 90 | 91 | ## Development 92 | There is a development environment in the `/dev` directory of the repository. To use it, create a new `.env` file in the `/dev` directory using the example `.env.example` file as a reference: 93 | 94 | ``` console 95 | cd dev 96 | cp .env.example .env 97 | ``` 98 | 99 | Before using the plugin in the development environment, the package needs to be built. To build the library, run one of the following commands: 100 | ### Build the lib 101 | Build the lib using: 102 | ```console 103 | yarn dev:build 104 | ``` 105 | or 106 | ```console 107 | npm run dev:build 108 | ``` 109 | 110 | ### Use development environment 111 | To run the development environment, use the following command: 112 | 113 | ```console 114 | yarn dev 115 | ``` 116 | or 117 | ```console 118 | npm run dev 119 | ``` 120 | 121 | ### Running test 122 | To run the test suite, use one of the following commands: 123 | ```console 124 | yarn test 125 | ``` 126 | or 127 | ```console 128 | npm run test 129 | ``` 130 | 131 | ## License 132 | [MIT](LICENSE) 133 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | *.DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | ### Node ### 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | node_modules 50 | jspm_packages/ 51 | 52 | # Snowpack dependency directory (https://snowpack.dev/) 53 | web_modules/ 54 | 55 | # TypeScript cache 56 | *.tsbuildinfo 57 | 58 | # Optional npm cache directory 59 | .npm 60 | 61 | # Optional eslint cache 62 | .eslintcache 63 | 64 | # Optional stylelint cache 65 | .stylelintcache 66 | 67 | # Microbundle cache 68 | .rpt2_cache/ 69 | .rts2_cache_cjs/ 70 | .rts2_cache_es/ 71 | .rts2_cache_umd/ 72 | 73 | # Optional REPL history 74 | .node_repl_history 75 | 76 | # Output of 'npm pack' 77 | *.tgz 78 | 79 | # Yarn Integrity file 80 | .yarn-integrity 81 | 82 | # dotenv environment variable files 83 | .env 84 | .env.development.local 85 | .env.test.local 86 | .env.production.local 87 | .env.local 88 | 89 | # parcel-bundler cache (https://parceljs.org/) 90 | .cache 91 | .parcel-cache 92 | 93 | # Next.js build output 94 | .next 95 | out 96 | 97 | # Nuxt.js build / generate output 98 | .nuxt 99 | dist 100 | 101 | # Gatsby files 102 | .cache/ 103 | # Comment in the public line in if your project uses Gatsby and not Next.js 104 | # https://nextjs.org/blog/next-9-1#public-directory-support 105 | # public 106 | 107 | # vuepress build output 108 | .vuepress/dist 109 | 110 | # vuepress v2.x temp and cache directory 111 | .temp 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | 138 | ### Node Patch ### 139 | # Serverless Webpack directories 140 | .webpack/ 141 | 142 | # Optional stylelint cache 143 | 144 | # SvelteKit build / generate output 145 | .svelte-kit 146 | 147 | ### VisualStudioCode ### 148 | .vscode/* 149 | !.vscode/settings.json 150 | !.vscode/tasks.json 151 | !.vscode/launch.json 152 | !.vscode/extensions.json 153 | !.vscode/*.code-snippets 154 | 155 | # Local History for Visual Studio Code 156 | .history/ 157 | 158 | # Built Visual Studio Code Extensions 159 | *.vsix 160 | 161 | ### VisualStudioCode Patch ### 162 | # Ignore all local history of files 163 | .history 164 | .ionide 165 | 166 | # Support for Project snippet scope 167 | .vscode/*.code-snippets 168 | 169 | # Ignore code-workspaces 170 | *.code-workspace 171 | 172 | # End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode 173 | build -------------------------------------------------------------------------------- /src/middlewares/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { canUseCache, getEntityName } from './helpers' 2 | 3 | describe('canUseCache', () => { 4 | it('returns true for included collections', () => { 5 | const apiBaseUrl = '/api' 6 | const originalUrl = '/api/collection1' 7 | const includedCollections = ['collection1', 'collection2'] 8 | const includedGlobals = ['global1', 'global2'] 9 | const includedPaths = [] 10 | 11 | expect( 12 | canUseCache({ apiBaseUrl, originalUrl, includedCollections, includedGlobals, includedPaths }) 13 | ).toBeTruthy() 14 | }) 15 | 16 | it('returns true for included collections', () => { 17 | const apiBaseUrl = '/api' 18 | const originalUrl = '/api/collection1?q=1' 19 | const includedCollections = ['collection1', 'collection2'] 20 | const includedGlobals = ['global1', 'global2'] 21 | const includedPaths = [] 22 | 23 | expect( 24 | canUseCache({ apiBaseUrl, originalUrl, includedCollections, includedGlobals, includedPaths }) 25 | ).toBeTruthy() 26 | }) 27 | 28 | it('returns true for included globals', () => { 29 | const apiBaseUrl = '/api' 30 | const originalUrl = '/api/globals/main-menu' 31 | const includedCollections = ['collection1', 'collection2'] 32 | const includedGlobals = ['main-menu'] 33 | const includedPaths = [] 34 | 35 | expect( 36 | canUseCache({ apiBaseUrl, originalUrl, includedCollections, includedGlobals, includedPaths }) 37 | ).toBeTruthy() 38 | }) 39 | 40 | it('returns true for included sub path in globals', () => { 41 | const apiBaseUrl = '/api' 42 | const originalUrl = '/api/globals/navigation/main-menu' 43 | const includedCollections = ['collection1', 'collection2'] 44 | const includedGlobals = ['navigation'] 45 | const includedPaths = [] 46 | 47 | expect( 48 | canUseCache({ apiBaseUrl, originalUrl, includedCollections, includedGlobals, includedPaths }) 49 | ).toBeTruthy() 50 | }) 51 | 52 | it('returns true for custom paths', () => { 53 | const apiBaseUrl = '/api' 54 | const originalUrl = '/api/navigation/main-menu' 55 | const includedCollections = ['collection1', 'collection2'] 56 | const includedGlobals = ['main-menu'] 57 | const includedPaths = ['/navigation/main-menu'] 58 | 59 | expect( 60 | canUseCache({ apiBaseUrl, originalUrl, includedCollections, includedGlobals, includedPaths }) 61 | ).toBeTruthy() 62 | }) 63 | 64 | it('returns false for not matching custom paths', () => { 65 | const apiBaseUrl = '/api' 66 | const originalUrl = '/api/navigation/main-menu' 67 | const includedCollections = ['collection1', 'collection2'] 68 | const includedGlobals = ['main-menu'] 69 | const includedPaths = ['/navigation/'] 70 | 71 | expect( 72 | canUseCache({ apiBaseUrl, originalUrl, includedCollections, includedGlobals, includedPaths }) 73 | ).toBeFalsy() 74 | }) 75 | 76 | it('returns false for _preferences', () => { 77 | const apiBaseUrl = '/api' 78 | const originalUrl = '/api/_preferences/something' 79 | const includedCollections = ['collection1', 'collection2'] 80 | const includedGlobals = ['main-menu'] 81 | const includedPaths = [] 82 | 83 | expect( 84 | canUseCache({ apiBaseUrl, originalUrl, includedCollections, includedGlobals, includedPaths }) 85 | ).toBeFalsy() 86 | }) 87 | }) 88 | 89 | describe('getEntityName', () => { 90 | it('returns the correct collection name', () => { 91 | const apiBaseUrl = '/api' 92 | 93 | expect(getEntityName(apiBaseUrl, '/api/users/')).toBe('users') 94 | expect(getEntityName(apiBaseUrl, '/api/users/other')).toBe('users') 95 | expect(getEntityName(apiBaseUrl, '/api/posts/')).toBe('posts') 96 | expect(getEntityName(apiBaseUrl, '/api/comments/')).toBe('comments') 97 | expect(getEntityName(apiBaseUrl, '/api/comments/test')).toBe('comments') 98 | expect(getEntityName(apiBaseUrl, '/api/comments?where=1')).toBe('comments') 99 | expect(getEntityName(apiBaseUrl, '/api/globals/home?where=1', 'globals')).toBe('home') 100 | expect(getEntityName(apiBaseUrl, '/api/globals/home', 'globals')).toBe('home') 101 | }) 102 | 103 | it('returns null for invalid input', () => { 104 | const apiBaseUrl = '/api' 105 | expect(getEntityName(apiBaseUrl, '/other/comments/')).toBe(null) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/g6/n1cpmxld5m9__szk451z2gt00000gn/T/jest_dx", 15 | 16 | // Automatically clear mock calls, instances, contexts and results before every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: true, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | // coverageDirectory: 'coverage', 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: 'v8', 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | coverageReporters: [ 38 | // "json", 39 | 'text' 40 | // "lcov", 41 | // "clover" 42 | ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // The default configuration for fake timers 54 | // fakeTimers: { 55 | // "enableGlobally": false 56 | // }, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | // moduleFileExtensions: [ 80 | // "js", 81 | // "mjs", 82 | // "cjs", 83 | // "jsx", 84 | // "ts", 85 | // "tsx", 86 | // "json", 87 | // "node" 88 | // ], 89 | 90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | // modulePathIgnorePatterns: [], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | preset: 'ts-jest', 104 | 105 | // Run tests from one or more projects 106 | // projects: undefined, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state before every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: undefined, 119 | 120 | // Automatically restore mock state and implementation before every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | rootDir: 'src' 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | // setupFiles: [], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | // setupFilesAfterEnv: [], 139 | 140 | // The number of seconds after which a test is considered as slow and reported as such in the results. 141 | // slowTestThreshold: 5, 142 | 143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 144 | // snapshotSerializers: [], 145 | 146 | // The test environment that will be used for testing 147 | // testEnvironment: "jest-environment-node", 148 | 149 | // Options that will be passed to the testEnvironment 150 | // testEnvironmentOptions: {}, 151 | 152 | // Adds a location field to test results 153 | // testLocationInResults: false, 154 | 155 | // The glob patterns Jest uses to detect test files 156 | // testMatch: [ 157 | // "**/__tests__/**/*.[jt]s?(x)", 158 | // "**/?(*.)+(spec|test).[tj]s?(x)" 159 | // ], 160 | 161 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 162 | // testPathIgnorePatterns: [ 163 | // "/node_modules/" 164 | // ], 165 | 166 | // The regexp pattern or array of patterns that Jest uses to detect test files 167 | // testRegex: [], 168 | 169 | // This option allows the use of a custom results processor 170 | // testResultsProcessor: undefined, 171 | 172 | // This option allows use of a custom test runner 173 | // testRunner: "jest-circus/runner", 174 | 175 | // A map from regular expressions to paths to transformers 176 | // transform: undefined, 177 | 178 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 179 | // transformIgnorePatterns: [ 180 | // "/node_modules/", 181 | // "\\.pnp\\.[^\\/]+$" 182 | // ], 183 | 184 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 185 | // unmockedModulePathPatterns: undefined, 186 | 187 | // Indicates whether each individual test should be reported during the run 188 | // verbose: undefined, 189 | 190 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 191 | // watchPathIgnorePatterns: [], 192 | 193 | // Whether to use watchman for file crawling 194 | // watchman: true, 195 | } 196 | -------------------------------------------------------------------------------- /src/adapters/cacheHelpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateCacheHash, getCacheItem, invalidateCache, setCacheItem } from './cacheHelpers' 2 | import { crypto } from './crypto' 3 | import { initRedisContext, InitRedisContextParams, redisContext } from './redis' 4 | jest.mock('./redis') 5 | jest.mock('./crypto') 6 | 7 | const STUB_USER_COLLECTION = 'users' 8 | const STUB_REQUESTED_URL = '/api/example' 9 | const STUB_DIGESTED_VALUE = '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' 10 | const STUB_CACHE_HASH = 'namespace:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' 11 | const STUB_CACHE_ITEM = '{"data":{"id":123,"name":"John"}}' 12 | const STUB_JSON_CACHE_ITEM = { data: { id: 123, name: 'John' } } 13 | 14 | describe('cacheHelpers', () => { 15 | describe('generateCacheHash', () => { 16 | let hashMock: any 17 | beforeEach(() => { 18 | hashMock = { 19 | update: jest.fn().mockReturnThis(), 20 | digest: jest.fn().mockReturnValue(STUB_DIGESTED_VALUE) 21 | } 22 | ;(crypto.createHash).mockReturnValue(hashMock) 23 | }) 24 | 25 | it('sha256 should be used', () => { 26 | generateCacheHash({ 27 | userCollection: STUB_USER_COLLECTION, 28 | requestedUrl: STUB_REQUESTED_URL, 29 | authorization: '' 30 | }) 31 | expect(crypto.createHash).toHaveBeenCalledWith('sha256') 32 | }) 33 | 34 | it('should pass a string to the update', () => { 35 | const expectedValue = 'users-/api/example-Bearer 1234' 36 | generateCacheHash({ 37 | userCollection: STUB_USER_COLLECTION, 38 | requestedUrl: STUB_REQUESTED_URL, 39 | authorization: 'Bearer 1234' 40 | }) 41 | expect(hashMock.update).toHaveBeenCalledWith(expectedValue) 42 | }) 43 | 44 | it('should generate a cache hash for the given user collection and requested URL', () => { 45 | ;(redisContext.getNamespace).mockReturnValue('namespace') 46 | const result = generateCacheHash({ 47 | userCollection: STUB_USER_COLLECTION, 48 | requestedUrl: STUB_REQUESTED_URL, 49 | authorization: '' 50 | }) 51 | expect(result).toEqual(STUB_CACHE_HASH) 52 | }) 53 | }) 54 | 55 | describe('getCacheItem', () => { 56 | let spyedGenerateCacheHash: jest.SpyInstance 57 | 58 | beforeEach(() => { 59 | const actualCacheHelpers = jest.requireActual('./cacheHelpers') 60 | spyedGenerateCacheHash = jest.spyOn(actualCacheHelpers, 'generateCacheHash') 61 | ;(generateCacheHash as jest.Mock).mockReturnValue(STUB_CACHE_HASH) 62 | }) 63 | 64 | it('should return null if the redis client is not available', async () => { 65 | // Mock the redisContext module to return a null redis client 66 | ;(redisContext.getRedisClient).mockReturnValue(null) 67 | 68 | const result = await getCacheItem({ 69 | userCollection: STUB_USER_COLLECTION, 70 | requestedUrl: STUB_REQUESTED_URL, 71 | authorization: '' 72 | }) 73 | expect(result).toBeNull() 74 | expect(spyedGenerateCacheHash).not.toBeCalled() 75 | }) 76 | 77 | it('should return null if the cache item is not found in the cache', async () => { 78 | // Mock the redisContext module to return a mocked redis client 79 | const redisClientMock = { 80 | GET: jest.fn().mockResolvedValue(null) 81 | } 82 | ;(redisContext.getRedisClient).mockReturnValue(redisClientMock) 83 | 84 | const result = await getCacheItem({ 85 | userCollection: STUB_USER_COLLECTION, 86 | requestedUrl: STUB_REQUESTED_URL, 87 | authorization: '' 88 | }) 89 | expect(result).toBeNull() 90 | expect(spyedGenerateCacheHash).toBeCalledWith({ 91 | userCollection: STUB_USER_COLLECTION, 92 | requestedUrl: STUB_REQUESTED_URL, 93 | authorization: '' 94 | }) 95 | expect(redisClientMock.GET).toBeCalledWith(STUB_CACHE_HASH) 96 | }) 97 | 98 | it('should return the cache item if it is found in the cache', async () => { 99 | // Mock the redisContext module to return a mocked redis client 100 | const redisClientMock = { 101 | GET: jest.fn().mockResolvedValue(STUB_CACHE_ITEM) 102 | } 103 | ;(redisContext.getRedisClient).mockReturnValue(redisClientMock) 104 | 105 | const result = await getCacheItem({ 106 | userCollection: STUB_USER_COLLECTION, 107 | requestedUrl: STUB_REQUESTED_URL, 108 | authorization: '' 109 | }) 110 | expect(result).toEqual(STUB_CACHE_ITEM) 111 | expect(spyedGenerateCacheHash).toBeCalledWith({ 112 | userCollection: STUB_USER_COLLECTION, 113 | requestedUrl: STUB_REQUESTED_URL, 114 | authorization: '' 115 | }) 116 | expect(redisClientMock.GET).toBeCalledWith(STUB_CACHE_HASH) 117 | }) 118 | }) 119 | 120 | describe('setCacheItem', () => { 121 | let spyedGenerateCacheHash: jest.SpyInstance 122 | 123 | beforeEach(() => { 124 | const actualCacheHelpers = jest.requireActual('./cacheHelpers') 125 | spyedGenerateCacheHash = jest.spyOn(actualCacheHelpers, 'generateCacheHash') 126 | ;(generateCacheHash as jest.Mock).mockReturnValue(STUB_CACHE_HASH) 127 | }) 128 | 129 | it('should return immidiatly if redis client is not available', () => { 130 | // Mock the redisContext module to return a null redis client 131 | ;(redisContext.getRedisClient).mockReturnValue(null) 132 | 133 | setCacheItem({ 134 | userCollection: STUB_USER_COLLECTION, 135 | requestedUrl: STUB_REQUESTED_URL, 136 | authorization: '', 137 | body: STUB_JSON_CACHE_ITEM 138 | }) 139 | expect(spyedGenerateCacheHash).not.toBeCalled() 140 | }) 141 | 142 | it('should set the cache item in Redis', () => { 143 | // Mock the redisContext module to return a mocked redis client 144 | const redisClientMock = { 145 | SET: jest.fn(), 146 | SADD: jest.fn() 147 | } 148 | ;(redisContext.getRedisClient).mockReturnValue(redisClientMock) 149 | ;(redisContext.getIndexesName).mockReturnValue('indexes') 150 | 151 | setCacheItem({ 152 | userCollection: STUB_USER_COLLECTION, 153 | requestedUrl: STUB_REQUESTED_URL, 154 | authorization: '', 155 | body: STUB_JSON_CACHE_ITEM 156 | }) 157 | 158 | expect(redisClientMock.SET).toHaveBeenCalledWith(STUB_CACHE_HASH, STUB_CACHE_ITEM) 159 | expect(redisClientMock.SADD).toHaveBeenCalledWith('indexes', STUB_CACHE_HASH) 160 | }) 161 | 162 | it('should catch error if unable to set the cache item in Redis', () => { 163 | // Mock the redisContext module to return a mocked redis client 164 | const redisClientMock = { 165 | SET: jest.fn(() => { 166 | throw new Error('Error setting cache item') 167 | }), 168 | SADD: jest.fn() 169 | } 170 | ;(redisContext.getRedisClient).mockReturnValue(redisClientMock) 171 | ;(redisContext.getIndexesName).mockReturnValue('indexes') 172 | 173 | setCacheItem({ 174 | userCollection: STUB_USER_COLLECTION, 175 | requestedUrl: STUB_REQUESTED_URL, 176 | authorization: '', 177 | body: STUB_JSON_CACHE_ITEM 178 | }) 179 | 180 | expect(redisClientMock.SET).toHaveBeenCalledWith(STUB_CACHE_HASH, STUB_CACHE_ITEM) 181 | expect(redisClientMock.SADD).not.toHaveBeenCalled() 182 | }) 183 | }) 184 | describe('invalidateCache', () => { 185 | let getIndexesNameMock: jest.Mock 186 | 187 | beforeEach(() => { 188 | getIndexesNameMock = (redisContext.getIndexesName).mockReturnValue('indexes') 189 | }) 190 | 191 | it('should return immidiatly if redis client is not available', async () => { 192 | // Mock the redisContext module to return a null redis client 193 | ;(redisContext.getRedisClient).mockReturnValue(null) 194 | 195 | await invalidateCache() 196 | expect(getIndexesNameMock).not.toBeCalled() 197 | }) 198 | 199 | it('should invalidate the cache in Redis', async () => { 200 | // Mock the redisContext module to return a mocked redis client 201 | const redisClientMock = { 202 | SMEMBERS: jest.fn(() => Promise.resolve(['index1', 'index2'])), 203 | DEL: jest.fn(), 204 | SREM: jest.fn() 205 | } 206 | ;(redisContext.getRedisClient).mockReturnValue(redisClientMock) 207 | 208 | await invalidateCache() 209 | 210 | expect(redisClientMock.SMEMBERS).toHaveBeenCalledWith('indexes') 211 | expect(redisClientMock.DEL).toHaveBeenCalledWith('index1') 212 | expect(redisClientMock.DEL).toHaveBeenCalledWith('index2') 213 | expect(redisClientMock.SREM).toHaveBeenCalledWith('indexes', 'index1') 214 | expect(redisClientMock.SREM).toHaveBeenCalledWith('indexes', 'index2') 215 | }) 216 | 217 | it('should throw an error if unable to invalidate the cache in Redis', async () => { 218 | console.log = jest.fn() 219 | // Mock the redisContext module to return a mocked redis client 220 | const redisClientMock = { 221 | SMEMBERS: jest.fn().mockRejectedValue(new Error('Error getting cache indexes')), 222 | DEL: jest.fn(), 223 | SREM: jest.fn() 224 | } 225 | ;(redisContext.getRedisClient).mockReturnValue(redisClientMock) 226 | 227 | await expect(invalidateCache()).rejects.toThrow('Error getting cache indexes') 228 | }) 229 | }) 230 | 231 | describe('initCache', () => { 232 | it('should initialize the Redis context with the given parameters', () => { 233 | const params: InitRedisContextParams = { 234 | url: 'redis://localhost:6379', 235 | namespace: 'payload', 236 | indexesName: 'indexes' 237 | } 238 | 239 | // Mock the initRedisContext function to verify that it is called with the correct parameters 240 | const getRedisClientMock = (initRedisContext).mockImplementation(() => {}) 241 | 242 | initRedisContext(params) 243 | 244 | // Assert that the initRedisContext function was called with the correct parameters 245 | expect(getRedisClientMock).toHaveBeenCalledWith(params) 246 | }) 247 | }) 248 | }) 249 | --------------------------------------------------------------------------------