├── test ├── samples │ └── image.png ├── helper │ └── index.js ├── unit │ └── lib-matcher.spec.js └── e2e │ ├── 12-real-world.e2e.js │ ├── 11-invalid-settings.e2e.js │ ├── 00-plug.e2e.js │ ├── 02-match-request-methods.e2e.js │ ├── 07-match-response-body.e2e.js │ ├── 08-mode.e2e.js │ ├── 01-match-request-route.e2e.js │ ├── 05-match-request-headers.e2e.js │ ├── 06-match-response-headers.e2e.js │ ├── 09-storages.e2e.js │ ├── 03-match-request-query.e2e.js │ ├── 10-dataset.e2e.js │ └── 04-match-request-body.e2e.js ├── .gitignore ├── src ├── validate │ ├── storage.js │ ├── settings.js │ └── rule.js ├── match.js ├── storage │ ├── index.js │ ├── memory.js │ └── fs.js ├── matcher.js ├── lib.js └── plugin.js ├── settings └── default.js ├── package.json ├── README.md └── doc └── README.md /test/samples/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simone-sanfratello/fastify-peekaboo/HEAD/test/samples/image.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dirs 2 | /.vscode/ 3 | /dist/ 4 | /log/ 5 | /pid/ 6 | /node_modules/ 7 | /coverage/ 8 | /debug 9 | 10 | # logs 11 | *.log 12 | *tgz 13 | /package-lock.json 14 | 15 | # sys and temp files 16 | *Thumbs.db 17 | *.DS_Store 18 | *._* 19 | .*.swp 20 | .~* 21 | .nyc_output 22 | 23 | package-lock.json 24 | -------------------------------------------------------------------------------- /src/validate/storage.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const superstruct = require('superstruct') 4 | const lib = require('../lib') 5 | 6 | const s = superstruct.struct 7 | 8 | const storage = s({ 9 | mode: s.optional(s.enum(Object.values(lib.STORAGE))), 10 | config: s.optional(s({ path: 'string?' })) 11 | }) 12 | 13 | module.exports = storage 14 | -------------------------------------------------------------------------------- /settings/default.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const default_ = { 4 | rules: [{ 5 | request: { 6 | methods: true, 7 | route: true 8 | }, 9 | response: { 10 | status: (code) => code > 199 && code < 300 11 | } 12 | }], 13 | storage: { mode: 'memory' }, 14 | expire: 86400000, // 1 day in ms 15 | xheader: true, 16 | log: false, 17 | mode: 'memoize' 18 | } 19 | 20 | module.exports = default_ 21 | -------------------------------------------------------------------------------- /src/validate/settings.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const superstruct = require('superstruct') 4 | const rule = require('./rule') 5 | const storage = require('./storage') 6 | const default_ = require(('../../settings/default')) 7 | 8 | const s = superstruct.struct 9 | 10 | const settings = s({ 11 | rules: [rule], 12 | storage: s.optional(storage), 13 | expire: 'number', 14 | xheader: 'boolean', 15 | noinfo: 'boolean?', 16 | mode: s.enum(['memoize', 'off', 'collector', 'stock']), 17 | log: 'boolean' 18 | }, default_) 19 | 20 | module.exports = settings 21 | -------------------------------------------------------------------------------- /src/match.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const lib = require('./lib') 4 | const matcher = require('./matcher') 5 | 6 | const match = { 7 | /** 8 | * @param {fastify.Request} request 9 | * @return {{rule,has}|undefined} 10 | */ 11 | request: function (request, rules) { 12 | for (let index = 0; index < rules.length; index++) { 13 | const rule = rules[index] 14 | if (matcher.request(request, rule.request)) { 15 | return { rule, hash: lib.hash.request(request, rule.request) } 16 | } 17 | } 18 | }, 19 | 20 | /** 21 | * @param {fastify.Request} request 22 | * @return {hash|undefined} 23 | */ 24 | response: function (response, rule) { 25 | if (matcher.response(response, rule.response)) { 26 | return lib.hash.response(response, rule.response) 27 | } 28 | } 29 | 30 | } 31 | 32 | module.exports = match 33 | -------------------------------------------------------------------------------- /test/helper/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | if (!process.env.NODE_ENV) { 4 | process.env.NODE_ENV = 'test' 5 | } 6 | 7 | const got = require('got') 8 | const options = {} // retry: 0, throwHttpErrors: false } 9 | 10 | const helper = { 11 | sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), 12 | fastify: { 13 | _port: null, 14 | start: async function (instance) { 15 | await instance.listen(0) 16 | instance.server.unref() 17 | }, 18 | stop: async function (instance) { 19 | await instance.close() 20 | }, 21 | url: function (instance, path) { 22 | return `http://127.0.0.1:${instance.server.address().port}${path}` 23 | } 24 | }, 25 | request: async function (request) { 26 | return got({ ...options, ...request }) 27 | }, 28 | assert: { 29 | isId: function (id) { 30 | return id.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/) 31 | } 32 | } 33 | } 34 | 35 | module.exports = helper 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-peekaboo", 3 | "version": "2.3.1", 4 | "description": "fastify plugin for memoize responses by expressive settings", 5 | "main": "src/plugin.js", 6 | "files": [ 7 | "src/", 8 | "settings/" 9 | ], 10 | "engines": { 11 | "node": ">=12" 12 | }, 13 | "dependencies": { 14 | "a-toolbox": "^1.7.3", 15 | "fast-json-stable-stringify": "^2.1.0", 16 | "fastify-plugin": "^3.0.0", 17 | "fs-extra": "^9.1.0", 18 | "superstruct": "^0.8.4", 19 | "uuid": "^8.3.2" 20 | }, 21 | "devDependencies": { 22 | "fastify": "^3.12.0", 23 | "got": "^11.8.2", 24 | "pre-commit": "^1.2.2", 25 | "standard": "^16", 26 | "tap": "^14.11.0" 27 | }, 28 | "pre-commit": [ 29 | "test" 30 | ], 31 | "scripts": { 32 | "format": "standard --fix", 33 | "test": "tap --files test/e2e/*.e2e.js test/unit/*.spec.js", 34 | "test-coverage": "tap test/* --cov" 35 | }, 36 | "author": "Simone Sanfratello ", 37 | "license": "MIT", 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/braceslab/fastify-peekaboo.git" 41 | }, 42 | "keywords": [ 43 | "cache" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /src/validate/rule.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const superstruct = require('superstruct') 4 | const lib = require('../lib') 5 | 6 | const s = superstruct.superstruct({ 7 | types: { 8 | '*': v => v === '*', 9 | matchingStringObject: object => { 10 | for (const key in object) { 11 | try { 12 | matchingString(object[key]) 13 | } catch (error) { 14 | return false 15 | } 16 | } 17 | return true 18 | } 19 | } 20 | }) 21 | 22 | const methods = Object.values(lib.METHOD) 23 | methods.shift() // trim '*' 24 | 25 | const matchingNumber = s.union(['boolean', 'number', 'regexp', 'function']) 26 | const matchingString = s.union(['boolean', 'string', 'regexp', 'function']) 27 | const matchingList = s.union(['boolean', s.enum(methods), s.array([s.enum(methods)]), '*', 'regexp', 'function']) 28 | const matchingObject = s.union(['boolean', 'function', 'matchingStringObject']) 29 | 30 | const rule = s({ 31 | request: { 32 | methods: matchingList, 33 | route: matchingString, 34 | headers: s.optional(matchingObject), 35 | body: s.optional(matchingObject), 36 | query: s.optional(matchingObject) 37 | }, 38 | response: s.optional({ 39 | status: s.optional(matchingNumber), 40 | headers: s.optional(matchingObject), 41 | body: s.optional(matchingObject) 42 | }) 43 | }) 44 | 45 | module.exports = rule 46 | -------------------------------------------------------------------------------- /test/unit/lib-matcher.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const matcher = require('../../src/matcher') 5 | 6 | tap.test('matcher.string', (test) => { 7 | const _cases = [ 8 | { input: { value: '200', match: /^2/ }, output: true }, 9 | { input: { value: 'hello', match: null }, output: false } 10 | ] 11 | test.plan(_cases.length) 12 | for (const _case of _cases) { 13 | const { input, output } = _case 14 | test.equal(matcher.string(input.value, input.match), output) 15 | } 16 | }) 17 | 18 | tap.test('matcher.number', (test) => { 19 | const _cases = [ 20 | { input: { value: 200, match: /^2/ }, output: true }, 21 | { input: { value: undefined, match: /^2/ }, output: false }, 22 | { input: { value: 200, match: true }, output: true }, 23 | { input: { value: 300, match: (value) => value < 300 }, output: false }, 24 | { input: { value: 200, match: false }, output: false }, 25 | { input: { value: 200, match: null }, output: false } 26 | ] 27 | test.plan(_cases.length) 28 | for (const _case of _cases) { 29 | const { input, output } = _case 30 | test.equal(matcher.number(input.value, input.match), output) 31 | } 32 | }) 33 | 34 | tap.test('matcher.list', (test) => { 35 | const _cases = [ 36 | { input: { value: 'get', match: false }, output: false }, 37 | { input: { value: 'get', match: (value) => value === 'post' }, output: false } 38 | ] 39 | test.plan(_cases.length) 40 | for (const _case of _cases) { 41 | const { input, output } = _case 42 | test.equal(matcher.list(input.value, input.match), output) 43 | } 44 | }) 45 | 46 | tap.test('matcher.object', (test) => { 47 | const _cases = [ 48 | { input: { value: { a: 'b' }, match: false }, output: false } 49 | ] 50 | test.plan(_cases.length) 51 | for (const _case of _cases) { 52 | const { input, output } = _case 53 | test.equal(matcher.object(input.object, input.match), output) 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /test/e2e/12-real-world.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const fs = require('fs') 6 | const path = require('path') 7 | const got = require('got') 8 | const helper = require('../helper') 9 | 10 | const peekaboo = require('../../src/plugin') 11 | 12 | tap.test('peekaboo with streams', 13 | async (_test) => { 14 | _test.plan(1) 15 | const _fastify = fastify({ logger: { level: 'trace' } }) 16 | _fastify 17 | .register(peekaboo) 18 | 19 | _fastify.get('/google', async (request, response) => { 20 | response.send(got.stream('https://www.google.com')) 21 | }) 22 | 23 | _fastify.get('/remote/image', async (request, response) => { 24 | response.send(got.stream('https://braceslab.com/img/header.jpg')) 25 | }) 26 | 27 | _fastify.get('/local/image', async (request, response) => { 28 | response.send(fs.createReadStream(path.join(__dirname, '../samples/image.png'))) 29 | }) 30 | 31 | let url 32 | try { 33 | await helper.fastify.start(_fastify) 34 | 35 | url = helper.fastify.url(_fastify, '/google') 36 | await helper.request({ url }) 37 | let _response = await helper.request({ url }) 38 | if (!_response.headers['x-peekaboo']) { 39 | _test.fail('should use cache, but it doesnt') 40 | } 41 | 42 | url = helper.fastify.url(_fastify, '/remote/image') 43 | await helper.request({ url }) 44 | _response = await helper.request({ url }) 45 | if (!_response.headers['x-peekaboo']) { 46 | _test.fail('should use cache, but it doesnt') 47 | } 48 | 49 | url = helper.fastify.url(_fastify, '/local/image') 50 | await helper.request({ url }) 51 | _response = await helper.request({ url }) 52 | if (!_response.headers['x-peekaboo']) { 53 | _test.fail('should use cache, but it doesnt') 54 | } 55 | 56 | await helper.fastify.stop(_fastify) 57 | _test.pass() 58 | } catch (error) { 59 | console.error(url, error) 60 | _test.threw(error) 61 | } 62 | }) 63 | -------------------------------------------------------------------------------- /test/e2e/11-invalid-settings.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | tap.test('peekaboo invalid settings #1', async (_test) => { 10 | _test.plan(1) 11 | 12 | const _fastify = fastify() 13 | try { 14 | _fastify.register(peekaboo, { 15 | rules: [{ 16 | request: { 17 | methods: true, 18 | route: true, 19 | headers: { a: 99 } 20 | } 21 | }] 22 | }) 23 | await helper.fastify.start(_fastify) 24 | await helper.fastify.stop(_fastify) 25 | _test.fail() 26 | } catch (error) { 27 | console.log(error.toString()) 28 | await helper.fastify.stop(_fastify) 29 | _test.pass() 30 | } 31 | }) 32 | 33 | tap.test('peekaboo invalid settings #2', async (_test) => { 34 | _test.plan(1) 35 | 36 | const _fastify = fastify() 37 | try { 38 | _fastify.register(peekaboo, { 39 | rules: [{ 40 | request: { 41 | methods: undefined, 42 | route: '/home' 43 | } 44 | }] 45 | }) 46 | await helper.fastify.start(_fastify) 47 | await helper.fastify.stop(_fastify) 48 | _test.fail() 49 | } catch (error) { 50 | console.log(error.toString()) 51 | await helper.fastify.stop(_fastify) 52 | _test.pass() 53 | } 54 | }) 55 | 56 | tap.test('peekaboo invalid settings #3', async (_test) => { 57 | _test.plan(1) 58 | 59 | const _fastify = fastify() 60 | try { 61 | _fastify.register(peekaboo, { 62 | rules: [{ 63 | request: { 64 | methods: ['miao', 'put'], 65 | route: '/home' 66 | } 67 | }] 68 | }) 69 | await helper.fastify.start(_fastify) 70 | await helper.fastify.stop(_fastify) 71 | _test.fail() 72 | } catch (error) { 73 | console.log(error.toString()) 74 | await helper.fastify.stop(_fastify) 75 | _test.pass() 76 | } 77 | }) 78 | 79 | tap.test('peekaboo invalid settings #4', async (_test) => { 80 | _test.plan(1) 81 | 82 | const _fastify = fastify() 83 | try { 84 | _fastify.register(peekaboo, { 85 | rules: [{ 86 | request: { 87 | methods: 'drink', 88 | route: '/home' 89 | } 90 | }] 91 | }) 92 | await helper.fastify.start(_fastify) 93 | await helper.fastify.stop(_fastify) 94 | _test.fail() 95 | } catch (error) { 96 | console.log(error.toString()) 97 | await helper.fastify.stop(_fastify) 98 | _test.pass() 99 | } 100 | }) 101 | -------------------------------------------------------------------------------- /src/storage/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const lib = require('../lib') 4 | const FsStorage = require('./fs') 5 | const MemoryStorage = require('./memory') 6 | 7 | /** 8 | * @param {object} [options] 9 | * @param {peekaboo.STORAGE} [options.type=lib.STORAGE.MEMORY] 10 | * @param {number} [options.expire=60000] 1 min 11 | */ 12 | const Storage = function (options) { 13 | let _storage 14 | 15 | const _init = function (options) { 16 | if (!options.mode) { 17 | options.mode = lib.STORAGE.MEMORY 18 | } 19 | if (!options.expire) { 20 | options.expire = 60 * 1000 21 | } 22 | 23 | switch (options.mode) { 24 | case lib.STORAGE.FS: 25 | _storage = new FsStorage({ 26 | path: options.config.path 27 | }) 28 | break 29 | case lib.STORAGE.MEMORY: 30 | default: 31 | _storage = new MemoryStorage() 32 | } 33 | } 34 | 35 | /** 36 | * @async 37 | * @param {string} key 38 | */ 39 | const get = function (key) { 40 | return _storage.get(key) 41 | } 42 | 43 | /** 44 | * @async 45 | * @param {string} key 46 | */ 47 | const set = function (key, data) { 48 | return _storage.set(key, data, options.expire) 49 | } 50 | 51 | /** 52 | * @async 53 | */ 54 | const list = function () { 55 | return _storage.list() 56 | } 57 | 58 | /** 59 | * @async 60 | * @param {string} key 61 | */ 62 | const rm = function (key) { 63 | return _storage.rm(key) 64 | } 65 | 66 | /** 67 | * @async 68 | */ 69 | const clear = function () { 70 | return _storage.clear() 71 | } 72 | 73 | const dataset = { 74 | /** 75 | * @async 76 | * @param {string} name 77 | * @throws 78 | */ 79 | create: function (name) { 80 | if (!name) { 81 | throw Error('INVALID_DATASET_NAME') 82 | } 83 | return _storage.dataset.create(name) 84 | }, 85 | /** 86 | * @async 87 | * @param {hash} id 88 | * @param {string} name 89 | * @throws 90 | */ 91 | update: function (id, name) { 92 | if (!name) { 93 | throw Error('INVALID_DATASET_NAME') 94 | } 95 | return _storage.dataset.update(id, name) 96 | }, 97 | /** 98 | * @async 99 | * @param {hash} id 100 | * @throws 101 | */ 102 | remove: function (id) { 103 | return _storage.dataset.remove(id) 104 | }, 105 | get: function () { 106 | return _storage.dataset.get() 107 | }, 108 | /** 109 | * @async 110 | * @param {hash} id 111 | * @throws 112 | */ 113 | set: function (id) { 114 | return _storage.dataset.set(id) 115 | }, 116 | /** 117 | * get the id of the dataset currently in use 118 | * @returns {hash} 119 | */ 120 | current: function () { 121 | return _storage.dataset.current() 122 | }, 123 | } 124 | 125 | _init(options) 126 | 127 | return { 128 | get, 129 | set, 130 | rm, 131 | list, 132 | clear, 133 | dataset 134 | } 135 | } 136 | 137 | module.exports = Storage 138 | -------------------------------------------------------------------------------- /test/e2e/00-plug.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | tap.test('peekaboo plugin is loaded', 10 | async (_test) => { 11 | _test.plan(1) 12 | const _fastify = fastify() 13 | await _fastify 14 | .register(peekaboo) 15 | .ready() 16 | await helper.fastify.stop(_fastify) 17 | _test.pass(_fastify.peekaboo) 18 | }) 19 | 20 | tap.test('peekaboo plugin is working (basic match)', 21 | async (_test) => { 22 | _test.plan(1) 23 | const _fastify = fastify({ logger: { level: 'trace' } }) 24 | _fastify.register(peekaboo, { 25 | xheader: true, 26 | rules: [{ 27 | request: { 28 | methods: 'get', 29 | route: '/home' 30 | } 31 | }] 32 | }) 33 | 34 | _fastify.get('/home', async (request, response) => { 35 | response.send('hey there') 36 | }) 37 | 38 | try { 39 | await helper.fastify.start(_fastify) 40 | const url = helper.fastify.url(_fastify, '/home') 41 | await helper.request({ url }) 42 | const _response = await helper.request({ url }) 43 | if (!_response.headers['x-peekaboo']) { 44 | _test.fail() 45 | } 46 | await helper.fastify.stop(_fastify) 47 | _test.pass() 48 | } catch (error) { 49 | _test.threw(error) 50 | } 51 | }) 52 | 53 | tap.test('peekaboo plugin is working, no xheader', 54 | async (_test) => { 55 | _test.plan(2) 56 | const _fastify = fastify({ logger: { level: 'trace' } }) 57 | _fastify.register(peekaboo, { 58 | xheader: false 59 | }) 60 | 61 | let i = 1 62 | _fastify.get('/home', async (request, response) => { 63 | response.send(i) 64 | i++ 65 | }) 66 | 67 | try { 68 | await helper.fastify.start(_fastify) 69 | const url = helper.fastify.url(_fastify, '/home') 70 | await helper.request({ url }) 71 | const _response = await helper.request({ url }) 72 | if (_response.headers['x-peekaboo']) { 73 | _test.fail() 74 | } 75 | _test.equal(_response.body, '1') 76 | 77 | await helper.fastify.stop(_fastify) 78 | _test.pass() 79 | } catch (error) { 80 | _test.threw(error) 81 | } 82 | }) 83 | 84 | tap.test('peekaboo plugin is working (default settings)', 85 | async (_test) => { 86 | _test.plan(1) 87 | const _fastify = fastify() 88 | _fastify 89 | .register(peekaboo, { 90 | xheader: true 91 | }) 92 | 93 | _fastify.all('/home', async (request, response) => { 94 | response.send('this is the home') 95 | }) 96 | 97 | try { 98 | await helper.fastify.start(_fastify) 99 | const url = helper.fastify.url(_fastify, '/home') 100 | await helper.request({ url }) 101 | const _response = await helper.request({ url }) 102 | if (!_response.headers['x-peekaboo']) { 103 | _test.fail() 104 | } 105 | await helper.fastify.stop(_fastify) 106 | _test.pass() 107 | } catch (error) { 108 | _test.threw(error) 109 | } 110 | }) 111 | -------------------------------------------------------------------------------- /src/matcher.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const url = require('url') 4 | 5 | const matcher = { 6 | request: function (request, rule) { 7 | const route = new url.URL('http://a.b' + request.raw.url).pathname 8 | 9 | if (!matcher.list(request.method.toLowerCase(), rule.methods) || 10 | !matcher.string(route, rule.route)) { 11 | return false 12 | } 13 | 14 | if (rule.headers && !matcher.object(request.headers, rule.headers)) { 15 | return false 16 | } 17 | 18 | if (rule.query && !matcher.object(request.query, rule.query)) { 19 | return false 20 | } 21 | 22 | if (rule.body && !matcher.object(request.body, rule.body)) { 23 | return false 24 | } 25 | return true 26 | }, 27 | 28 | response: function (response, rule) { 29 | if (!rule) { 30 | return true 31 | } 32 | if (rule.status && !matcher.number(response.status, rule.status)) { 33 | return false 34 | } 35 | if (rule.headers && !matcher.object(response.headers, rule.headers)) { 36 | return false 37 | } 38 | if (rule.body && !matcher.object(response.body, rule.body)) { 39 | return false 40 | } 41 | return true 42 | }, 43 | 44 | string: function (string, match) { 45 | const typeOf = typeof match 46 | if (typeOf === 'boolean') { 47 | return (match && string !== undefined) || (!match && string === undefined) 48 | } 49 | if (typeOf === 'string') { 50 | return string === match 51 | } 52 | if (typeOf === 'function') { 53 | return !!match(string) 54 | } 55 | if (match instanceof RegExp) { 56 | return match.test(string) 57 | } 58 | return false 59 | }, 60 | 61 | number: function (number, match) { 62 | if (number === undefined) { 63 | return false 64 | } 65 | const typeOf = typeof match 66 | if (typeOf === 'boolean') { 67 | return match 68 | } 69 | if (typeOf === 'string' || typeOf === 'number') { 70 | // eslint-disable-next-line 71 | return number == match 72 | } 73 | if (typeOf === 'function') { 74 | return !!match(number) 75 | } 76 | if (match instanceof RegExp) { 77 | return match.test(number.toString()) 78 | } 79 | return false 80 | }, 81 | 82 | list: function (value, match) { 83 | if (match === '*' || match === true) { 84 | return true 85 | } 86 | if (match === false) { 87 | return false 88 | } 89 | const typeOf = typeof match 90 | if (typeOf === 'function') { 91 | return !!match(value) 92 | } 93 | if (typeOf === 'string') { 94 | return match === value 95 | } 96 | return match.includes(value) 97 | }, 98 | 99 | object: function (object, match) { 100 | if (match === true) { 101 | return true 102 | } 103 | if (match === false) { 104 | return false 105 | } 106 | if (typeof match === 'function') { 107 | return !!match(object) 108 | } 109 | for (const key in match) { 110 | if (!matcher.string(object[key], match[key])) { 111 | return false 112 | } 113 | } 114 | return true 115 | } 116 | 117 | } 118 | 119 | module.exports = matcher 120 | -------------------------------------------------------------------------------- /src/storage/memory.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { v1: uuid } = require('uuid') 4 | 5 | const MemoryStorage = function () { 6 | const _dataset = {} 7 | const _store = {} 8 | 9 | const _init = async function () { 10 | _dataset.default = uuid() 11 | _dataset.entries = { 12 | [_dataset.default]: 'default' 13 | } 14 | _dataset.current = _dataset.default 15 | _store[_dataset.default] = {} 16 | _dataset.store = _store[_dataset.default] 17 | } 18 | 19 | const get = async function (key) { 20 | if (!_dataset.store[key]) { 21 | return 22 | } 23 | if (_dataset.store[key].expire > Date.now()) { 24 | return _dataset.store[key] 25 | } 26 | rm(key) 27 | } 28 | 29 | const set = async function (key, data, expire) { 30 | if (expire && !data.expire) { 31 | data.expire = Date.now() + expire 32 | } 33 | _dataset.store[key] = data 34 | } 35 | 36 | const rm = async function (key) { 37 | delete _dataset.store[key] 38 | } 39 | 40 | const clear = async function () { 41 | for (const key in _dataset.store) { 42 | delete _dataset.store[key] 43 | } 44 | } 45 | 46 | const list = async function () { 47 | return Object.keys(_dataset.store) 48 | } 49 | 50 | const dataset = { 51 | /** 52 | * @async 53 | * @param {string} name 54 | * @returns {hash} id 55 | * @throws 56 | */ 57 | create: async function (name) { 58 | const id = uuid() 59 | _dataset.entries[id] = name 60 | _store[id] = {} 61 | return id 62 | }, 63 | /** 64 | * @async 65 | * @param {hash} id 66 | * @param {string} name 67 | * @throws 68 | */ 69 | update: async function (id, name) { 70 | if (!_dataset.entries[id]) { 71 | throw Error('INVALID_DATASET_ID') 72 | } 73 | _dataset.entries[id] = name 74 | }, 75 | /** 76 | * @async 77 | * @param {hash} id 78 | * @throws 79 | */ 80 | remove: async function (id) { 81 | if (!_dataset.entries[id]) { 82 | throw Error('INVALID_DATASET_ID') 83 | } 84 | if (id == _dataset.default) { 85 | throw Error('INVALID_DATASET_OPERATION_CANT_REMOVE_DEFAULT') 86 | } 87 | delete _dataset.entries[id] 88 | delete _store[id] 89 | if (_dataset.current == id) { 90 | dataset.set(_dataset.default) 91 | } 92 | }, 93 | /** 94 | * @async 95 | */ 96 | get: async function () { 97 | return { 98 | entries: { ..._dataset.entries }, 99 | current: _dataset.current, 100 | default: _dataset.default 101 | } 102 | }, 103 | current: function () { 104 | return _dataset.current 105 | }, 106 | /** 107 | * @async 108 | * @param {hash} id 109 | * @throws error if `id` is not a valid dataset id 110 | */ 111 | set: async function (id) { 112 | if (!_dataset.entries[id]) { 113 | throw Error('INVALID_DATASET_CURRENT_VALUE') 114 | } 115 | _dataset.current = id 116 | _dataset.store = _store[id] 117 | } 118 | } 119 | 120 | _init() 121 | 122 | return { 123 | get, 124 | set, 125 | rm, 126 | list, 127 | clear, 128 | dataset 129 | } 130 | } 131 | 132 | module.exports = MemoryStorage 133 | -------------------------------------------------------------------------------- /test/e2e/02-match-request-methods.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | tap.test('peekaboo matching by request methods (*)', 10 | async (_test) => { 11 | _test.plan(4) 12 | const _fastify = fastify() 13 | _fastify 14 | .register(peekaboo, { 15 | xheader: true, 16 | rules: [{ 17 | request: { 18 | methods: '*', 19 | route: true 20 | } 21 | }] 22 | }) 23 | 24 | _fastify.all('/resource', async (request, response) => { 25 | response.send('in ' + request.req.method) 26 | }) 27 | 28 | await helper.fastify.start(_fastify) 29 | 30 | try { 31 | const url = helper.fastify.url(_fastify, '/resource') 32 | await helper.request({ method: 'post', url }) 33 | const _response = await helper.request({ method: 'post', url }) 34 | if (!_response.headers['x-peekaboo']) { 35 | _test.fail() 36 | } 37 | _test.equal(_response.body, 'in POST') 38 | } catch (error) { 39 | _test.threw(error) 40 | } 41 | 42 | try { 43 | const url = helper.fastify.url(_fastify, '/resource') 44 | await helper.request({ method: 'delete', url }) 45 | const _response = await helper.request({ method: 'delete', url }) 46 | if (!_response.headers['x-peekaboo']) { 47 | _test.fail() 48 | } 49 | _test.equal(_response.body, 'in DELETE') 50 | } catch (error) { 51 | _test.threw(error) 52 | } 53 | 54 | try { 55 | const url = helper.fastify.url(_fastify, '/not-matching') 56 | await helper.request({ url }) 57 | } catch (error) { 58 | _test.pass() 59 | } 60 | 61 | await helper.fastify.stop(_fastify) 62 | _test.pass() 63 | }) 64 | 65 | tap.test('peekaboo matching by request methods (string)', 66 | async (_test) => { 67 | _test.plan(4) 68 | const _fastify = fastify() 69 | _fastify 70 | .register(peekaboo, { 71 | xheader: true, 72 | rules: [{ 73 | request: { 74 | methods: 'put', 75 | route: true 76 | } 77 | }] 78 | }) 79 | 80 | _fastify.put('/resource', async (request, response) => { 81 | response.send('in ' + request.req.method) 82 | }) 83 | 84 | _fastify.delete('/resource', async (request, response) => { 85 | response.send('in ' + request.req.method) 86 | }) 87 | 88 | await helper.fastify.start(_fastify) 89 | 90 | try { 91 | const url = helper.fastify.url(_fastify, '/resource') 92 | await helper.request({ method: 'put', url }) 93 | const _response = await helper.request({ method: 'put', url }) 94 | if (!_response.headers['x-peekaboo']) { 95 | _test.fail() 96 | } 97 | _test.equal(_response.body, 'in PUT') 98 | } catch (error) { 99 | _test.threw(error) 100 | } 101 | 102 | try { 103 | const url = helper.fastify.url(_fastify, '/resource') 104 | await helper.request({ method: 'delete', url }) 105 | const _response = await helper.request({ method: 'delete', url }) 106 | if (_response.headers['x-peekaboo']) { 107 | _test.fail() 108 | } 109 | _test.equal(_response.body, 'in DELETE') 110 | } catch (error) { 111 | _test.threw(error) 112 | } 113 | 114 | try { 115 | const url = helper.fastify.url(_fastify, '/resource') 116 | await helper.request({ url }) 117 | } catch (error) { 118 | _test.pass() 119 | } 120 | 121 | await helper.fastify.stop(_fastify) 122 | _test.pass() 123 | }) 124 | -------------------------------------------------------------------------------- /test/e2e/07-match-response-body.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | tap.test('peekaboo matching by response body (object)', 10 | async (_test) => { 11 | _test.plan(1) 12 | const _fastify = fastify() 13 | _fastify 14 | .register(peekaboo, { 15 | xheader: true, 16 | rules: [{ 17 | request: { 18 | methods: '*', 19 | route: true 20 | }, 21 | response: { 22 | body: { 23 | user: 'Alice' 24 | } 25 | } 26 | }] 27 | }) 28 | 29 | _fastify.get('/user/:id', async (request, response) => { 30 | response.send({ user: 'Alice' }) 31 | }) 32 | 33 | _fastify.get('/admin', async (request, response) => { 34 | response.code(403).send('ERROR_INVALID_AUTH') 35 | }) 36 | 37 | await helper.fastify.start(_fastify) 38 | 39 | try { 40 | const url = helper.fastify.url(_fastify, '/user/1012') 41 | await helper.request({ url }) 42 | const _response = await helper.request({ url }) 43 | if (!_response.headers['x-peekaboo']) { 44 | _test.fail('not response from cache') 45 | } 46 | } catch (error) { 47 | _test.threw(error) 48 | } 49 | 50 | try { 51 | const url = helper.fastify.url(_fastify, '/admin') 52 | await helper.request({ url }) 53 | } catch (error) {} 54 | 55 | const url = helper.fastify.url(_fastify, '/admin') 56 | const _response = await helper.request({ url, throwHttpErrors: false }) 57 | if (_response.headers['x-peekaboo']) { 58 | _test.fail('response from cache') 59 | } 60 | 61 | await helper.fastify.stop(_fastify) 62 | _test.pass() 63 | }) 64 | 65 | tap.test('peekaboo matching by response body (function)', 66 | async (_test) => { 67 | _test.plan(1) 68 | const _fastify = fastify() 69 | _fastify 70 | .register(peekaboo, { 71 | xheader: true, 72 | rules: [{ 73 | request: { 74 | methods: '*', 75 | route: true 76 | }, 77 | response: { 78 | body: function (body) { 79 | try { 80 | return body.indexOf('Alice') !== -1 81 | } catch (error) {} 82 | } 83 | } 84 | }] 85 | }) 86 | 87 | _fastify.get('/user/:id', async (request, response) => { 88 | response.send({ user: 'Alice' }) 89 | }) 90 | 91 | _fastify.get('/admin', async (request, response) => { 92 | response.send('ERROR') 93 | }) 94 | 95 | _fastify.get('/content', async (request, response) => { 96 | response.send(' ... Alice ...') 97 | }) 98 | 99 | await helper.fastify.start(_fastify) 100 | 101 | try { 102 | const url = helper.fastify.url(_fastify, '/content') 103 | await helper.request({ url }) 104 | const _response = await helper.request({ url }) 105 | if (!_response.headers['x-peekaboo']) { 106 | _test.fail('not response from cache') 107 | } 108 | } catch (error) { 109 | _test.threw(error) 110 | } 111 | 112 | try { 113 | const url = helper.fastify.url(_fastify, '/user/1012') 114 | await helper.request({ url }) 115 | const _response = await helper.request({ url }) 116 | if (_response.headers['x-peekaboo']) { 117 | _test.fail('response from cache') 118 | } 119 | } catch (error) { 120 | _test.threw(error) 121 | } 122 | 123 | try { 124 | const url = helper.fastify.url(_fastify, '/admin') 125 | await helper.request({ url }) 126 | const _response = await helper.request({ url }) 127 | if (_response.headers['x-peekaboo']) { 128 | _test.fail('response from cache') 129 | } 130 | } catch (error) { 131 | _test.threw(error) 132 | } 133 | 134 | await helper.fastify.stop(_fastify) 135 | _test.pass() 136 | }) 137 | -------------------------------------------------------------------------------- /test/e2e/08-mode.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | for (const mode of ['memoize', 'off', 'collector', 'stock']) { 10 | tap.test('peekaboo change mode ' + mode, 11 | async (_test) => { 12 | _test.plan(2) 13 | const _fastify = fastify() 14 | _fastify.register(peekaboo) 15 | 16 | _fastify.all('/set/:mode', async (request, response) => { 17 | _fastify.peekaboo.mode.set(request.params.mode) 18 | response.send(_fastify.peekaboo.mode.get()) 19 | }) 20 | 21 | await helper.fastify.start(_fastify) 22 | 23 | try { 24 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/set/' + mode) }) 25 | _test.equal(_response.body, mode) 26 | } catch (error) { 27 | _test.threw(error) 28 | } 29 | 30 | await helper.fastify.stop(_fastify) 31 | _test.pass() 32 | }) 33 | } 34 | 35 | tap.test('peekaboo works in mode off', 36 | async (_test) => { 37 | _test.plan(2) 38 | const _fastify = fastify() 39 | _fastify.register(peekaboo, { mode: 'off' }) 40 | 41 | _fastify.all('/something', async (request, response) => { 42 | response.send('a thing') 43 | }) 44 | 45 | await helper.fastify.start(_fastify) 46 | 47 | try { 48 | const url = helper.fastify.url(_fastify, '/something') 49 | await helper.request({ url }) 50 | const _response = await helper.request({ url }) 51 | if (_response.headers['x-peekaboo']) { 52 | _test.fail() 53 | } 54 | _test.equal(_response.body, 'a thing') 55 | } catch (error) { 56 | _test.threw(error) 57 | } 58 | 59 | await helper.fastify.stop(_fastify) 60 | _test.pass() 61 | }) 62 | 63 | tap.test('peekaboo works in mode collector', 64 | async (_test) => { 65 | _test.plan(2) 66 | const _fastify = fastify() 67 | _fastify.register(peekaboo, { mode: 'collector' }) 68 | 69 | _fastify.all('/something', async (request, response) => { 70 | response.send('a thing') 71 | }) 72 | 73 | await helper.fastify.start(_fastify) 74 | 75 | try { 76 | const url = helper.fastify.url(_fastify, '/something') 77 | await helper.request({ url }) 78 | const _response = await helper.request({ url }) 79 | if (_response.headers['x-peekaboo']) { 80 | _test.fail() 81 | } 82 | _test.equal(_response.body, 'a thing') 83 | } catch (error) { 84 | _test.threw(error) 85 | } 86 | 87 | await helper.fastify.stop(_fastify) 88 | _test.pass() 89 | }) 90 | 91 | tap.test('peekaboo works in mode stock', 92 | async (_test) => { 93 | _test.plan(2) 94 | const _fastify = fastify() 95 | _fastify.register(peekaboo, { mode: 'stock' }) 96 | 97 | _fastify.all('/something', async (request, response) => { 98 | response.send('a thing') 99 | }) 100 | 101 | await helper.fastify.start(_fastify) 102 | 103 | try { 104 | const url = helper.fastify.url(_fastify, '/something') 105 | await helper.request({ url }) 106 | } catch (error) { 107 | _test.equal(error.response.statusCode, 404) 108 | } 109 | 110 | await helper.fastify.stop(_fastify) 111 | _test.pass() 112 | }) 113 | 114 | tap.test('peekaboo change to invalid mode and nothing change', 115 | async (_test) => { 116 | _test.plan(2) 117 | const _fastify = fastify() 118 | _fastify.register(peekaboo, { xheader: false, noinfo: true }) 119 | 120 | _fastify.all('/set/:mode', async (request, response) => { 121 | _fastify.peekaboo.mode.set(request.params.mode) 122 | response.send(_fastify.peekaboo.mode.get()) 123 | }) 124 | 125 | await helper.fastify.start(_fastify) 126 | 127 | try { 128 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/set/unknown') }) 129 | _test.equal(_response.body, 'memoize') 130 | } catch (error) { 131 | _test.threw(error) 132 | } 133 | 134 | await helper.fastify.stop(_fastify) 135 | _test.pass() 136 | }) 137 | -------------------------------------------------------------------------------- /test/e2e/01-match-request-route.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | tap.test('peekaboo matching by request route (string)', 10 | async (_test) => { 11 | _test.plan(3) 12 | const _fastify = fastify() 13 | _fastify 14 | .register(peekaboo, { 15 | xheader: true, 16 | rules: [{ 17 | request: { 18 | methods: true, 19 | route: /^\/a\/p/ 20 | } 21 | }] 22 | }) 23 | 24 | _fastify.all('/a/path/to/:resource', async (request, response) => { 25 | response.send('here you are ' + request.params.resource) 26 | }) 27 | 28 | await helper.fastify.start(_fastify) 29 | 30 | try { 31 | const url = helper.fastify.url(_fastify, '/a/path/to/something?q=1') 32 | await helper.request({ url }) 33 | const _response = await helper.request({ url }) 34 | if (!_response.headers['x-peekaboo']) { 35 | _test.fail() 36 | } 37 | _test.equal(_response.body, 'here you are something') 38 | } catch (error) { 39 | _test.threw(error) 40 | } 41 | 42 | try { 43 | const url = helper.fastify.url(_fastify, '/not-matching') 44 | await helper.request({ url }) 45 | } catch (error) { 46 | _test.pass() 47 | } 48 | 49 | await helper.fastify.stop(_fastify) 50 | _test.pass() 51 | }) 52 | 53 | tap.test('peekaboo matching by request route (RegExp)', 54 | async (_test) => { 55 | _test.plan(3) 56 | const _fastify = fastify() 57 | _fastify 58 | .register(peekaboo, { 59 | xheader: true, 60 | rules: [{ 61 | request: { 62 | methods: true, 63 | route: /users|guest/ 64 | } 65 | }] 66 | }) 67 | 68 | _fastify.get('/path/to/users', async (request, response) => { 69 | response.send('users') 70 | }) 71 | 72 | await helper.fastify.start(_fastify) 73 | 74 | try { 75 | const url = helper.fastify.url(_fastify, '/path/to/users') 76 | await helper.request({ url }) 77 | const _response = await helper.request({ url }) 78 | if (!_response.headers['x-peekaboo']) { 79 | _test.fail() 80 | } 81 | _test.equal(_response.body, 'users') 82 | } catch (error) { 83 | _test.threw(error) 84 | } 85 | 86 | try { 87 | const url = helper.fastify.url(_fastify, '/not-matching') 88 | await helper.request({ url }) 89 | } catch (error) { 90 | _test.pass() 91 | } 92 | 93 | await helper.fastify.stop(_fastify) 94 | _test.pass() 95 | }) 96 | 97 | tap.test('peekaboo matching by request route (function)', 98 | async (_test) => { 99 | _test.plan(4) 100 | const _fastify = fastify() 101 | _fastify 102 | .register(peekaboo, { 103 | xheader: true, 104 | rules: [{ 105 | request: { 106 | methods: true, 107 | route: function (route) { 108 | return route.indexOf('user/10') !== -1 && route.indexOf('user/20') === -1 109 | } 110 | } 111 | }] 112 | }) 113 | 114 | _fastify.get('/path/to/user/:id', async (request, response) => { 115 | response.send('user.id=' + request.params.id) 116 | }) 117 | 118 | await helper.fastify.start(_fastify) 119 | 120 | try { 121 | let path = '/path/to/user/10' 122 | let url = helper.fastify.url(_fastify, path) 123 | await helper.request({ url }) 124 | let _response = await helper.request({ url }) 125 | if (!_response.headers['x-peekaboo']) { 126 | _test.fail() 127 | } 128 | _test.equal(_response.body, 'user.id=10') 129 | 130 | path = '/path/to/user/20' 131 | url = helper.fastify.url(_fastify, path) 132 | await helper.request({ url }) 133 | _response = await helper.request({ url }) 134 | if (_response.headers['x-peekaboo']) { 135 | _test.fail() 136 | } 137 | _test.equal(_response.body, 'user.id=20') 138 | } catch (error) { 139 | _test.threw(error) 140 | } 141 | 142 | try { 143 | const path = '/not-matching' 144 | const url = helper.fastify.url(_fastify, path) 145 | await helper.request({ url }) 146 | } catch (error) { 147 | _test.pass() 148 | } 149 | 150 | await helper.fastify.stop(_fastify) 151 | _test.pass() 152 | }) 153 | -------------------------------------------------------------------------------- /src/lib.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const crypto = require('crypto') 4 | const url = require('url') 5 | const stream = require('stream') 6 | const stringify = require('fast-json-stable-stringify') 7 | 8 | const lib = { 9 | METHOD: { 10 | ALL: '*', 11 | GET: 'get', 12 | HEAD: 'head', 13 | POST: 'post', 14 | PUT: 'put', 15 | DELETE: 'delete', 16 | OPTIONS: 'options', 17 | PATCH: 'patch' 18 | }, 19 | 20 | STORAGE: { 21 | MEMORY: 'memory', 22 | FS: 'fs' 23 | }, 24 | 25 | hash: { 26 | /** 27 | * if rule is true, hash using the whole part 28 | * if rule is a function, use the function result, or the whole part if the function returns true 29 | * if rule is an object, hash using only the selected parts 30 | * it's supposed the request[part] to be a plain object 31 | */ 32 | objectSelect: function (request, rule, part) { 33 | if (rule[part] === true) { 34 | return request[part] 35 | } 36 | // on function, if return true or false, get the whole part 37 | // else, use the return value 38 | if (typeof rule[part] === 'function') { 39 | const data = rule[part](request[part]) 40 | return data === true ? request[part] : data 41 | } 42 | const hashing = {} 43 | for (const key in rule[part]) { 44 | hashing[key] = request[part][key] 45 | } 46 | return hashing 47 | }, 48 | /** 49 | * 50 | * it suppose the request[part] to not be a plain object, 51 | * so it does not perform the object keys hashing 52 | */ 53 | anySelect: function (request, rule, part) { 54 | if (rule[part] === true) { 55 | return request[part] 56 | } 57 | // on function, if return true or false, get the whole part 58 | // else, use the return value 59 | if (typeof rule[part] === 'function') { 60 | const data = rule[part](request[part]) 61 | return data === true ? request[part] : data 62 | } 63 | }, 64 | /** 65 | * hash `request` by `rule` matching 66 | * @param {fastify.Request} request 67 | * @param {rule} rule 68 | */ 69 | request: function (request, rule) { 70 | const route = new url.URL('http://a.b' + request.raw.url).pathname 71 | const hashing = { 72 | method: request.method, 73 | route 74 | } 75 | 76 | // copy/paste code for performance reason 77 | if (rule.headers) { 78 | hashing.headers = lib.hash.objectSelect(request, rule, 'headers') 79 | } 80 | if (rule.query) { 81 | hashing.query = lib.hash.objectSelect(request, rule, 'query') 82 | } 83 | if (rule.body) { 84 | if (lib.isPlainObject(request.body)) { 85 | hashing.body = lib.hash.objectSelect(request, rule, 'body') 86 | } else { 87 | hashing.body = lib.hash.anySelect(request, rule, 'body') 88 | } 89 | } 90 | 91 | return crypto.createHmac('sha256', '') 92 | .update(stringify(hashing)) 93 | .digest('hex') 94 | }, 95 | response: function (response, rule) { 96 | if (!rule) { 97 | return Date.now() 98 | } 99 | const hashing = {} 100 | 101 | // copy/paste code for performance reason 102 | if (rule.status) { 103 | hashing.status = response.status 104 | } 105 | if (rule.body) { 106 | if (lib.isPlainObject(response.body)) { 107 | hashing.body = {} 108 | for (const key in rule.body) { 109 | hashing.body[key] = response.body[key] 110 | } 111 | } else { 112 | hashing.body = response.body 113 | } 114 | } 115 | if (rule.headers) { 116 | hashing.headers = {} 117 | for (const key in rule.headers) { 118 | hashing.headers[key] = response.headers[key] 119 | } 120 | } 121 | return crypto.createHmac('sha256', '') 122 | .update(stringify(hashing)) 123 | .digest('hex') 124 | } 125 | }, 126 | 127 | isStream: function (object) { 128 | // ? ['DuplexWrapper', 'ReadStream'].includes(object.__proto__.constructor.name) 129 | return object.pipe && object.unpipe 130 | }, 131 | 132 | isPlainObject: function (object) { 133 | return typeof object === 'object' && object.constructor == Object 134 | }, 135 | 136 | acquireStream: async function (source) { 137 | let _content = Buffer.alloc(0) 138 | const _stream = source.pipe(new stream.PassThrough()) 139 | const done = new Promise((resolve, reject) => { 140 | _stream.on('data', (chunk) => { 141 | _content = Buffer.concat([_content, chunk]) 142 | }) 143 | _stream.once('finish', resolve) 144 | _stream.once('error', reject) 145 | }) 146 | await done 147 | return _content 148 | }, 149 | 150 | log: { 151 | request: function (request) { 152 | return request.raw.url 153 | } 154 | } 155 | 156 | } 157 | 158 | module.exports = lib 159 | -------------------------------------------------------------------------------- /src/storage/fs.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { v1: uuid } = require('uuid') 4 | const fs = require('fs-extra') 5 | const path = require('path') 6 | 7 | const FsStorage = function (options) { 8 | const _basePath = options.path 9 | const _dataset = {} 10 | let _indexFile 11 | let _path 12 | let _inited 13 | 14 | const _init = async function () { 15 | _indexFile = path.join(_basePath, 'index.json') 16 | await fs.ensureDir(_basePath) 17 | 18 | try { 19 | // try load dataset index 20 | const index = require(_indexFile) 21 | _dataset.default = index.default 22 | _dataset.entries = index.entries 23 | _dataset.current = index.current 24 | } catch (error) { 25 | // create the dataset index file if index is not found or invalid 26 | _dataset.default = uuid() 27 | _dataset.entries = { 28 | [_dataset.default]: 'default' 29 | } 30 | _dataset.current = _dataset.default 31 | await dataset.saveIndex() 32 | } 33 | _path = path.join(_basePath, _dataset.current) 34 | await fs.ensureDir(_path) 35 | } 36 | 37 | const get = async function (key) { 38 | try { 39 | await _inited 40 | const data = await fs.readFile(path.join(_path, key), 'utf8') 41 | const content = JSON.parse(data) 42 | if (content.expire && content.expire < Date.now()) { 43 | rm(key) 44 | return 45 | } 46 | return content 47 | } catch (error) { } 48 | } 49 | 50 | const set = async function (key, data, expire) { 51 | try { 52 | await _inited 53 | if (expire && !data.expire) { 54 | data.expire = Date.now() + expire 55 | } 56 | return fs.writeFile(path.join(_path, key), JSON.stringify(data), 'utf8') 57 | } catch (error) { } 58 | } 59 | 60 | const list = async function () { 61 | const _entries = [] 62 | try { 63 | await _inited 64 | const _files = await fs.readdir(_path) 65 | for (let i = 0; i < _files.length; i++) { 66 | const _file = _files[i] 67 | _entries.push(path.basename(_file)) 68 | } 69 | } catch (error) { } 70 | return _entries 71 | } 72 | 73 | const rm = async function (key) { 74 | try { 75 | await _inited 76 | fs.unlink(path.join(_path, key)) 77 | } catch (error) { } 78 | } 79 | 80 | const clear = async function () { 81 | try { 82 | await _inited 83 | const _files = await fs.readdir(_path) 84 | for (let i = 0; i < _files.length; i++) { 85 | fs.unlink(path.join(_path, _files[i])) 86 | } 87 | } catch (error) { } 88 | } 89 | 90 | const dataset = { 91 | /** 92 | * @async 93 | * @throws 94 | */ 95 | saveIndex: function () { 96 | return fs.writeFile(_indexFile, JSON.stringify(_dataset), 'utf8') 97 | }, 98 | /** 99 | * @async 100 | * @param {string} name 101 | * @returns {hash} id 102 | * @throws 103 | */ 104 | create: async function (name) { 105 | await _inited 106 | const id = uuid() 107 | _dataset.entries[id] = name 108 | await fs.ensureDir(path.join(_basePath, id)) 109 | await dataset.saveIndex() 110 | return id 111 | }, 112 | /** 113 | * @async 114 | * @param {hash} id 115 | * @param {string} name 116 | * @throws 117 | */ 118 | update: async function (id, name) { 119 | try { 120 | await _inited 121 | if (!_dataset.entries[id]) { 122 | throw Error('INVALID_DATASET_ID') 123 | } 124 | _dataset.entries[id] = name 125 | await dataset.saveIndex() 126 | } catch (error) { 127 | throw error 128 | } 129 | }, 130 | /** 131 | * @async 132 | * @param {hash} id 133 | * @throws 134 | */ 135 | remove: async function (id) { 136 | try { 137 | await _inited 138 | if (!_dataset.entries[id]) { 139 | throw Error('INVALID_DATASET_ID') 140 | } 141 | if (id == _dataset.default) { 142 | throw Error('INVALID_DATASET_OPERATION_CANT_REMOVE_DEFAULT') 143 | } 144 | const entries = _dataset.entries 145 | delete _dataset.entries[id] 146 | await dataset.saveIndex() 147 | if (_dataset.current == id) { 148 | dataset.set(_dataset.default) 149 | } 150 | fs.remove(path.join(_basePath, id)) 151 | } catch (error) { 152 | throw error 153 | } 154 | }, 155 | get: async function () { 156 | await _inited 157 | return { 158 | entries: { ..._dataset.entries }, 159 | current: _dataset.current, 160 | default: _dataset.default 161 | } 162 | }, 163 | current: function () { 164 | return _dataset.current 165 | }, 166 | /** 167 | * @async 168 | * @param {hash} id 169 | * @throws error if `id` is not a valid dataset id 170 | */ 171 | set: async function (id) { 172 | await _inited 173 | if (!_dataset.entries[id]) { 174 | throw Error('INVALID_DATASET_CURRENT_VALUE') 175 | } 176 | _dataset.current = id 177 | _path = path.join(_basePath, id) 178 | await dataset.saveIndex() 179 | } 180 | } 181 | 182 | _inited = _init() 183 | 184 | return { 185 | get, 186 | set, 187 | rm, 188 | list, 189 | clear, 190 | dataset 191 | } 192 | } 193 | 194 | module.exports = FsStorage 195 | -------------------------------------------------------------------------------- /test/e2e/05-match-request-headers.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | tap.test('peekaboo matching by request headers (string)', 10 | async (_test) => { 11 | _test.plan(2) 12 | const _fastify = fastify() 13 | _fastify 14 | .register(peekaboo, { 15 | xheader: true, 16 | rules: [{ 17 | request: { 18 | route: '/', 19 | methods: true, 20 | headers: { referer: true } 21 | } 22 | }] 23 | }) 24 | 25 | _fastify.get('/', async (request, response) => { 26 | response.send(request.headers.referer) 27 | }) 28 | 29 | try { 30 | await helper.fastify.start(_fastify) 31 | 32 | let url = helper.fastify.url(_fastify, '/') 33 | let _headers = { referer: 'testing', empty: '' } 34 | await helper.request({ url, headers: _headers }) 35 | let _response = await helper.request({ url, headers: _headers }) 36 | if (!_response.headers['x-peekaboo']) { 37 | _test.fail() 38 | } 39 | _test.equal(_response.body, 'testing') 40 | 41 | url = helper.fastify.url(_fastify, '/') 42 | _headers = { host: 'localhost' } 43 | await helper.request({ url, headers: _headers }) 44 | _response = await helper.request({ url, headers: _headers }) 45 | if (_response.headers['x-peekaboo']) { 46 | _test.fail() 47 | } 48 | 49 | await helper.fastify.stop(_fastify) 50 | _test.pass() 51 | } catch (error) { 52 | _test.threw(error) 53 | } 54 | }) 55 | 56 | tap.test('peekaboo matching by request headers (array)', 57 | async (_test) => { 58 | _test.plan(3) 59 | const _fastify = fastify() 60 | _fastify 61 | .register(peekaboo, { 62 | xheader: true, 63 | rules: [{ 64 | request: { 65 | route: '/', 66 | methods: true, 67 | headers: { authorization: true, cookie: true } 68 | } 69 | }] 70 | }) 71 | 72 | _fastify.get('/', async (request, response) => { 73 | if (request.headers.authorization) { 74 | response.send('ok ' + request.headers.cookie) 75 | } else { 76 | response.send('error') 77 | } 78 | }) 79 | 80 | try { 81 | await helper.fastify.start(_fastify) 82 | 83 | let url = helper.fastify.url(_fastify, '/') 84 | let headers = { authorization: 'token#1', cookie: 'sid=abcde13564' } 85 | await helper.request({ url, headers }) 86 | let _response = await helper.request({ url, headers }) 87 | if (!_response.headers['x-peekaboo']) { 88 | _test.fail() 89 | } 90 | 91 | url = helper.fastify.url(_fastify, '/') 92 | await helper.request({ url, headers }) 93 | _response = await helper.request({ url }) 94 | if (_response.headers['x-peekaboo']) { 95 | _test.fail() 96 | } 97 | _test.equal(_response.body, 'error') 98 | 99 | url = helper.fastify.url(_fastify, '/') 100 | headers = { authorization: 'token#2', cookie: 'sid=987654abcde' } 101 | await helper.request({ url, headers }) 102 | _response = await helper.request({ url, headers }) 103 | if (!_response.headers['x-peekaboo']) { 104 | _test.fail() 105 | } 106 | _test.equal(_response.body, 'ok ' + headers.cookie) 107 | 108 | await helper.fastify.stop(_fastify) 109 | _test.pass() 110 | } catch (error) { 111 | _test.threw(error) 112 | } 113 | }) 114 | 115 | tap.test('peekaboo matching by request headers (function)', 116 | async (_test) => { 117 | _test.plan(3) 118 | const _fastify = fastify() 119 | _fastify 120 | .register(peekaboo, { 121 | xheader: true, 122 | rules: [{ 123 | request: { 124 | route: '/', 125 | methods: true, 126 | headers: function (headers) { 127 | if (headers['accept-language'] && headers['accept-language'].indexOf('it') !== -1) { 128 | return ['accept-language'] 129 | } 130 | } 131 | } 132 | }] 133 | }) 134 | 135 | _fastify.all('/', async (request, response) => { 136 | if (request.headers['accept-language'] && request.headers['accept-language'].indexOf('it') !== -1) { 137 | response.send('ciao') 138 | } else { 139 | response.send('hello') 140 | } 141 | }) 142 | 143 | try { 144 | await helper.fastify.start(_fastify) 145 | 146 | let url = helper.fastify.url(_fastify, '/') 147 | await helper.request({ url }) 148 | let _response = await helper.request({ url }) 149 | if (_response.headers['x-peekaboo']) { 150 | _test.fail() 151 | } 152 | _test.equal(_response.body, 'hello') 153 | 154 | url = helper.fastify.url(_fastify, '/') 155 | const headers = { 'accept-language': 'en-US,en;q=0.9,it;q=0.8,la;q=0.7' } 156 | await helper.request({ url, headers }) 157 | _response = await helper.request({ url, headers }) 158 | if (!_response.headers['x-peekaboo']) { 159 | _test.fail() 160 | } 161 | _test.equal(_response.body, 'ciao') 162 | 163 | await helper.fastify.stop(_fastify) 164 | _test.pass() 165 | } catch (error) { 166 | _test.threw(error) 167 | } 168 | }) 169 | -------------------------------------------------------------------------------- /test/e2e/06-match-response-headers.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | tap.test('peekaboo matching by response status (exact)', 10 | async (_test) => { 11 | _test.plan(1) 12 | const _fastify = fastify() 13 | _fastify 14 | .register(peekaboo, { 15 | xheader: true, 16 | rules: [{ 17 | request: { 18 | methods: '*', 19 | route: true 20 | }, 21 | response: { 22 | status: 201 23 | } 24 | }] 25 | }) 26 | 27 | _fastify.get('/200', async (request, response) => { 28 | response.code(200).send('200') 29 | }) 30 | 31 | _fastify.get('/201', async (request, response) => { 32 | response.header('empty', '') 33 | response.code(201).send('201') 34 | }) 35 | 36 | try { 37 | await helper.fastify.start(_fastify) 38 | 39 | let url = helper.fastify.url(_fastify, '/200') 40 | await helper.request({ url }) 41 | let _response = await helper.request({ url }) 42 | if (_response.headers['x-peekaboo']) { 43 | _test.fail() 44 | } 45 | 46 | url = helper.fastify.url(_fastify, '/201') 47 | await helper.request({ url }) 48 | _response = await helper.request({ url }) 49 | if (!_response.headers['x-peekaboo']) { 50 | _test.fail() 51 | } 52 | 53 | await helper.fastify.stop(_fastify) 54 | _test.pass() 55 | } catch (error) { 56 | _test.threw(error) 57 | } 58 | }) 59 | 60 | tap.test('peekaboo matching by response status (regexp)', 61 | async (_test) => { 62 | _test.plan(1) 63 | const _fastify = fastify() 64 | _fastify 65 | .register(peekaboo, { 66 | xheader: true, 67 | rules: [{ 68 | request: { 69 | methods: '*', 70 | route: true 71 | }, 72 | response: { 73 | status: /^2/ 74 | } 75 | }] 76 | }) 77 | 78 | _fastify.get('/200', async (request, response) => { 79 | response.code(200).send('200') 80 | }) 81 | 82 | _fastify.get('/301', async (request, response) => { 83 | response.code(301).send('301') 84 | }) 85 | 86 | try { 87 | await helper.fastify.start(_fastify) 88 | 89 | let url = helper.fastify.url(_fastify, '/200') 90 | await helper.request({ url }) 91 | let _response = await helper.request({ url }) 92 | if (!_response.headers['x-peekaboo']) { 93 | _test.fail() 94 | } 95 | 96 | url = helper.fastify.url(_fastify, '/301') 97 | await helper.request({ url, throwHttpErrors: false }) 98 | _response = await helper.request({ url, throwHttpErrors: false }) 99 | if (_response.headers['x-peekaboo']) { 100 | _test.fail() 101 | } 102 | 103 | await helper.fastify.stop(_fastify) 104 | _test.pass() 105 | } catch (error) { 106 | _test.threw(error) 107 | } 108 | }) 109 | 110 | tap.test('peekaboo matching by response headers (object)', 111 | async (_test) => { 112 | _test.plan(1) 113 | const _fastify = fastify() 114 | _fastify 115 | .register(peekaboo, { 116 | xheader: true, 117 | rules: [{ 118 | request: { 119 | methods: '*', 120 | route: true 121 | }, 122 | response: { 123 | headers: { 124 | 'x-test': true 125 | } 126 | } 127 | }] 128 | }) 129 | 130 | _fastify.get('/', async (request, response) => { 131 | response 132 | .header('x-test', 'one') 133 | .send('hello') 134 | }) 135 | 136 | try { 137 | await helper.fastify.start(_fastify) 138 | 139 | const url = helper.fastify.url(_fastify, '/') 140 | await helper.request({ url }) 141 | const _response = await helper.request({ url }) 142 | if (!_response.headers['x-peekaboo']) { 143 | _test.fail() 144 | } 145 | 146 | await helper.fastify.stop(_fastify) 147 | _test.pass() 148 | } catch (error) { 149 | _test.threw(error) 150 | } 151 | }) 152 | 153 | tap.test('peekaboo matching by response headers (function)', 154 | async (_test) => { 155 | _test.plan(1) 156 | const _fastify = fastify() 157 | _fastify 158 | .register(peekaboo, { 159 | xheader: true, 160 | rules: [{ 161 | request: { 162 | methods: '*', 163 | route: true 164 | }, 165 | response: { 166 | headers: function (headers) { 167 | return !!headers['set-cookie'] 168 | } 169 | } 170 | }] 171 | }) 172 | 173 | _fastify.get('/cookie', async (request, response) => { 174 | response 175 | .header('set-cookie', 'session=987654abcdeeecafebabe') 176 | .send('ok') 177 | }) 178 | 179 | _fastify.get('/home', async (request, response) => { 180 | response.send('home') 181 | }) 182 | 183 | try { 184 | await helper.fastify.start(_fastify) 185 | 186 | let url = helper.fastify.url(_fastify, '/cookie') 187 | await helper.request({ url }) 188 | let _response = await helper.request({ url }) 189 | if (!_response.headers['x-peekaboo']) { 190 | _test.fail() 191 | } 192 | 193 | url = helper.fastify.url(_fastify, '/home') 194 | await helper.request({ url }) 195 | _response = await helper.request({ url }) 196 | if (_response.headers['x-peekaboo']) { 197 | _test.fail() 198 | } 199 | 200 | await helper.fastify.stop(_fastify) 201 | _test.pass() 202 | } catch (error) { 203 | _test.threw(error) 204 | } 205 | }) 206 | -------------------------------------------------------------------------------- /test/e2e/09-storages.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | const rules = [{ 10 | request: { 11 | methods: true, 12 | route: /^\/(?!clear).*$/ 13 | } 14 | }] 15 | 16 | const storages = { 17 | default: { 18 | xheader: 'from-cache-memory', 19 | settings: { 20 | xheader: true, 21 | expire: 30 * 1000, 22 | rules 23 | } 24 | }, 25 | 26 | memory: { 27 | xheader: 'from-cache-memory', 28 | settings: { 29 | xheader: true, 30 | expire: 30 * 1000, 31 | rules, 32 | storage: { 33 | mode: 'memory' 34 | } 35 | } 36 | }, 37 | 38 | fs: { 39 | xheader: 'from-cache-fs', 40 | settings: { 41 | xheader: true, 42 | expire: 30 * 1000, 43 | rules, 44 | storage: { 45 | mode: 'fs', 46 | config: { 47 | path: '/tmp/peekaboo' 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | for (const name in storages) { 55 | const storage = storages[name] 56 | 57 | tap.test('peekaboo get from storage (' + name + ')', 58 | async (_test) => { 59 | _test.plan(2) 60 | const _fastify = fastify() 61 | _fastify 62 | .register(peekaboo, storage.settings) 63 | 64 | _fastify.all('/', async (request, response) => { 65 | response.send('response') 66 | }) 67 | 68 | await helper.fastify.start(_fastify) 69 | 70 | try { 71 | const url = helper.fastify.url(_fastify, '/') 72 | await helper.request({ url }) 73 | const _response = await helper.request({ url }) 74 | if (_response.headers['x-peekaboo'] !== storage.xheader) { 75 | _test.fail(name + ' should use cache, but it doesnt') 76 | } 77 | _test.equal(_response.body, 'response') 78 | } catch (error) { 79 | _test.threw(error) 80 | } 81 | 82 | await helper.fastify.stop(_fastify) 83 | _test.pass() 84 | }) 85 | 86 | tap.test('peekaboo do not get from storage because it is expired (' + name + ')', 87 | async (_test) => { 88 | _test.plan(2) 89 | const _fastify = fastify() 90 | _fastify 91 | .register(peekaboo, { ...storage.settings, expire: 10 }) 92 | 93 | _fastify.all('/', async (request, response) => { 94 | response.send('response') 95 | }) 96 | _fastify.all('/clear', async (request, response) => { 97 | await request.peekaboo.storage.clear() 98 | response.send('clear') 99 | }) 100 | 101 | await helper.fastify.start(_fastify) 102 | 103 | try { 104 | const url = helper.fastify.url(_fastify, '/') 105 | await helper.request({ url: helper.fastify.url(_fastify, '/clear') }) 106 | await helper.request({ url }) 107 | await helper.sleep(200) 108 | const _response = await helper.request({ url }) 109 | if (_response.headers['x-peekaboo']) { 110 | _test.fail(name + ' should not use cache, but it does') 111 | } 112 | _test.equal(_response.body, 'response') 113 | } catch (error) { 114 | _test.threw(error) 115 | } 116 | 117 | await helper.fastify.stop(_fastify) 118 | _test.pass() 119 | }) 120 | 121 | tap.test('peekaboo get list of cached entries (' + name + ')', 122 | async (_test) => { 123 | _test.plan(3) 124 | const _fastify = fastify() 125 | _fastify 126 | .register(peekaboo, storage.settings) 127 | 128 | _fastify.all('/', async (request, response) => { 129 | response.send('index') 130 | }) 131 | _fastify.all('/one', async (request, response) => { 132 | response.send('one') 133 | }) 134 | _fastify.all('/two', async (request, response) => { 135 | response.send('two') 136 | }) 137 | _fastify.all('/clear', async (request, response) => { 138 | await request.peekaboo.storage.clear() 139 | response.send('clear') 140 | }) 141 | _fastify.all('/list', async (request, response) => { 142 | response.send(await request.peekaboo.storage.list()) 143 | }) 144 | 145 | await helper.fastify.start(_fastify) 146 | 147 | try { 148 | await helper.request({ url: helper.fastify.url(_fastify, '/one') }) 149 | await helper.request({ url: helper.fastify.url(_fastify, '/two') }) 150 | await helper.request({ url: helper.fastify.url(_fastify, '/clear') }) 151 | await helper.request({ url: helper.fastify.url(_fastify, '/') }) 152 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/list') }) 153 | const _body = JSON.parse(_response.body) 154 | _test.equal(_body.length, 1) 155 | _test.match(_body[0], /^[a-f0-9]{64}$/) 156 | } catch (error) { 157 | _test.threw(error) 158 | } 159 | 160 | await helper.fastify.stop(_fastify) 161 | _test.pass() 162 | }) 163 | 164 | tap.test('peekaboo remove a cached entry by hash (' + name + ')', 165 | async (_test) => { 166 | _test.plan(2) 167 | const _fastify = fastify() 168 | 169 | _fastify 170 | .register(peekaboo, { ...storage.settings, xheader: false }) 171 | 172 | _fastify.all('/one', async (request, response) => { 173 | response.send('one') 174 | }) 175 | _fastify.all('/two', async (request, response) => { 176 | response.send('two') 177 | }) 178 | _fastify.all('/rm/:hash', async (request, response) => { 179 | await request.peekaboo.storage.rm(request.params.hash) 180 | response.send('rm') 181 | }) 182 | _fastify.all('/list', async (request, response) => { 183 | response.send(await request.peekaboo.storage.list()) 184 | }) 185 | 186 | await helper.fastify.start(_fastify) 187 | 188 | try { 189 | await helper.request({ url: helper.fastify.url(_fastify, '/one') }) 190 | await helper.request({ url: helper.fastify.url(_fastify, '/two') }) 191 | let _response = await helper.request({ url: helper.fastify.url(_fastify, '/list') }) 192 | const _list = JSON.parse(_response.body) 193 | _response = await helper.request({ url: helper.fastify.url(_fastify, '/rm/' + _list[0]) }) 194 | _test.equal(_response.statusCode, 200) 195 | } catch (error) { 196 | _test.threw(error) 197 | } 198 | 199 | await helper.fastify.stop(_fastify) 200 | _test.pass() 201 | }) 202 | 203 | tap.test('peekaboo set a cached item by hash (' + name + ')', 204 | async (_test) => { 205 | _test.plan(2) 206 | const _fastify = fastify() 207 | _fastify 208 | .register(peekaboo, storage.settings) 209 | 210 | _fastify.all('/one', async (request, response) => { 211 | response.send('one') 212 | }) 213 | _fastify.all('/two', async (request, response) => { 214 | response.send('two') 215 | }) 216 | _fastify.all('/set/:hash', async (request, response) => { 217 | await request.peekaboo.storage.set(request.params.hash, { expire: 3000 }) 218 | response.send('set') 219 | }) 220 | _fastify.all('/list', async (request, response) => { 221 | response.send(await request.peekaboo.storage.list()) 222 | }) 223 | 224 | await helper.fastify.start(_fastify) 225 | 226 | try { 227 | await helper.request({ url: helper.fastify.url(_fastify, '/one') }) 228 | await helper.request({ url: helper.fastify.url(_fastify, '/two?three=3') }) 229 | let _response = await helper.request({ url: helper.fastify.url(_fastify, '/list') }) 230 | const _list = JSON.parse(_response.body) 231 | _response = await helper.request({ url: helper.fastify.url(_fastify, '/set/' + _list[0]) }) 232 | _test.equal(_response.statusCode, 200) 233 | } catch (error) { 234 | _test.threw(error) 235 | } 236 | 237 | await helper.fastify.stop(_fastify) 238 | _test.pass() 239 | }) 240 | } 241 | -------------------------------------------------------------------------------- /test/e2e/03-match-request-query.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | tap.test('peekaboo matching by request query (*)', 10 | async (_test) => { 11 | _test.plan(2) 12 | const _fastify = fastify() 13 | _fastify 14 | .register(peekaboo, { 15 | xheader: true, 16 | rules: [{ 17 | request: { 18 | methods: ['get', 'put'], 19 | route: '/query', 20 | query: { 21 | q: '1' 22 | } 23 | } 24 | }] 25 | }) 26 | 27 | _fastify.all('/query', async (request, response) => { 28 | response.send(JSON.stringify(request.query)) 29 | }) 30 | 31 | try { 32 | await helper.fastify.start(_fastify) 33 | 34 | let url = helper.fastify.url(_fastify, '/query?q=1') 35 | await helper.request({ url }) 36 | let _response = await helper.request({ url }) 37 | if (!_response.headers['x-peekaboo']) { 38 | _test.fail() 39 | } 40 | _test.equal(_response.body, '{"q":"1"}') 41 | 42 | url = helper.fastify.url(_fastify, '/query?q=0&p=0') 43 | await helper.request({ url }) 44 | _response = await helper.request({ url }) 45 | if (_response.headers['x-peekaboo']) { 46 | _test.fail() 47 | } 48 | 49 | await helper.fastify.stop(_fastify) 50 | _test.pass() 51 | } catch (error) { 52 | _test.threw(error) 53 | } 54 | }) 55 | 56 | tap.test('peekaboo matching by request query (string)', 57 | async (_test) => { 58 | _test.plan(3) 59 | const _fastify = fastify() 60 | _fastify 61 | .register(peekaboo, { 62 | xheader: true, 63 | rules: [{ 64 | request: { 65 | methods: '*', 66 | route: '/query', 67 | query: { param: true } 68 | } 69 | }] 70 | }) 71 | 72 | _fastify.all('/query', async (request, response) => { 73 | response.send(JSON.stringify(request.query)) 74 | }) 75 | 76 | try { 77 | await helper.fastify.start(_fastify) 78 | 79 | let url = helper.fastify.url(_fastify, '/query?param=value1') 80 | await helper.request({ url }) 81 | let _response = await helper.request({ url }) 82 | if (!_response.headers['x-peekaboo']) { 83 | _test.fail() 84 | } 85 | _test.equal(_response.body, '{"param":"value1"}') 86 | 87 | url = helper.fastify.url(_fastify, '/query?param=value2') 88 | await helper.request({ url }) 89 | _response = await helper.request({ url }) 90 | if (!_response.headers['x-peekaboo']) { 91 | _test.fail() 92 | } 93 | _test.equal(_response.body, '{"param":"value2"}') 94 | 95 | await helper.fastify.stop(_fastify) 96 | _test.pass() 97 | } catch (error) { 98 | _test.threw(error) 99 | } 100 | }) 101 | 102 | tap.test('peekaboo matching by request query (any and never)', 103 | async (_test) => { 104 | _test.plan(2) 105 | const _fastify = fastify() 106 | _fastify 107 | .register(peekaboo, { 108 | xheader: true, 109 | rules: [{ 110 | request: { 111 | methods: '*', 112 | route: '/query', 113 | // any page and offset but no filter 114 | query: { 115 | page: true, 116 | filter: false 117 | } 118 | } 119 | }] 120 | }) 121 | 122 | _fastify.all('/query', async (request, response) => { 123 | response.send(JSON.stringify(request.query)) 124 | }) 125 | 126 | try { 127 | await helper.fastify.start(_fastify) 128 | 129 | let url = helper.fastify.url(_fastify, '/query?page=0') 130 | await helper.request({ url }) 131 | let _response = await helper.request({ url }) 132 | if (!_response.headers['x-peekaboo']) { 133 | _test.fail() 134 | } 135 | 136 | url = helper.fastify.url(_fastify, '/query?page=1&offset=2') 137 | await helper.request({ url }) 138 | _response = await helper.request({ url }) 139 | if (!_response.headers['x-peekaboo']) { 140 | _test.fail() 141 | } 142 | _test.equal(_response.body, '{"page":"1","offset":"2"}') 143 | 144 | url = helper.fastify.url(_fastify, '/query?page=1&offset=2&filter=value') 145 | await helper.request({ url }) 146 | _response = await helper.request({ url }) 147 | if (_response.headers['x-peekaboo']) { 148 | _test.fail() 149 | } 150 | 151 | await helper.fastify.stop(_fastify) 152 | _test.pass() 153 | } catch (error) { 154 | _test.threw(error) 155 | } 156 | }) 157 | 158 | tap.test('peekaboo matching by request query (function)', 159 | async (_test) => { 160 | _test.plan(1) 161 | const _fastify = fastify() 162 | _fastify 163 | .register(peekaboo, { 164 | xheader: true, 165 | rules: [{ 166 | request: { 167 | methods: '*', 168 | route: '/query', 169 | query: function (query) { 170 | return parseInt(query.page) > 0 171 | } 172 | } 173 | }] 174 | }) 175 | 176 | _fastify.all('/query', async (request, response) => { 177 | response.send(JSON.stringify(request.query)) 178 | }) 179 | 180 | try { 181 | await helper.fastify.start(_fastify) 182 | 183 | let url = helper.fastify.url(_fastify, '/query?page=0') 184 | await helper.request({ url }) 185 | let _response = await helper.request({ url }) 186 | if (_response.headers['x-peekaboo']) { 187 | _test.fail() 188 | } 189 | 190 | url = helper.fastify.url(_fastify, '/query?page=1&offset=2') 191 | await helper.request({ url }) 192 | _response = await helper.request({ url }) 193 | if (!_response.headers['x-peekaboo']) { 194 | _test.fail() 195 | } 196 | 197 | url = helper.fastify.url(_fastify, '/query?page=2&offset=2&filter=value') 198 | await helper.request({ url }) 199 | _response = await helper.request({ url }) 200 | if (!_response.headers['x-peekaboo']) { 201 | _test.fail() 202 | } 203 | 204 | url = helper.fastify.url(_fastify, '/query?offset=0') 205 | await helper.request({ url }) 206 | _response = await helper.request({ url }) 207 | if (_response.headers['x-peekaboo']) { 208 | _test.fail() 209 | } 210 | 211 | await helper.fastify.stop(_fastify) 212 | _test.pass() 213 | } catch (error) { 214 | _test.threw(error) 215 | } 216 | }) 217 | 218 | tap.test('peekaboo partial matching by request query (function)', 219 | async (_test) => { 220 | _test.plan(4) 221 | const _fastify = fastify() 222 | _fastify 223 | .register(peekaboo, { 224 | xheader: true, 225 | rules: [{ 226 | request: { 227 | methods: '*', 228 | route: '/query', 229 | query: function (query) { 230 | return query.page && parseInt(query.page) > 0 231 | ? query.page 232 | : false 233 | } 234 | } 235 | }] 236 | }) 237 | 238 | _fastify.all('/query', async (request, response) => { 239 | response.send(request.query) 240 | }) 241 | 242 | try { 243 | await helper.fastify.start(_fastify) 244 | 245 | let url = helper.fastify.url(_fastify, '/query?page=0') 246 | await helper.request({ url }) 247 | let _response = await helper.request({ url }) 248 | if (_response.headers['x-peekaboo']) { 249 | _test.fail() 250 | } 251 | 252 | url = helper.fastify.url(_fastify, '/query?page=0') 253 | await helper.request({ url }) 254 | _response = await helper.request({ url }) 255 | _test.deepEqual(JSON.parse(_response.body), { page: 0 }) 256 | if (_response.headers['x-peekaboo']) { 257 | _test.fail() 258 | } 259 | 260 | url = helper.fastify.url(_fastify, '/query?page=1&offset=2') 261 | await helper.request({ url }) 262 | _response = await helper.request({ url }) 263 | _test.deepEqual(JSON.parse(_response.body), { page: 1, offset: 2 }) 264 | if (!_response.headers['x-peekaboo']) { 265 | _test.fail() 266 | } 267 | 268 | url = helper.fastify.url(_fastify, '/query?nopage=2&filter=value') 269 | await helper.request({ url }) 270 | _response = await helper.request({ url }) 271 | _test.deepEqual(JSON.parse(_response.body), { nopage: 2, filter: 'value' }) 272 | if (_response.headers['x-peekaboo']) { 273 | _test.fail() 274 | } 275 | 276 | await helper.fastify.stop(_fastify) 277 | _test.pass() 278 | } catch (error) { 279 | _test.threw(error) 280 | } 281 | }) 282 | -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const plug = require('fastify-plugin') 4 | const stringify = require('fast-json-stable-stringify') 5 | const package_ = require('../package.json') 6 | const defaultSettings = require('../settings/default') 7 | const Storage = require('./storage') 8 | const lib = require('./lib') 9 | const match = require('./match') 10 | const validateSettings = require('./validate/settings') 11 | 12 | /** 13 | * it implement the fastify-plugin interface 14 | * @param {Fastify} fastify instance 15 | * @param {Settings} settings 16 | * @param {function} next 17 | * 18 | * @throws if settings are invalid 19 | */ 20 | const plugin = function (fastify, settings, next) { 21 | let _settings, _storage 22 | 23 | const _init = function (settings) { 24 | _settings = { 25 | ...defaultSettings, 26 | ...settings 27 | } 28 | validateSettings(_settings) 29 | 30 | const { storage, expire } = settings 31 | _storage = new Storage({ ...storage, expire }) 32 | } 33 | 34 | const preHandler = function (request, response, next) { 35 | (async () => { 36 | if (_settings.mode === 'off') { 37 | return next(null) 38 | } 39 | 40 | request.log.trace({ ns: 'peekaboo', message: 'preHandler', request: lib.log.request(request) }) 41 | request.peekaboo = { storage: _storage } 42 | const _match = match.request(request, _settings.rules) 43 | if (_match) { 44 | response.peekaboo = { match: true, ..._match } 45 | } 46 | if (!_match || _settings.mode == 'collector') { 47 | return next() 48 | } 49 | 50 | request.log.trace({ ns: 'peekaboo', message: 'preHandler - will search in cache', request: lib.log.request(request) }) 51 | const _cached = await _storage.get(_match.hash) 52 | if (!_cached) { 53 | if (_settings.mode === 'stock') { 54 | // @todo settings 55 | response.code(404) 56 | response.peekaboo.sent = true 57 | response.send('PEEKABOO_NOT_IN_STOCK') 58 | return 59 | } 60 | request.log.trace({ ns: 'peekaboo', message: 'preHandler - still not cached', request: lib.log.request(request) }) 61 | return next() 62 | } 63 | 64 | request.log.trace({ ns: 'peekaboo', message: 'preHandler - serve response from cache', request: lib.log.request(request) }) 65 | if (_settings.xheader) { 66 | response.header('x-peekaboo', 'from-cache-' + _settings.storage.mode) 67 | response.header('x-peekaboo-hash', _match.hash) 68 | } 69 | for (const _name in _cached.response.headers) { 70 | response.header(_name, _cached.response.headers[_name]) 71 | } 72 | response.code(_cached.response.status) 73 | response.peekaboo.sent = true 74 | response.send(_cached.response.body) 75 | })() 76 | } 77 | 78 | const onSend = function (request, response, payload, next) { 79 | (async () => { 80 | if (_settings.mode == 'off' || _settings.mode == 'stock' || !response.peekaboo.match) { 81 | request.log.trace({ ns: 'peekaboo', message: 'onSend - response has not to be cached', request: lib.log.request(request) }) 82 | return next() 83 | } 84 | 85 | const _peekaboo = response.peekaboo 86 | if (!_peekaboo.sent && _peekaboo.match) { 87 | request.log.trace({ ns: 'peekaboo', message: 'onSend - response has to be cached', request: lib.log.request(request) }) 88 | if (lib.isStream(payload)) { 89 | _peekaboo.stream = true 90 | request.log.trace({ ns: 'peekaboo', message: 'onSend - response is a stream', request: lib.log.request(request) }) 91 | next(null, payload) 92 | request.log.trace({ ns: 'peekaboo', message: 'onSend - acquiring response stream', request: lib.log.request(request) }) 93 | _peekaboo.body = lib.acquireStream(payload) 94 | request.log.trace({ ns: 'peekaboo', message: 'onSend - response stream acquired', request: lib.log.request(request) }) 95 | return 96 | } else { 97 | _peekaboo.body = payload 98 | request.log.trace({ ns: 'peekaboo', message: 'onSend - response acquired', request: lib.log.request(request) }) 99 | } 100 | } else { 101 | response.peekaboo.sent = true 102 | request.log.trace({ ns: 'peekaboo', message: 'onSend - response sent from cache', request: lib.log.request(request) }) 103 | } 104 | request.log.trace({ ns: 'peekaboo', message: 'onSend - done', request: lib.log.request(request) }) 105 | next(null, payload) 106 | })() 107 | } 108 | 109 | const onResponse = function (request, response, next) { 110 | (async () => { 111 | if (_settings.mode == 'off' || _settings.mode == 'stock' || !response.peekaboo.match || response.peekaboo.sent) { 112 | request.log.trace({ ns: 'peekaboo', message: 'onResponse - response has not to be cached', request: lib.log.request(request) }) 113 | return next() 114 | } 115 | request.log.trace({ ns: 'peekaboo', message: 'onResponse', request: lib.log.request(request) }) 116 | 117 | const _entry = { 118 | response: { 119 | status: response.statusCode, 120 | headers: {}, 121 | body: await response.peekaboo.body 122 | } 123 | } 124 | 125 | const _headers = response.raw._header 126 | .split('\r\n') 127 | .map(header => { 128 | const [key, ...value] = header.split(':') 129 | return { 130 | key: key.toLowerCase(), 131 | value: value.join(':').trim() 132 | } 133 | }) 134 | .filter((header) => { 135 | return !!header.value 136 | }) 137 | 138 | for (let index = 0; index < _headers.length; index++) { 139 | const _header = _headers[index] 140 | _entry.response.headers[_header.key] = _header.value 141 | } 142 | 143 | if (response.peekaboo.stream) { 144 | // request.log.trace('plugin', 'onResponse', 'response body content-type', _set.headers['content-type']) 145 | // @todo _set.body = _set.body.toString(charset(_set.headers['content-type']) || 'utf8') 146 | if (contentTypeText(_entry.response.headers['content-type'])) { 147 | _entry.response.body = _entry.response.body.toString('utf8') 148 | } 149 | } 150 | 151 | if (_entry.response.headers['content-type'].includes('json')) { 152 | try { 153 | _entry.response.body = JSON.parse(_entry.response.body) 154 | } catch (error) { } 155 | } 156 | 157 | // @todo custom trim headers function 158 | delete _entry.response.headers.status 159 | delete _entry.response.headers.connection 160 | delete _entry.response.headers['transfer-encoding'] 161 | 162 | if (match.response(_entry.response, response.peekaboo.rule)) { 163 | if (!_settings.noinfo) { 164 | _entry.request = { 165 | method: request.method, 166 | route: request.raw.url, 167 | headers: request.headers, 168 | query: request.query, 169 | body: request.body ? JSON.stringify(request.body) : undefined 170 | } 171 | _entry.info = { 172 | rule: stringify(response.peekaboo.rule), 173 | created: Date.now() 174 | } 175 | } 176 | await _storage.set(response.peekaboo.hash, _entry, settings.expire) 177 | } 178 | 179 | // @todo "next()" could be moved after "await response.peekaboo.body" 180 | next() 181 | })() 182 | } 183 | 184 | try { 185 | _init(settings) 186 | } catch (error) { 187 | return next(error) 188 | } 189 | 190 | fastify.decorate('peekaboo', { 191 | mode: { 192 | set: function (value) { 193 | if (!['off', 'memoize', 'collector', 'stock'].includes(value)) { 194 | fastify.log.warn({ ns: 'peekaboo', message: `try to set invalid mode "${value}", ignore` }) 195 | return 196 | } 197 | fastify.log.info({ ns: 'peekaboo', message: `change mode from ${_settings.mode} to ${value}` }) 198 | _settings.mode = value 199 | }, 200 | get: function () { 201 | return _settings.mode 202 | } 203 | }, 204 | dataset: { 205 | /** 206 | * @async 207 | */ 208 | get: function () { 209 | return _storage.dataset.get() 210 | }, 211 | /** 212 | * @async 213 | */ 214 | set: async function (id) { 215 | await _storage.dataset.set(id) 216 | }, 217 | /** 218 | * get the dataset id currently in use (sync) 219 | */ 220 | current: function () { 221 | return _storage.dataset.current() 222 | }, 223 | /** 224 | * @async 225 | * @param {string} name 226 | * @returns {hash} id 227 | * @throws 228 | */ 229 | create: function (name) { 230 | return _storage.dataset.create(name) 231 | }, 232 | /** 233 | * @async 234 | * @param {hash} id 235 | * @param {string} name 236 | * @throws 237 | */ 238 | update: async function (id, name) { 239 | return _storage.dataset.update(id, name) 240 | }, 241 | /** 242 | * @async 243 | * @param {hash} id 244 | * @throws 245 | */ 246 | remove: async function (id) { 247 | return _storage.dataset.remove(id) 248 | } 249 | } 250 | }) 251 | 252 | fastify.decorateReply('peekaboo', {}) 253 | fastify.decorateRequest('peekaboo', {}) 254 | fastify.addHook('preHandler', preHandler) 255 | fastify.addHook('onSend', onSend) 256 | fastify.addHook('onResponse', onResponse) 257 | 258 | next() 259 | } 260 | 261 | const contentTypeText = function (contentType) { 262 | return contentType && contentType.indexOf('text') !== -1 263 | } 264 | 265 | module.exports = plug(plugin, { 266 | fastify: '3', 267 | name: package_.name 268 | }) 269 | 270 | Object.assign(module.exports, lib) 271 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastify-peekaboo 2 | 3 | [![NPM Version](http://img.shields.io/npm/v/fastify-peekaboo.svg?style=flat)](https://www.npmjs.org/package/fastify-peekaboo) 4 | [![NPM Downloads](https://img.shields.io/npm/dm/fastify-peekaboo.svg?style=flat)](https://www.npmjs.org/package/fastify-peekaboo) 5 | [![JS Standard Style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com/) 6 | ![100% code coverage](https://img.shields.io/badge/coverage-100%25-brightgreen) 7 | ![Snyk Security Rate](https://snyk-widget.herokuapp.com/badge/npm/fastify-peekaboo/1.3.0/badge.svg) 8 | 9 | fastify plugin for memoize responses by expressive settings. 10 | 11 | ## Purpose 12 | 13 | Use arbitrary cache to serve responses from previous elaboration, matching them by request and response. 14 | 15 | - [fastify-peekaboo](#fastify-peekaboo) 16 | - [Purpose](#purpose) 17 | - [Installing](#installing) 18 | - [Quick start](#quick-start) 19 | - [Storage and dataset](#storage-and-dataset) 20 | - [Upgrade from v.1 to v.2](#upgrade-from-v1-to-v2) 21 | - [Settings](#settings) 22 | - [settings](#settings-1) 23 | - [settings.rules](#settingsrules) 24 | - [settings.mode](#settingsmode) 25 | - [settings.storage](#settingsstorage) 26 | - [settings.expire](#settingsexpire) 27 | - [settings.xheader](#settingsxheader) 28 | - [settings.noinfo](#settingsnoinfo) 29 | - [Log](#log) 30 | - [Documentation](#documentation) 31 | - [Changelog](#changelog) 32 | - [Roadmap](#roadmap) 33 | - [v. 2.3](#v-23) 34 | - [v. 2.4](#v-24) 35 | - [v. 2.5](#v-25) 36 | - [License](#license) 37 | 38 | ## Installing 39 | 40 | ````bash 41 | npm i fastify-peekaboo 42 | ```` 43 | 44 | ### Quick start 45 | 46 | ```js 47 | const fastify = require('fastify') 48 | const peekaboo = require('fastify-peekaboo') 49 | const fs = require('fs') 50 | 51 | const _fastify = fastify() 52 | _fastify.register(peekaboo, { 53 | // default settings: cache good stuff for 1 day 54 | rules: [{ 55 | request: { 56 | methods: true, 57 | route: true 58 | }, 59 | response: { 60 | status: (code) => code > 199 && code < 300 61 | } 62 | }], 63 | mode: 'memoize', 64 | storage: { mode: 'memory' }, 65 | expire: 86400000, // 1 day in ms 66 | xheader: true, 67 | log: false 68 | }) 69 | 70 | _fastify.get('/home', async (request, response) => { 71 | const _home = 'welcome!' 72 | response.send(_home) 73 | }) 74 | 75 | _fastify.get('/image', async (request, response) => { 76 | response.send(fs.createReadStream('image.png')) 77 | }) 78 | 79 | await _fastify.listen(80) 80 | ``` 81 | 82 | First call to `/home` or `/image` will execute the handler; from second time content will be served straight from the cache without running the handlers. 83 | 84 | Cache storage can be `memory` (ram), `fs`. 85 | 86 | ## Storage and dataset 87 | 88 | `dataset` feature allow to have different caches and switching between them. 89 | 90 | Example: create a new dataset and use it 91 | 92 | ```js 93 | fastify.post('/dataset', async (request, response) => { 94 | try { 95 | const id = await _fastify.peekaboo.dataset.create(request.body.name) 96 | await _fastify.peekaboo.dataset.set(id) 97 | response.send({ id }) 98 | } catch (error) { 99 | response.send({ message: error.message }) 100 | } 101 | }) 102 | ``` 103 | 104 | See [documentation](./doc/README.md#dataset) for full information and examples. 105 | 106 | ### Upgrade from v.1 to v.2 107 | 108 | If you are using `memory` storage, cache is volatile, no action is required. 109 | In order to keep cache using `fs` storage, move dir and content from `peekaboo` to `peekaboo/default`; otherwise, a new empty cache is created. 110 | 111 | Update API calls for `set.mode` and `get.mode` to `mode.set` and `mode.get`. 112 | 113 | ## Settings 114 | 115 | Cache works by matching request and response. 116 | If `request` (and `response`) match, `response` is saved by hashing the matching `request`. 117 | The first rule that match the request is choosen. 118 | 119 | ### settings 120 | 121 | ```js 122 | { 123 | rules: MatchingRule[], 124 | mode: Mode, 125 | storage: Storage, 126 | expire: number, 127 | xheader: boolean, 128 | noinfo: boolean 129 | } 130 | ``` 131 | 132 | #### settings.rules 133 | 134 | The set of rules that indicate to use cache or not for requests. 135 | See [matching system](./doc/README.md#matching-system) for details. 136 | 137 | #### settings.mode 138 | 139 | type: `string`, one of `memoize`, `off`, `collector`, `stock` 140 | default: `memoize` 141 | 142 | It set how the cache system behave: 143 | 144 | - **memoize** 145 | on each request, if the relative cache entry is present serve that, or elaborate and cache on response if it doesn't 146 | - **collector** 147 | cache entries but don't use cache for serve responses 148 | - **stock** 149 | serve only responses from cache or `404` if the cache entry does not exists 150 | - **off** 151 | the plugin is not used at all 152 | 153 | You can get/set also at runtime by 154 | 155 | ```js 156 | fastify.get('/cache/mode', async (request, response) => { 157 | response.send({ mode: fastify.peekaboo.mode.get() }) 158 | }) 159 | fastify.get('/cache/mode/:mode', async (request, response) => { 160 | fastify.peekaboo.mode.set(request.params.mode) 161 | response.send('set mode ' + request.params.mode) 162 | }) 163 | ``` 164 | 165 | #### settings.storage 166 | 167 | - `mode` 168 | type: `string` [ `memory` | `fs` ] 169 | default: `memory` 170 | - `memory` (default) cache use runtime memory 171 | - `fs` use filesystem, need also `config.path` 172 | 173 | - storage `config` 174 | type: `object` 175 | 176 | for `file` mode 177 | - `path` 178 | type: `string` 179 | dir path where files will be stored 180 | 181 | ```js 182 | { 183 | mode: 'memory' 184 | } 185 | 186 | { 187 | mode: 'fs', 188 | config: { path: '/tmp/peekaboo' } 189 | } 190 | ``` 191 | 192 | See [storage documentation](./doc/README.md#storage) for further information about to access and manipulate entries. 193 | 194 | #### settings.expire 195 | 196 | type: `number` 197 | default: `86400000` // 1 day 198 | cache expiration in ms, optional. 199 | 200 | #### settings.xheader 201 | 202 | type: `boolean` 203 | default: `true` 204 | add on response header `x-peekaboo` and `x-peekaboo-hash` if response comes from cache. 205 | 206 | #### settings.noinfo 207 | 208 | type: `boolean` 209 | default: `false` 210 | do not store info (matching rule, request) for entries, in order to speed up a little bit in write/read cache and save space; info are needed only for cache manipulation. 211 | 212 | ### Log 213 | 214 | Use server log settings 215 | 216 | ```js 217 | fastify({ logger: true }) 218 | ``` 219 | 220 | ## Documentation 221 | 222 | See [documentation](./doc/README.md) for further information and examples. 223 | 224 | --- 225 | 226 | ## Changelog 227 | 228 | - **v. 2.3.1** [ 2021-03-02 ] 229 | - fix route hashing 230 | 231 | - **v. 2.3.0** [ 2021-03-01 ] 232 | - update deps 233 | 234 | - **v. 2.2.0** [ 2020-10-11 ] 235 | - update matching `function` allow cache based on values, [see notes](./doc/README.md#match-by-function-notes) 236 | - update documentation 237 | - update deps 238 | 239 | - **v. 2.0.0** [ 2020-09-25 ] 240 | - add `dataset` feature 241 | - update `mode` public methods 242 | 243 | - **v. 1.3.0** [ 2020-07-25 ] 244 | - stable version 245 | - update to `fastify v3` 246 | - update deps 247 | 248 | - **v. 1.2.0-beta** [ 2020-06-18 ] beta 249 | - move to `beta` stage 250 | - fix fs storage persistence 251 | - add `mode` (memoize, off, collector, stock) 252 | - add storage access for editing: `get`, `list`, `set`, `rm`, `clear` 253 | - add `info` in stored entries and `settings.noinfo` to skip that 254 | - add `x-peekaboo-hash` in xheader 255 | 256 | - **v. 1.1.0-alpha** [ 2020-05-14 ] alpha 257 | - drop `keyv` storage 258 | 259 | - **v. 1.0.0-alpha** [ 2020-05-03 ] alpha 260 | - new matching system 261 | - drop redis storage 262 | - 100% test coverage 263 | - validate settings with `superstruct` 264 | 265 | - **v. 0.5.0-beta** [ 2020-04-30 ] beta 266 | - upgrade dependencies 267 | 268 | - **v. 0.4.0-beta** [ 2019-05-21 ] beta 269 | - upgrade to `fastify v.2` 270 | - fix redis connection close (by fork to keyv redis adapter https://github.com/simone-sanfratello/keyv-redis) 271 | 272 | - **v. 0.1.0-alpha** [ 2018-12-27 ] alpha 273 | - first release 274 | 275 | --- 276 | 277 | ## Roadmap 278 | 279 | ### v. 2.3 280 | 281 | - [ ] remove `got` in favor of natvie `http` client 282 | - [ ] `response.rewrite` option 283 | - [ ] `request.rewrite` option 284 | - [ ] postgresql storage 285 | - [ ] redis storage 286 | 287 | ### v. 2.4 288 | 289 | - [ ] doc: real world examples 290 | - [ ] benchmark plugin overhead (autocannon?) 291 | - [ ] benchmark with different storages 292 | - [ ] support binary request/response (upload, download) 293 | - [ ] test edge cases 294 | - [ ] querystring array or object 295 | - [ ] preset recipes (example graphql caching) 296 | - [ ] CI 297 | 298 | ### v. 2.5 299 | 300 | - [ ] fine grained settings (storage, expiration, xheader ...) for each rule 301 | - [ ] invalidate cache (by ...?) 302 | - [ ] expire can be a function(request, response) 303 | 304 | --- 305 | 306 | ## License 307 | 308 | The MIT License (MIT) 309 | 310 | Copyright (c) 2018-2020 [Simone Sanfratello](https://braceslab.com) 311 | 312 | Permission is hereby granted, free of charge, to any person obtaining a copy 313 | of this software and associated documentation files (the "Software"), to deal 314 | in the Software without restriction, including without limitation the rights 315 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 316 | copies of the Software, and to permit persons to whom the Software is 317 | furnished to do so, subject to the following conditions: 318 | 319 | The above copyright notice and this permission notice shall be included in all 320 | copies or substantial portions of the Software. 321 | 322 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 323 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 324 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 325 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 326 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 327 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 328 | SOFTWARE. 329 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | **Index** 2 | 3 | - [Matching system](#matching-system) 4 | - [MatchRule](#matchrule) 5 | - [MatchRequest](#matchrequest) 6 | - [MatchResponse](#matchresponse) 7 | - [MatchString](#matchstring) 8 | - [MatchNumber](#matchnumber) 9 | - [MatchList](#matchlist) 10 | - [MatchObject](#matchobject) 11 | - [Match by function notes](#match-by-function-notes) 12 | - [Storage](#storage) 13 | - [storage.get](#storageget) 14 | - [storage.set](#storageset) 15 | - [storage.rm](#storagerm) 16 | - [storage.clear](#storageclear) 17 | - [storage.list](#storagelist) 18 | - [Dataset](#dataset) 19 | - [dataset.get](#datasetget) 20 | - [dataset.create](#datasetcreate) 21 | - [dataset.update](#datasetupdate) 22 | - [dataset.remove](#datasetremove) 23 | - [dataset.set](#datasetset) 24 | - [dataset.current](#datasetcurrent) 25 | - [Examples](#examples) 26 | 27 | --- 28 | 29 | ## Matching system 30 | 31 | ### MatchRule 32 | 33 | ```js 34 | { 35 | request: MatchRequest, 36 | response?: MatchResponse 37 | } 38 | ``` 39 | 40 | #### MatchRequest 41 | 42 | `route` and `methods` are mandatory, while can be any values. 43 | 44 | ```js 45 | { 46 | methods: MatchList, 47 | route: MatchString, 48 | headers?: MatchObject, 49 | body?: MatchObject, 50 | query?: MatchObject 51 | } 52 | ``` 53 | 54 | #### MatchResponse 55 | 56 | ```js 57 | { 58 | status?: MatchNumber, 59 | headers?: MatchObject, 60 | body?: MatchObject 61 | } 62 | ``` 63 | 64 | #### MatchString 65 | 66 | `request.route` and properties of `MatchObject` use a string matching logic and can be matched by: 67 | 68 | - `true` or `false` 69 | - string 70 | - regexp 71 | - function 72 | 73 | **Examples** 74 | 75 | ```js 76 | route: true 77 | ``` 78 | 79 | match everything not null 80 | 81 | ```js 82 | authorization: false 83 | ``` 84 | 85 | match if value is not set (or undefined) 86 | 87 | ```js 88 | route: '/home' 89 | ``` 90 | 91 | exact match: match if value is `===` 92 | 93 | ```js 94 | route: /^\/public/ 95 | ``` 96 | 97 | use a regexp: everything starts with `/public` in this case 98 | 99 | ```js 100 | route: (value) => value != '/private' 101 | ``` 102 | 103 | use a function for more logic 104 | 105 | #### MatchNumber 106 | 107 | Only `response.status` uses this 108 | 109 | ```js 110 | status: true 111 | ``` 112 | 113 | always 114 | 115 | ```js 116 | status: false 117 | ``` 118 | 119 | never 120 | 121 | ```js 122 | status: 200 123 | ``` 124 | 125 | exact match: match if value is `200` but not `201` 126 | 127 | ```js 128 | status: /^2/ 129 | ``` 130 | 131 | use a regexp: `2xx` response status are ok 132 | 133 | ```js 134 | status: (code) => code > 199 && code < 300 135 | ``` 136 | 137 | use a function for more logic: `2xx` statuses are ok 138 | 139 | #### MatchList 140 | 141 | Only `request.methods` uses this 142 | 143 | ```js 144 | methods: true 145 | ``` 146 | 147 | always 148 | 149 | ```js 150 | methods: '*' 151 | ``` 152 | 153 | always 154 | 155 | ```js 156 | methods: false 157 | ``` 158 | 159 | never 160 | 161 | ```js 162 | methods: 'get' 163 | ``` 164 | 165 | exact match: cache only `GET` requests. Allowed values: 166 | `'get', 'head', 'post', 'put', 'delete', 'options', 'patch'` 167 | 168 | ```js 169 | methods: ['get', 'head'] 170 | ``` 171 | 172 | match methods in the list 173 | 174 | ```js 175 | methods: (method) => method != 'delete' 176 | ``` 177 | 178 | use a function for more logic: anything but `DELETE` 179 | 180 | #### MatchObject 181 | 182 | `request.headers`, `request.body`, `request.query`, `response.headers` and `response.body` use the same logic. 183 | 184 | ```js 185 | request: { body: true } 186 | ``` 187 | 188 | match if body is present; also use the whole body for caching 189 | 190 | ```js 191 | request: { body: false } 192 | ``` 193 | 194 | match if body not is present 195 | 196 | ```js 197 | response: { 198 | headers: (headers) => { 199 | return !headers.authorization 200 | } 201 | } 202 | ``` 203 | 204 | match using a function, in this case only if `response` has no `authorization` header 205 | 206 | ```js 207 | response: { 208 | headers: { 209 | 'content-type': /^\/image\//, 210 | 'content-length': (length) => length < 2048 211 | } 212 | } 213 | ``` 214 | 215 | Match single object entries using a `MatchString` logic. 216 | All entries must succeed in order to match the object. 217 | In this case, match all sent images less than 2k. 218 | 219 | --- 220 | 221 | ### Match by function notes 222 | 223 | In case the function returns `true`, the whole part is considered for caching; 224 | if the function returns a value, the value is taken: in this way you can add a custom logic 225 | for caching based on function evaluation. 226 | 227 | **Example** 228 | 229 | ```js 230 | response: { 231 | body: (body) => { 232 | return { user: body.userId } 233 | } 234 | } 235 | ``` 236 | 237 | This is applied to matching `request.methods`, `request.route`, `request.headers`, `request.body`, `request.query`, but not on `MatchingObject` field functions. 238 | 239 | --- 240 | 241 | ## Storage 242 | 243 | The storage allow access to entries for: 244 | 245 | ### storage.get 246 | 247 | retrieve the entry 248 | 249 | ```js 250 | fastify.get('/cache/get/:hash', async (request, response) => { 251 | response.send(await request.peekaboo.storage.get(request.params.hash)) 252 | }) 253 | 254 | { 255 | "response": { 256 | "status": 200, 257 | "headers": { 258 | "date": "Mon, 01 Jun 2020 12:46:29 GMT", 259 | "content-type": "application/json;charset=UTF-8", 260 | "content-length": "329" 261 | }, 262 | "body": { ... } 263 | }, 264 | "request": { 265 | "method": "GET", 266 | "route": "/my/route", 267 | "headers": { 268 | "host": "localhost:8080", 269 | "client-platform": "web", 270 | "authorization": "Bearer 8JWyaSndABPj3APA3MmmF50m2bNa", 271 | "content-type": "application/json; charset=UTF-8", 272 | "accept": "application/json", 273 | "accept-encoding": "gzip, deflate, br", 274 | } 275 | }, 276 | "info": { 277 | "rule": "{request:{methods:'*',route:/^\\/url/,body:true,query:true},response:{status:(status) => status > 199 && status < 501}}", 278 | "created": 1591015589805 279 | }, 280 | "expire": 1622551589805 281 | } 282 | ``` 283 | 284 | ### storage.set 285 | 286 | set the content of a entry, all part must be provided: 287 | 288 | ```js 289 | fastify.put('/cache/set/:hash', async (request, response) => { 290 | const update = { 291 | response: { 292 | status: 200, 293 | headers: { 'content-type': 'application/json;charset=UTF-8', 'content-length': '123' }, 294 | body: { new: 'content' }, 295 | expire: 1622551586632 296 | } 297 | } 298 | await request.peekaboo.storage.set(request.params.hash, update) 299 | response.send('entry updated') 300 | }) 301 | ``` 302 | 303 | ### storage.rm 304 | 305 | ```js 306 | fastify.delete('/cache/rm/:hash', async (request, response) => { 307 | await request.peekaboo.storage.rm(request.params.hash) 308 | response.send('entry removed') 309 | }) 310 | ``` 311 | 312 | ### storage.clear 313 | 314 | ```js 315 | fastify.delete('/cache/clear', async (request, response) => { 316 | await request.peekaboo.storage.clear() 317 | response.send('cache is empty now') 318 | }) 319 | ``` 320 | 321 | ### storage.list 322 | 323 | retrieve the hashes of entries 324 | 325 | ```js 326 | fastify.delete('/cache/list', async (request, response) => { 327 | response.send(await request.peekaboo.storage.list()) 328 | }) 329 | 330 | ["48471f2408e9e1c2f9058060f5723f40e93cd965c0ab2322d1…", "af1ec22be30172fb69f9624b91042d9945943db81da052554a…"] 331 | ``` 332 | 333 | ## Dataset 334 | 335 | Storage uses a default dataset, however storage can use many dataset at runtime. 336 | Dataset are volatile using `memory` storage, but persist using `fs` storage. 337 | 338 | ### dataset.get 339 | 340 | Get the dataset status: `entries`, `default` and `current`. 341 | 342 | ```js 343 | fastify.get('/dataset', async (request, response) => { 344 | response.send(await fastify.peekaboo.dataset.get()) 345 | }) 346 | ``` 347 | 348 | ### dataset.create 349 | 350 | Create a new dataset with the given `name`. An error occurs if `name` is not valid or empty. 351 | 352 | ```js 353 | fastify.post('/dataset', async (request, response) => { 354 | try { 355 | const id = await fastify.peekaboo.dataset.create(request.body.name) 356 | response.send({ id }) 357 | } catch (error) { 358 | response.code(400).send({ message: error.message }) 359 | } 360 | }) 361 | ``` 362 | 363 | ### dataset.update 364 | 365 | Update a dataset `name`. An error occurs if `name` is not valid or empty or the `id` is not valid. 366 | 367 | ```js 368 | fastify.patch('/dataset', async (request, response) => { 369 | try { 370 | await fastify.peekaboo.dataset.update(request.body.id, request.body.name) 371 | response.send({}) 372 | } catch (error) { 373 | response.code(400).send({ message: error.message }) 374 | } 375 | }) 376 | ``` 377 | 378 | ### dataset.remove 379 | 380 | Remove a dataset. An error occurs trying to remove the `default` dataset or the `id` is not valid. 381 | 382 | ```js 383 | fastify.patch('/dataset', async (request, response) => { 384 | try { 385 | await fastify.peekaboo.dataset.update(request.body.id, request.body.name) 386 | response.send({}) 387 | } catch (error) { 388 | response.code(400).send({ message: error.message }) 389 | } 390 | }) 391 | ``` 392 | 393 | ### dataset.set 394 | 395 | Set the dataset in use. An error occurs trying if the `id` is not valid. 396 | 397 | ```js 398 | fastify.get('/dataset/:id', async (request, response) => { 399 | try { 400 | await fastify.peekaboo.dataset.set(request.params.id) 401 | response.send({}) 402 | } catch (error) { 403 | response.code(400).send({ message: error.message }) 404 | } 405 | }) 406 | ``` 407 | 408 | ### dataset.current 409 | 410 | Get the id of the dataset currently in use. It's the same value of `dataset.get().current`, but the function is sync. 411 | 412 | ```js 413 | fastify.get('/dataset/current', async (request, response) => { 414 | response.send({current: fastify.peekaboo.dataset.current() }) 415 | }) 416 | ``` 417 | 418 | --- 419 | 420 | ## Examples 421 | 422 | Setup and run 423 | 424 | ```js 425 | const fastify = require('fastify') 426 | const peekaboo = require('fastify-peekaboo') 427 | 428 | const fastify = fastify() 429 | fastify.register(peekaboo, { 430 | rules: [ 431 | // list of matches, see below 432 | ]} 433 | ) 434 | ``` 435 | 436 | - cache `GET /home` (using default settings) 437 | 438 | ```js 439 | const rules = [{ 440 | request: { 441 | methods: 'get', 442 | route: '/home' 443 | } 444 | }] 445 | ``` 446 | 447 | - response using cache after from the second time, same response always 448 | 449 | ```js 450 | fastify.get('/home', async (request, response) => { 451 | response.send('hey there') 452 | }) 453 | ``` 454 | 455 | - cache route /session by cookie 456 | 457 | ```js 458 | const rules = [{ 459 | request: { 460 | methods: '*', 461 | route: '/session', 462 | headers: { 463 | cookie: true 464 | } 465 | } 466 | }] 467 | ``` 468 | 469 | - response using cache but different from header/cookie, means that every request is based on cookie 470 | 471 | ```js 472 | fastify.get('/session', async (request, response) => { 473 | // cookie parsing is done by a plugin like fastify-cookie 474 | // ... retrieve user 475 | const _user = user.retrieve(request.cookies.token) 476 | response.send('welcome ' + _user.name) 477 | }) 478 | ``` 479 | 480 | - cache route /content even if response is an error 481 | 482 | ```js 483 | const rules = [{ 484 | request: { 485 | methods: 'get', 486 | route: /^\/content/, 487 | }, 488 | response: { 489 | headers: { 490 | status: true 491 | } 492 | } 493 | }] 494 | ``` 495 | 496 | - response using cache either on error too 497 | 498 | ```js 499 | fastify.get('/content/:id', async (request, response) => { 500 | const _id = parseInt(request.params.id) 501 | if (isNaN(_id)) { 502 | response.code(405).send('BAD_REQUEST') 503 | return 504 | } 505 | response.send('your content ...') 506 | }) 507 | ``` 508 | -------------------------------------------------------------------------------- /test/e2e/10-dataset.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fs = require('fs-extra') 5 | const fastify = require('fastify') 6 | const helper = require('../helper') 7 | 8 | const peekaboo = require('../../src/plugin') 9 | 10 | const rules = [{ 11 | request: { 12 | methods: true, 13 | route: true 14 | } 15 | }] 16 | 17 | const storages = { 18 | memory: { 19 | xheader: true, 20 | expire: 30 * 1000, 21 | rules, 22 | storage: { 23 | mode: 'memory' 24 | } 25 | }, 26 | 27 | fs: { 28 | xheader: true, 29 | expire: 30 * 1000, 30 | rules, 31 | storage: { 32 | mode: 'fs', 33 | config: { 34 | path: '/tmp/peekaboo-dataset' 35 | } 36 | } 37 | } 38 | } 39 | 40 | for (const storage in storages) { 41 | tap.test(`dataset create (${storage})`, async (_test) => { 42 | _test.plan(3) 43 | 44 | await fs.remove('/tmp/peekaboo-dataset') 45 | 46 | const _fastify = fastify() 47 | _fastify.register(peekaboo, storages[storage]) 48 | 49 | _fastify.all('/dataset/create/:name', async (request, response) => { 50 | const id = await _fastify.peekaboo.dataset.create(request.params.name) 51 | response.send({ 52 | new: id, 53 | get: await _fastify.peekaboo.dataset.get() 54 | }) 55 | }) 56 | 57 | await helper.fastify.start(_fastify) 58 | 59 | try { 60 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/dataset/create/parallel') }) 61 | const content = JSON.parse(_response.body) 62 | _test.true(helper.assert.isId(content.new)) 63 | _test.true(content.get.entries) 64 | } catch (error) { 65 | _test.threw(error) 66 | } 67 | 68 | await helper.fastify.stop(_fastify) 69 | _test.pass() 70 | }) 71 | 72 | tap.test(`dataset create (${storage}) error empty name`, async (_test) => { 73 | _test.plan(2) 74 | 75 | const _fastify = fastify() 76 | _fastify.register(peekaboo, storages[storage]) 77 | 78 | _fastify.all('/dataset/create/:name', async (request, response) => { 79 | try { 80 | await _fastify.peekaboo.dataset.create(request.params.name) 81 | response.send({}) 82 | } catch (error) { 83 | response.send({ message: error.message }) 84 | } 85 | }) 86 | 87 | await helper.fastify.start(_fastify) 88 | 89 | try { 90 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/dataset/create/') }) 91 | const content = JSON.parse(_response.body) 92 | _test.equal(content.message, 'INVALID_DATASET_NAME') 93 | } catch (error) { 94 | _test.threw(error) 95 | } 96 | 97 | await helper.fastify.stop(_fastify) 98 | _test.pass() 99 | }) 100 | 101 | tap.test(`dataset update (${storage})`, async (_test) => { 102 | _test.plan(2) 103 | const _fastify = fastify() 104 | _fastify.register(peekaboo, storages[storage]) 105 | 106 | _fastify.get('/dataset/get', async (request, response) => { 107 | response.send(await _fastify.peekaboo.dataset.get()) 108 | }) 109 | 110 | _fastify.all('/dataset/update/:id/:name', async (request, response) => { 111 | await _fastify.peekaboo.dataset.update(request.params.id, request.params.name) 112 | response.send(await _fastify.peekaboo.dataset.get()) 113 | }) 114 | 115 | await helper.fastify.start(_fastify) 116 | 117 | try { 118 | let _response = await helper.request({ url: helper.fastify.url(_fastify, '/dataset/get') }) 119 | let _content = JSON.parse(_response.body) 120 | const id = _content.default 121 | _response = await helper.request({ url: helper.fastify.url(_fastify, `/dataset/update/${id}/parallelo`) }) 122 | _content = JSON.parse(_response.body) 123 | _test.equal(_content.entries[id], 'parallelo') 124 | } catch (error) { 125 | _test.threw(error) 126 | } 127 | 128 | await helper.fastify.stop(_fastify) 129 | _test.pass() 130 | }) 131 | 132 | tap.test(`dataset update (${storage}) error empty name`, async (_test) => { 133 | _test.plan(2) 134 | 135 | const _fastify = fastify() 136 | _fastify.register(peekaboo, storages[storage]) 137 | 138 | _fastify.all('/dataset/update/empty', async (request, response) => { 139 | try { 140 | const id = (await _fastify.peekaboo.dataset.get()).default 141 | await _fastify.peekaboo.dataset.update(id, '') 142 | response.send({}) 143 | } catch (error) { 144 | response.send({ message: error.message }) 145 | } 146 | }) 147 | 148 | await helper.fastify.start(_fastify) 149 | 150 | try { 151 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/dataset/update/empty') }) 152 | const _content = JSON.parse(_response.body) 153 | _test.equal(_content.message, 'INVALID_DATASET_NAME') 154 | } catch (error) { 155 | _test.threw(error) 156 | } 157 | 158 | await helper.fastify.stop(_fastify) 159 | _test.pass() 160 | }) 161 | 162 | tap.test(`dataset update (${storage}) invalid id`, async (_test) => { 163 | _test.plan(2) 164 | const _fastify = fastify() 165 | _fastify.register(peekaboo, storages[storage]) 166 | 167 | _fastify.all('/update/invalid', async (request, response) => { 168 | try { 169 | await _fastify.peekaboo.dataset.update('not-an-id', 'name') 170 | response.send({}) 171 | } catch (error) { 172 | response.send({ message: error.message }) 173 | } 174 | }) 175 | 176 | await helper.fastify.start(_fastify) 177 | 178 | try { 179 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/update/invalid') }) 180 | const _content = JSON.parse(_response.body) 181 | _test.equal(_content.message, 'INVALID_DATASET_ID') 182 | } catch (error) { 183 | _test.threw(error) 184 | } 185 | 186 | await helper.fastify.stop(_fastify) 187 | _test.pass() 188 | }) 189 | 190 | tap.test(`dataset remove (${storage})`, async (_test) => { 191 | _test.plan(2) 192 | const _fastify = fastify() 193 | _fastify.register(peekaboo, storages[storage]) 194 | 195 | _fastify.all('/dataset/create/:name', async (request, response) => { 196 | response.send({ id: await _fastify.peekaboo.dataset.create(request.params.name) }) 197 | }) 198 | _fastify.all('/dataset/remove/:id', async (request, response) => { 199 | await _fastify.peekaboo.dataset.remove(request.params.id) 200 | response.send(await _fastify.peekaboo.dataset.get()) 201 | }) 202 | 203 | await helper.fastify.start(_fastify) 204 | 205 | try { 206 | let _response = await helper.request({ url: helper.fastify.url(_fastify, '/dataset/create/ciao') }) 207 | let _content = JSON.parse(_response.body) 208 | const id = _content.id 209 | _response = await helper.request({ url: helper.fastify.url(_fastify, `/dataset/remove/${id}`) }) 210 | _content = JSON.parse(_response.body) 211 | _test.equal(_content.entries[id], undefined) 212 | } catch (error) { 213 | _test.threw(error) 214 | } 215 | 216 | await helper.fastify.stop(_fastify) 217 | _test.pass() 218 | }) 219 | 220 | tap.test(`dataset remove (${storage}) invalid id`, async (_test) => { 221 | _test.plan(2) 222 | const _fastify = fastify() 223 | _fastify.register(peekaboo, storages[storage]) 224 | 225 | _fastify.all('/remove/invalid', async (request, response) => { 226 | try { 227 | await _fastify.peekaboo.dataset.remove('not-an-id') 228 | response.send({}) 229 | } catch (error) { 230 | response.send({ message: error.message }) 231 | } 232 | }) 233 | 234 | await helper.fastify.start(_fastify) 235 | 236 | try { 237 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/remove/invalid') }) 238 | const _content = JSON.parse(_response.body) 239 | _test.equal(_content.message, 'INVALID_DATASET_ID') 240 | } catch (error) { 241 | _test.threw(error) 242 | } 243 | 244 | await helper.fastify.stop(_fastify) 245 | _test.pass() 246 | }) 247 | 248 | tap.test(`dataset remove (${storage}) current`, async (_test) => { 249 | _test.plan(2) 250 | const _fastify = fastify() 251 | _fastify.register(peekaboo, storages[storage]) 252 | 253 | _fastify.all('/remove/current', async (request, response) => { 254 | try { 255 | const id = await _fastify.peekaboo.dataset.create('new') 256 | await _fastify.peekaboo.dataset.set(id) 257 | await _fastify.peekaboo.dataset.remove(id) 258 | response.send({}) 259 | } catch (error) { 260 | response.send({ message: error.message }) 261 | } 262 | }) 263 | 264 | await helper.fastify.start(_fastify) 265 | 266 | try { 267 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/remove/current') }) 268 | const _content = JSON.parse(_response.body) 269 | _test.pass() 270 | } catch (error) { 271 | _test.threw(error) 272 | } 273 | 274 | await helper.fastify.stop(_fastify) 275 | _test.pass() 276 | }) 277 | 278 | tap.test(`dataset remove (${storage}) error removing default dataset`, async (_test) => { 279 | _test.plan(2) 280 | const _fastify = fastify() 281 | _fastify.register(peekaboo, storages[storage]) 282 | 283 | _fastify.all('/remove/default', async (request, response) => { 284 | const dataset = await _fastify.peekaboo.dataset.get() 285 | try { 286 | await _fastify.peekaboo.dataset.remove(dataset.default) 287 | response.send(dataset) 288 | } catch (error) { 289 | response.send({ message: error.message }) 290 | } 291 | }) 292 | 293 | await helper.fastify.start(_fastify) 294 | 295 | try { 296 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/remove/default') }) 297 | const _content = JSON.parse(_response.body) 298 | _test.equal(_content.message, 'INVALID_DATASET_OPERATION_CANT_REMOVE_DEFAULT') 299 | } catch (error) { 300 | _test.threw(error) 301 | } 302 | 303 | await helper.fastify.stop(_fastify) 304 | _test.pass() 305 | }) 306 | 307 | tap.test(`dataset set (${storage})`, async (_test) => { 308 | _test.plan(2) 309 | const _fastify = fastify() 310 | _fastify.register(peekaboo, storages[storage]) 311 | 312 | _fastify.get('/dataset/get', async (request, response) => { 313 | response.send(await _fastify.peekaboo.dataset.get()) 314 | }) 315 | 316 | _fastify.all('/dataset/set/:id', async (request, response) => { 317 | await _fastify.peekaboo.dataset.set(request.params.id) 318 | response.send({ current: _fastify.peekaboo.dataset.current() }) 319 | }) 320 | 321 | await helper.fastify.start(_fastify) 322 | 323 | try { 324 | let _response = await helper.request({ url: helper.fastify.url(_fastify, '/dataset/get') }) 325 | let _content = JSON.parse(_response.body) 326 | 327 | const id = _content.default 328 | _response = await helper.request({ url: helper.fastify.url(_fastify, `/dataset/set/${id}`) }) 329 | _content = JSON.parse(_response.body) 330 | 331 | _test.equal(_content.current, id) 332 | } catch (error) { 333 | _test.threw(error) 334 | } 335 | 336 | await helper.fastify.stop(_fastify) 337 | _test.pass() 338 | }) 339 | 340 | tap.test(`dataset set (${storage}) invalid id`, async (_test) => { 341 | _test.plan(2) 342 | const _fastify = fastify() 343 | _fastify.register(peekaboo, storages[storage]) 344 | 345 | _fastify.all('/set/invalid', async (request, response) => { 346 | try { 347 | await _fastify.peekaboo.dataset.set('not-an-id') 348 | response.send() 349 | } catch (error) { 350 | response.send({ message: error.message }) 351 | } 352 | }) 353 | 354 | await helper.fastify.start(_fastify) 355 | 356 | try { 357 | const _response = await helper.request({ url: helper.fastify.url(_fastify, '/set/invalid') }) 358 | const _content = JSON.parse(_response.body) 359 | _test.equal(_content.message, 'INVALID_DATASET_CURRENT_VALUE') 360 | } catch (error) { 361 | _test.threw(error) 362 | } 363 | 364 | await helper.fastify.stop(_fastify) 365 | _test.pass() 366 | }) 367 | 368 | } 369 | 370 | // @todo fs load from last stop 371 | // @todo fs use cache from brand new 372 | -------------------------------------------------------------------------------- /test/e2e/04-match-request-body.e2e.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const fastify = require('fastify') 5 | const helper = require('../helper') 6 | 7 | const peekaboo = require('../../src/plugin') 8 | 9 | tap.test('peekaboo matching by request body (*)', 10 | async (_test) => { 11 | _test.plan(3) 12 | const _fastify = fastify() 13 | _fastify 14 | .register(peekaboo, { 15 | xheader: true, 16 | rules: [{ 17 | request: { 18 | methods: '*', 19 | route: true, 20 | body: true 21 | } 22 | }] 23 | }) 24 | 25 | _fastify.post('/update', async (request, response) => { 26 | if (!request.body.name) { 27 | response.send({ error: false, id: request.body.id }) 28 | } else { 29 | response.send({ error: false, name: request.body.name }) 30 | } 31 | }) 32 | 33 | try { 34 | await helper.fastify.start(_fastify) 35 | 36 | let url = helper.fastify.url(_fastify, '/update') 37 | await helper.request({ method: 'post', url, json: { id: 11 } }) 38 | let _response = await helper.request({ method: 'post', url, json: { id: 11 } }) 39 | if (!_response.headers['x-peekaboo']) { 40 | _test.fail() 41 | } 42 | _test.same(JSON.parse(_response.body), { error: false, id: 11 }) 43 | 44 | url = helper.fastify.url(_fastify, '/update') 45 | await helper.request({ method: 'post', url, json: { id: 11, name: 'Alice' } }) 46 | _response = await helper.request({ method: 'post', url, json: { id: 11, name: 'Alice' } }) 47 | if (!_response.headers['x-peekaboo']) { 48 | _test.fail() 49 | } 50 | _test.same(JSON.parse(_response.body), { error: false, name: 'Alice' }) 51 | 52 | await helper.fastify.stop(_fastify) 53 | _test.pass() 54 | } catch (error) { 55 | _test.threw(error) 56 | } 57 | }) 58 | 59 | tap.test('peekaboo matching by request body (string)', 60 | async (_test) => { 61 | _test.plan(3) 62 | const _fastify = fastify() 63 | _fastify 64 | .register(peekaboo, { 65 | xheader: true, 66 | rules: [{ 67 | request: { 68 | methods: '*', 69 | route: '/update/user', 70 | body: { id: true } 71 | } 72 | }] 73 | }) 74 | 75 | _fastify.post('/update/user', async (request, response) => { 76 | response.send({ error: false, id: request.body.id }) 77 | }) 78 | 79 | try { 80 | await helper.fastify.start(_fastify) 81 | 82 | let url = helper.fastify.url(_fastify, '/update/user') 83 | await helper.request({ method: 'post', url, json: { id: 9 } }) 84 | let _response = await helper.request({ method: 'post', url, json: { id: 9 } }) 85 | if (!_response.headers['x-peekaboo']) { 86 | _test.fail() 87 | } 88 | _test.same(JSON.parse(_response.body), { error: false, id: 9 }) 89 | 90 | url = helper.fastify.url(_fastify, '/update/user') 91 | await helper.request({ method: 'post', url, json: { name: 'Alice' } }) 92 | _response = await helper.request({ method: 'post', url, json: { name: 'Alice' } }) 93 | if (_response.headers['x-peekaboo']) { 94 | _test.fail() 95 | } 96 | _test.same(JSON.parse(_response.body), { error: false }) 97 | 98 | await helper.fastify.stop(_fastify) 99 | _test.pass() 100 | } catch (error) { 101 | _test.threw(error) 102 | } 103 | }) 104 | 105 | tap.test('peekaboo matching by request body (array)', 106 | async (_test) => { 107 | _test.plan(4) 108 | const _fastify = fastify() 109 | _fastify 110 | .register(peekaboo, { 111 | xheader: true, 112 | rules: [{ 113 | request: { 114 | methods: '*', 115 | route: '/update/user', 116 | body: { id: true, name: 'Alice' } 117 | } 118 | }] 119 | }) 120 | 121 | _fastify.post('/update/user', async (request, response) => { 122 | response.send({ error: false, ...request.body }) 123 | }) 124 | 125 | try { 126 | await helper.fastify.start(_fastify) 127 | 128 | let url = helper.fastify.url(_fastify, '/update/user') 129 | await helper.request({ method: 'post', url, json: { id: 9 } }) 130 | let _response = await helper.request({ method: 'post', url, json: { id: 9 } }) 131 | if (_response.headers['x-peekaboo']) { 132 | _test.fail() 133 | } 134 | _test.same(JSON.parse(_response.body), { error: false, id: 9 }) 135 | 136 | url = helper.fastify.url(_fastify, '/update/user') 137 | await helper.request({ method: 'post', url, json: { name: 'Alice' } }) 138 | _response = await helper.request({ method: 'post', url, json: { name: 'Alice' } }) 139 | if (_response.headers['x-peekaboo']) { 140 | _test.fail() 141 | } 142 | _test.same(JSON.parse(_response.body), { error: false, name: 'Alice' }) 143 | 144 | url = helper.fastify.url(_fastify, '/update/user') 145 | await helper.request({ method: 'post', url, json: { id: 8, name: 'Mimì' } }) 146 | _response = await helper.request({ method: 'post', url, json: { id: 8, name: 'Mimì' } }) 147 | if (_response.headers['x-peekaboo']) { 148 | _test.fail() 149 | } 150 | _test.same(JSON.parse(_response.body), { error: false, id: 8, name: 'Mimì' }) 151 | 152 | await helper.fastify.stop(_fastify) 153 | _test.pass() 154 | } catch (error) { 155 | _test.threw(error) 156 | } 157 | }) 158 | 159 | tap.test('peekaboo matching by request body (function) returns true', 160 | async (_test) => { 161 | _test.plan(1) 162 | const _fastify = fastify() 163 | _fastify 164 | .register(peekaboo, { 165 | xheader: true, 166 | rules: [{ 167 | request: { 168 | methods: 'put', 169 | route: '/update/user', 170 | body: function (body) { 171 | return !!body 172 | } 173 | } 174 | }] 175 | }) 176 | 177 | _fastify.put('/update/user', async (request, response) => { 178 | response.send({ ok: 1 }) 179 | /* 180 | fastify bug 181 | response 182 | .type('text/plain') 183 | .send('ok') 184 | */ 185 | }) 186 | 187 | try { 188 | await helper.fastify.start(_fastify) 189 | 190 | const url = helper.fastify.url(_fastify, '/update/user') 191 | await helper.request({ method: 'put', url }) 192 | let _response = await helper.request({ method: 'put', url }) 193 | if (_response.headers['x-peekaboo']) { 194 | _test.fail() 195 | } 196 | 197 | await helper.request({ method: 'put', url, json: { name: 'Alice' } }) 198 | _response = await helper.request({ method: 'put', url, json: { name: 'Alice' } }) 199 | if (!_response.headers['x-peekaboo']) { 200 | _test.fail() 201 | } 202 | 203 | await helper.fastify.stop(_fastify) 204 | _test.pass() 205 | } catch (error) { 206 | _test.threw(error) 207 | } 208 | }) 209 | 210 | tap.test('peekaboo matching by request body (function) returns false', 211 | async (_test) => { 212 | _test.plan(1) 213 | const _fastify = fastify() 214 | _fastify 215 | .register(peekaboo, { 216 | xheader: true, 217 | rules: [{ 218 | request: { 219 | methods: '*', 220 | route: '/update/user', 221 | body: () => false 222 | } 223 | }] 224 | }) 225 | 226 | _fastify.post('/update/user', async (request, response) => { 227 | response.send({ user: 1, name: 'Rico' }) 228 | }) 229 | 230 | try { 231 | await helper.fastify.start(_fastify) 232 | 233 | const url = helper.fastify.url(_fastify, '/update/user') 234 | await helper.request({ method: 'post', url }) 235 | let _response = await helper.request({ method: 'post', url }) 236 | if (_response.headers['x-peekaboo']) { 237 | _test.fail() 238 | } 239 | 240 | await helper.request({ method: 'post', url, json: { name: 'Alice' } }) 241 | _response = await helper.request({ method: 'post', url, json: { name: 'Alice' } }) 242 | if (_response.headers['x-peekaboo']) { 243 | _test.fail() 244 | } 245 | 246 | await helper.fastify.stop(_fastify) 247 | _test.pass() 248 | } catch (error) { 249 | _test.threw(error) 250 | } 251 | }) 252 | 253 | tap.test('peekaboo matching by request body (function) where body is json, returns value', 254 | async (_test) => { 255 | _test.plan(1) 256 | const _fastify = fastify() 257 | _fastify 258 | .register(peekaboo, { 259 | xheader: true, 260 | rules: [{ 261 | request: { 262 | methods: '*', 263 | route: '/update/user', 264 | body: (body) => ({ name: body.name }) 265 | } 266 | }] 267 | }) 268 | 269 | _fastify.post('/update/user', async (request, response) => { 270 | response.send({ user: 1, name: 'Rico' }) 271 | }) 272 | 273 | try { 274 | await helper.fastify.start(_fastify) 275 | 276 | const url = helper.fastify.url(_fastify, '/update/user') 277 | await helper.request({ method: 'post', url, json: { name: 'Alice' } }) 278 | let _response = await helper.request({ method: 'post', url, json: { name: 'Alice' } }) 279 | if (!_response.headers['x-peekaboo']) { 280 | _test.fail() 281 | } 282 | 283 | _response = await helper.request({ method: 'post', url, json: { name: 'Katia' } }) 284 | if (_response.headers['x-peekaboo']) { 285 | _test.fail() 286 | } 287 | 288 | _response = await helper.request({ method: 'post', url, json: { name: 'Katia' } }) 289 | if (!_response.headers['x-peekaboo']) { 290 | _test.fail() 291 | } 292 | 293 | await helper.fastify.stop(_fastify) 294 | _test.pass() 295 | } catch (error) { 296 | _test.threw(error) 297 | } 298 | }) 299 | 300 | tap.test('peekaboo matching by request body (function) where body is text, returns value', 301 | async (_test) => { 302 | _test.plan(4) 303 | const _fastify = fastify() 304 | _fastify 305 | .register(peekaboo, { 306 | xheader: true, 307 | rules: [{ 308 | request: { 309 | methods: '*', 310 | route: '/update/user', 311 | body: (body) => { 312 | // special cache for Alice, same cache for anyone else 313 | return body.includes('Alice') ? 'yes' : 'no' 314 | } 315 | } 316 | }] 317 | }) 318 | 319 | _fastify.post('/update/user', async (request, response) => { 320 | response.send(request.body.includes('Alice') ? 'ciao Alice' : 'welcome') 321 | }) 322 | 323 | try { 324 | await helper.fastify.start(_fastify) 325 | 326 | const url = helper.fastify.url(_fastify, '/update/user') 327 | await helper.request({ method: 'post', url, body: 'Alice', headers: { 'content-type': 'text/plain' } }) 328 | let _response = await helper.request({ method: 'post', url, body: 'Alice', headers: { 'content-type': 'text/plain' } }) 329 | if (!_response.headers['x-peekaboo']) { 330 | _test.fail() 331 | } 332 | _test.equal(_response.body, 'ciao Alice') 333 | 334 | _response = await helper.request({ method: 'post', url, body: 'Alissia', headers: { 'content-type': 'text/plain' } }) 335 | if (_response.headers['x-peekaboo']) { 336 | _test.fail() 337 | } 338 | _test.equal(_response.body, 'welcome') 339 | 340 | _response = await helper.request({ method: 'post', url, body: 'Alisson', headers: { 'content-type': 'text/plain' } }) 341 | if (!_response.headers['x-peekaboo']) { 342 | _test.fail() 343 | } 344 | _test.equal(_response.body, 'welcome') 345 | 346 | await helper.fastify.stop(_fastify) 347 | _test.pass() 348 | } catch (error) { 349 | _test.threw(error) 350 | } 351 | }) 352 | 353 | tap.test('peekaboo matching by request body (bool) where body is text', 354 | async (_test) => { 355 | _test.plan(2) 356 | const _fastify = fastify() 357 | _fastify 358 | .register(peekaboo, { 359 | xheader: true, 360 | rules: [{ 361 | request: { 362 | methods: '*', 363 | route: '/update/user', 364 | body: true 365 | } 366 | }] 367 | }) 368 | 369 | _fastify.post('/update/user', async (request, response) => { 370 | response.send(request.body.includes('Alice') ? 'ciao Alice' : 'welcome') 371 | }) 372 | 373 | try { 374 | await helper.fastify.start(_fastify) 375 | 376 | const url = helper.fastify.url(_fastify, '/update/user') 377 | await helper.request({ method: 'post', url, body: 'Alice', headers: { 'content-type': 'text/plain' } }) 378 | const _response = await helper.request({ method: 'post', url, body: 'Alice', headers: { 'content-type': 'text/plain' } }) 379 | if (!_response.headers['x-peekaboo']) { 380 | _test.fail() 381 | } 382 | _test.equal(_response.body, 'ciao Alice') 383 | 384 | await helper.fastify.stop(_fastify) 385 | _test.pass() 386 | } catch (error) { 387 | _test.threw(error) 388 | } 389 | }) 390 | --------------------------------------------------------------------------------