├── .eslintrc ├── .gitignore ├── .travis.yml ├── test ├── api │ ├── lib │ │ ├── data.json │ │ └── routes.js │ ├── express.js │ ├── express-gzip.js │ ├── restify.js │ └── restify-gzip.js ├── mock_api_gzip_restify.js └── apicache_test.js ├── LICENSE ├── src ├── memory-cache.js └── apicache.js ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/node_modules 2 | /node_modules 3 | /npm-debug.log 4 | .DS_STORE 5 | *.rdb 6 | .nyc_output 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 4.0.0 4 | - 6.0.0 5 | - 7.0.0 6 | - 8.0.0 7 | after_success: npm run coverage 8 | -------------------------------------------------------------------------------- /test/api/lib/data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "The Prestige", 4 | "director": "Christopher Nolan" 5 | }, 6 | { 7 | "title": "Schindler's List", 8 | "director": "Steven Spielberg" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /test/api/express.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | var addRoutes = require('./lib/routes') 3 | 4 | function MockAPI(expiration, options, toggle) { 5 | var apicache = require('../../src/apicache').newInstance(options) 6 | var app = express() 7 | 8 | // EMBED UPSTREAM RESPONSE PARAM 9 | app.use(function(req, res, next) { 10 | res.id = 123 11 | next() 12 | }) 13 | 14 | // ENABLE APICACHE 15 | app.use(apicache.middleware(expiration, toggle)) 16 | app.apicache = apicache 17 | 18 | // ADD API ROUTES 19 | app = addRoutes(app) 20 | 21 | return app 22 | } 23 | 24 | module.exports = { 25 | create: function(expiration, config, toggle) { return new MockAPI(expiration, config, toggle) } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /test/api/express-gzip.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | var compression = require('compression') 3 | var addRoutes = require('./lib/routes') 4 | 5 | function MockAPI(expiration, options, toggle) { 6 | var apicache = require('../../src/apicache').newInstance(options) 7 | var app = express() 8 | 9 | // ENABLE COMPRESSION 10 | app.use(compression({ threshold: 1 })) 11 | 12 | // EMBED UPSTREAM RESPONSE PARAM 13 | app.use(function(req, res, next) { 14 | res.id = 123 15 | next() 16 | }) 17 | 18 | // ENABLE APICACHE 19 | app.use(apicache.middleware(expiration, toggle)) 20 | app.apicache = apicache 21 | 22 | // ADD API ROUTES 23 | app = addRoutes(app) 24 | 25 | return app 26 | } 27 | 28 | module.exports = { 29 | create: function(expiration, config, toggle) { return new MockAPI(expiration, config, toggle) } 30 | } 31 | -------------------------------------------------------------------------------- /test/api/restify.js: -------------------------------------------------------------------------------- 1 | var restify = require('restify') 2 | var addRoutes = require('./lib/routes') 3 | 4 | function MockAPI(expiration, options, toggle) { 5 | var apicache = require('../../src/apicache').newInstance(options) 6 | var app = restify.createServer() 7 | 8 | // EMBED UPSTREAM RESPONSE PARAM 9 | app.use(function(req, res, next) { 10 | res.id = 123 11 | next() 12 | }) 13 | 14 | // ENABLE APICACHE 15 | app.use(apicache.middleware(expiration, toggle)) 16 | app.apicache = apicache 17 | 18 | app.use(function(req, res, next) { 19 | res.charSet('utf-8'); 20 | next() 21 | }) 22 | 23 | app.use(require('restify-etag-cache')()) 24 | 25 | // ADD API ROUTES 26 | app = addRoutes(app) 27 | 28 | return app 29 | } 30 | 31 | module.exports = { 32 | create: function(expiration, config, toggle) { return new MockAPI(expiration, config, toggle) } 33 | } 34 | -------------------------------------------------------------------------------- /test/api/restify-gzip.js: -------------------------------------------------------------------------------- 1 | var restify = require('restify') 2 | var addRoutes = require('./lib/routes') 3 | 4 | function MockAPI(expiration, options, toggle) { 5 | var apicache = require('../../src/apicache').newInstance(options) 6 | var app = restify.createServer() 7 | 8 | // ENABLE COMPRESSION 9 | var whichGzip = restify.gzipResponse && restify.gzipResponse() || restify.plugins.gzipResponse() 10 | app.use(whichGzip) 11 | 12 | // EMBED UPSTREAM RESPONSE PARAM 13 | app.use(function(req, res, next) { 14 | res.id = 123 15 | next() 16 | }) 17 | 18 | // ENABLE APICACHE 19 | app.use(apicache.middleware(expiration, toggle)) 20 | app.apicache = apicache 21 | 22 | app.use(function(req, res, next) { 23 | res.charSet('utf-8') 24 | next() 25 | }) 26 | 27 | app.use(require('restify-etag-cache')()) 28 | 29 | // ADD API ROUTES 30 | app = addRoutes(app) 31 | 32 | return app 33 | } 34 | 35 | module.exports = { 36 | create: function(expiration, config, toggle) { return new MockAPI(expiration, config, toggle) } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Kevin Whitley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/memory-cache.js: -------------------------------------------------------------------------------- 1 | function MemoryCache() { 2 | this.cache = {} 3 | this.size = 0 4 | } 5 | 6 | MemoryCache.prototype.add = function(key, value, time, timeoutCallback) { 7 | var old = this.cache[key] 8 | var instance = this 9 | 10 | var entry = { 11 | value: value, 12 | expire: time + Date.now(), 13 | timeout: setTimeout(function() { 14 | instance.delete(key) 15 | return timeoutCallback && typeof timeoutCallback === 'function' && timeoutCallback(value, key) 16 | }, time) 17 | } 18 | 19 | this.cache[key] = entry 20 | this.size = Object.keys(this.cache).length 21 | 22 | return entry 23 | } 24 | 25 | MemoryCache.prototype.delete = function(key) { 26 | var entry = this.cache[key] 27 | 28 | if (entry) { 29 | clearTimeout(entry.timeout) 30 | } 31 | 32 | delete this.cache[key] 33 | 34 | this.size = Object.keys(this.cache).length 35 | 36 | return null 37 | } 38 | 39 | MemoryCache.prototype.get = function(key) { 40 | var entry = this.cache[key] 41 | 42 | return entry 43 | } 44 | 45 | MemoryCache.prototype.getValue = function(key) { 46 | var entry = this.get(key) 47 | 48 | return entry && entry.value 49 | } 50 | 51 | MemoryCache.prototype.clear = function() { 52 | Object.keys(this.cache).forEach(function(key) { 53 | this.delete(key) 54 | }, this) 55 | 56 | return true 57 | } 58 | 59 | module.exports = MemoryCache 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apicache", 3 | "version": "1.1.0", 4 | "scripts": { 5 | "lint": "eslint **/*.js", 6 | "pretest": "npm run lint", 7 | "test": "nyc mocha $(find test -name '*.test.js') --recursive", 8 | "test:watch": "npm run test -- --watch", 9 | "coverage": "nyc report --reporter=text-lcov | coveralls", 10 | "prepublish": "npm run test" 11 | }, 12 | "engines": { 13 | "node": ">=4.0.0" 14 | }, 15 | "description": "An ultra-simplified API response caching middleware for Express/Node using plain-english durations.", 16 | "main": "./src/apicache.js", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/kwhitley/apicache.git" 20 | }, 21 | "keywords": [ 22 | "cache", 23 | "API", 24 | "redis", 25 | "memcache", 26 | "response", 27 | "express", 28 | "JSON", 29 | "duration", 30 | "middleware", 31 | "memory" 32 | ], 33 | "author": "Kevin R. Whitley (http://krwhitley.com)", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/kwhitley/apicache/issues", 37 | "email": "kevin3503@gmail.com" 38 | }, 39 | "devDependencies": { 40 | "chai": "^4.1.2", 41 | "compression": "^1.7.0", 42 | "coveralls": "^2.13.1", 43 | "eslint": "^4.6.1", 44 | "express": "^4.15.4", 45 | "fakeredis": "^2.0.0", 46 | "mocha": "^3.5.0", 47 | "nyc": "^11.2.1", 48 | "restify": "^5.2.0", 49 | "restify-etag-cache": "^1.0.12", 50 | "supertest": "^3.0.0" 51 | }, 52 | "dependencies": {} 53 | } 54 | -------------------------------------------------------------------------------- /test/mock_api_gzip_restify.js: -------------------------------------------------------------------------------- 1 | var movies = [{ 2 | title: 'The Prestige', 3 | director: 'Christopher Nolan', 4 | },{ 5 | title: 'Schindler\'s List', 6 | director: 'Steven Spielberg' 7 | }] 8 | 9 | var instances = [] 10 | 11 | function MockAPI(expiration, options) { 12 | var restify = require('restify') 13 | var apicache = require('../src/apicache').newInstance(options) 14 | 15 | var app = restify.createServer(); 16 | 17 | app.use(restify.gzipResponse()); 18 | 19 | instances.push(this) 20 | 21 | this.apicache = apicache 22 | this.id = instances.length 23 | this.app = app 24 | 25 | instances.forEach(function(instance, id) { 26 | if (instance.id !== this.id && this.apicache === instance.apicache) { 27 | console.log('WARNING: SHARED APICACHE INSTANCE', id, this.id, this.apicache.id, instance.apicache.id) 28 | } 29 | if (instance.id !== this.id && this.app === instance.app) { 30 | console.log('WARNING: SHARED EXPRESS INSTANCE', id, this.id) 31 | } 32 | }) 33 | 34 | app.use(this.apicache.middleware(expiration)) 35 | 36 | app.get('/api/gzip/movies', function(req, res) { 37 | app.requestsProcessed++ 38 | 39 | res.json(movies) 40 | }) 41 | 42 | app.get('/api/gzip/writeandend', function(req, res) { 43 | app.requestsProcessed++ 44 | 45 | res.write('a') 46 | res.write('b') 47 | res.write('c') 48 | 49 | res.end() 50 | }) 51 | 52 | app.apicache = apicache 53 | app.requestsProcessed = 0 54 | 55 | return app 56 | } 57 | 58 | module.exports = { 59 | create: function(expiration, config) { return new MockAPI(expiration, config) } 60 | }; 61 | -------------------------------------------------------------------------------- /test/api/lib/routes.js: -------------------------------------------------------------------------------- 1 | var movies = require('./data.json') 2 | 3 | module.exports = function(app) { 4 | app.requestsProcessed = 0 5 | 6 | app.get('/api/movies', function(req, res) { 7 | app.requestsProcessed++ 8 | 9 | res.json(movies) 10 | }) 11 | 12 | app.get('/api/params/:where', function(req, res) { 13 | app.requestsProcessed++ 14 | 15 | res.json(movies) 16 | }) 17 | 18 | 19 | app.get('/api/writeandend', function(req, res) { 20 | app.requestsProcessed++ 21 | 22 | res.write('a') 23 | res.write('b') 24 | res.write('c') 25 | 26 | res.end() 27 | }) 28 | 29 | app.get('/api/writebufferandend', function(req, res) { 30 | app.requestsProcessed++ 31 | 32 | 33 | if (process.versions.node.indexOf('4') === 0) { 34 | res.write(new Buffer([0x61])) 35 | res.write(new Buffer([0x62])) 36 | res.write(new Buffer([0x63])) 37 | } else { 38 | res.write(Buffer.from('a')) 39 | res.write(Buffer.from('b')) 40 | res.write(Buffer.from('c')) 41 | } 42 | 43 | res.end() 44 | }) 45 | 46 | app.get('/api/testheaderblacklist', function(req, res) { 47 | app.requestsProcessed++ 48 | res.set('x-blacklisted', app.requestsProcessed) 49 | res.set('x-notblacklisted', app.requestsProcessed) 50 | 51 | res.json(movies) 52 | }) 53 | 54 | app.get('/api/testcachegroup', function(req, res) { 55 | app.requestsProcessed++ 56 | req.apicacheGroup = 'cachegroup' 57 | 58 | res.json(movies) 59 | }) 60 | 61 | app.get('/api/text', function(req, res) { 62 | app.requestsProcessed++ 63 | 64 | res.send('plaintext') 65 | }) 66 | 67 | app.get('/api/html', function(req, res) { 68 | app.requestsProcessed++ 69 | 70 | res.send('') 71 | }) 72 | 73 | app.get('/api/missing', function(req, res) { 74 | app.requestsProcessed++ 75 | 76 | res.status(404) 77 | res.json({ success: false, message: 'Resource not found' }) 78 | }) 79 | 80 | app.get('/api/movies/:index', function(req, res) { 81 | app.requestsProcessed++ 82 | 83 | res.json(movies[index]) 84 | }) 85 | 86 | return app 87 | } 88 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at krwhitley@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A simple API response caching middleware for Express/Node using plain-english durations. 2 | ======= 3 | #### Supports Redis or built-in memory engine with auto-clearing. 4 | 5 | [![npm version](https://badge.fury.io/js/apicache.svg)](https://www.npmjs.com/package/apicache) 6 | [![node version support](https://img.shields.io/node/v/apicache.svg)](https://www.npmjs.com/package/apicache) 7 | [![Build Status via Travis CI](https://travis-ci.org/kwhitley/apicache.svg?branch=master)](https://travis-ci.org/kwhitley/apicache) 8 | [![Coverage Status](https://coveralls.io/repos/github/kwhitley/apicache/badge.svg?branch=master)](https://coveralls.io/github/kwhitley/apicache?branch=master) 9 | [![NPM downloads](https://img.shields.io/npm/dt/apicache.svg?style=flat-square)](https://www.npmjs.com/package/apicache) 10 | 11 | ## Why? 12 | Because route-caching of simple data/responses should ALSO be simple. 13 | 14 | ## Installation 15 | ``` 16 | npm install apicache 17 | ``` 18 | 19 | ## Dependencies 20 | None 21 | 22 | ## Usage 23 | To use, simply inject the middleware (example: `apicache.middleware('5 minutes', [optionalMiddlewareToggle])`) into your routes. Everything else is automagic. 24 | 25 | #### Cache a route 26 | ```js 27 | import express from 'express' 28 | import apicache from 'apicache' 29 | 30 | let app = express() 31 | let cache = apicache.middleware 32 | 33 | app.get('/api/collection/:id?', cache('5 minutes'), (req, res) => { 34 | // do some work... this will only occur once per 5 minutes 35 | res.json({ foo: 'bar' }) 36 | }) 37 | ``` 38 | 39 | #### Cache all routes 40 | ```js 41 | let cache = apicache.middleware 42 | 43 | app.use(cache('5 minutes')) 44 | 45 | app.get('/will-be-cached', (req, res) => { 46 | res.json({ success: true }) 47 | }) 48 | ``` 49 | 50 | #### Use with Redis 51 | ```js 52 | import express from 'express' 53 | import apicache from 'apicache' 54 | import redis from 'redis' 55 | 56 | let app = express() 57 | 58 | // if redisClient option is defined, apicache will use redis client 59 | // instead of built-in memory store 60 | let cacheWithRedis = apicache 61 | .options({ redisClient: redis.createClient() }) 62 | .middleware 63 | 64 | app.get('/will-be-cached', cacheWithRedis('5 minutes'), (req, res) => { 65 | res.json({ success: true }) 66 | }) 67 | ``` 68 | 69 | #### Cache grouping and manual controls 70 | ```js 71 | import apicache from 'apicache' 72 | let cache = apicache.middleware 73 | 74 | app.use(cache('5 minutes')) 75 | 76 | // routes are automatically added to index, but may be further added 77 | // to groups for quick deleting of collections 78 | app.get('/api/:collection/:item?', (req, res) => { 79 | req.apicacheGroup = req.params.collection 80 | res.json({ success: true }) 81 | }) 82 | 83 | // add route to display cache index 84 | app.get('/api/cache/index', (req, res) => { 85 | res.json(apicache.getIndex()) 86 | }) 87 | 88 | // add route to manually clear target/group 89 | app.get('/api/cache/clear/:target?', (req, res) => { 90 | res.json(apicache.clear(req.params.target)) 91 | }) 92 | 93 | /* 94 | 95 | GET /api/foo/bar --> caches entry at /api/foo/bar and adds a group called 'foo' to index 96 | GET /api/cache/index --> displays index 97 | GET /api/cache/clear/foo --> clears all cached entries for 'foo' group/collection 98 | 99 | */ 100 | ``` 101 | 102 | #### Use with middleware toggle for fine control 103 | ```js 104 | // higher-order function returns false for responses of other status codes (e.g. 403, 404, 500, etc) 105 | const onlyStatus200 = (req, res) => res.statusCode === 200 106 | 107 | const cacheSuccesses = cache('5 minutes', onlyStatus200) 108 | 109 | app.get('/api/missing', cacheSuccesses, (req, res) => { 110 | res.status(404).json({ results: 'will not be cached' }) 111 | }) 112 | 113 | app.get('/api/found', cacheSuccesses, (req, res) => { 114 | res.json({ results: 'will be cached' }) 115 | }) 116 | ``` 117 | 118 | #### Prevent cache-control header "max-age" from automatically being set to expiration age 119 | ```js 120 | let cache = apicache.options({ 121 | headers: { 122 | 'cache-control': 'no-cache' 123 | } 124 | }) 125 | .middleware 126 | 127 | let cache5min = cache('5 min') // continue to use normally 128 | ``` 129 | 130 | ## API 131 | 132 | - `apicache.options([globalOptions])` - getter/setter for global options. If used as a setter, this function is chainable, allowing you to do things such as... say... return the middleware. 133 | - `apicache.middleware([duration], [toggleMiddleware], [localOptions])` - the actual middleware that will be used in your routes. `duration` is in the following format "[length] [unit]", as in `"10 minutes"` or `"1 day"`. A second param is a middleware toggle function, accepting request and response params, and must return truthy to enable cache for the request. Third param is the options that will override global ones and affect this middleware only. 134 | - `middleware.options([localOptions])` - getter/setter for middleware-specific options that will override global ones. 135 | - `apicache.getIndex()` - returns current cache index [of keys] 136 | - `apicache.clear([target])` - clears cache target (key or group), or entire cache if no value passed, returns new index. 137 | - `apicache.newInstance([options])` - used to create a new ApiCache instance (by default, simply requiring this library shares a common instance) 138 | - `apicache.clone()` - used to create a new ApiCache instance with the same options as the current one 139 | 140 | #### Available Options (first value is default) 141 | 142 | ```js 143 | { 144 | debug: false|true, // if true, enables console output 145 | defaultDuration: '1 hour', // should be either a number (in ms) or a string, defaults to 1 hour 146 | enabled: true|false, // if false, turns off caching globally (useful on dev) 147 | redisClient: client, // if provided, uses the [node-redis](https://github.com/NodeRedis/node_redis) client instead of [memory-cache](https://github.com/ptarjan/node-cache) 148 | appendKey: fn(req, res), // appendKey takes the req/res objects and returns a custom value to extend the cache key 149 | headerBlacklist: [], // list of headers that should never be cached 150 | statusCodes: { 151 | exclude: [], // list status codes to specifically exclude (e.g. [404, 403] cache all responses unless they had a 404 or 403 status) 152 | include: [], // list status codes to require (e.g. [200] caches ONLY responses with a success/200 code) 153 | }, 154 | headers: { 155 | // 'cache-control': 'no-cache' // example of header overwrite 156 | } 157 | } 158 | ``` 159 | 160 | ## Custom Cache Keys 161 | 162 | Sometimes you need custom keys (e.g. save routes per-session, or per method). 163 | We've made it easy! 164 | 165 | **Note:** All req/res attributes used in the generation of the key must have been set 166 | previously (upstream). The entire route logic block is skipped on future cache hits 167 | so it can't rely on those params. 168 | 169 | ```js 170 | apicache.options({ 171 | appendKey: (req, res) => req.method + res.session.id 172 | }) 173 | ``` 174 | 175 | ## Cache Key Groups 176 | 177 | Oftentimes it benefits us to group cache entries, for example, by collection (in an API). This 178 | would enable us to clear all cached "post" requests if we updated something in the "post" collection 179 | for instance. Adding a simple `req.apicacheGroup = [somevalue];` to your route enables this. See example below: 180 | 181 | ```js 182 | 183 | var apicache = require('apicache') 184 | var cache = apicache.middleware 185 | 186 | // GET collection/id 187 | app.get('/api/:collection/:id?', cache('1 hour'), function(req, res, next) { 188 | req.apicacheGroup = req.params.collection 189 | // do some work 190 | res.send({ foo: 'bar' }) 191 | }); 192 | 193 | // POST collection/id 194 | app.post('/api/:collection/:id?', function(req, res, next) { 195 | // update model 196 | apicache.clear(req.params.collection) 197 | res.send('added a new item, so the cache has been cleared') 198 | }); 199 | 200 | ``` 201 | 202 | Additionally, you could add manual cache control to the previous project with routes such as these: 203 | 204 | ```js 205 | 206 | // GET apicache index (for the curious) 207 | app.get('/api/cache/index', function(req, res, next) { 208 | res.send(apicache.getIndex()); 209 | }); 210 | 211 | // GET apicache index (for the curious) 212 | app.get('/api/cache/clear/:key?', function(req, res, next) { 213 | res.send(200, apicache.clear(req.params.key || req.query.key)); 214 | }); 215 | ``` 216 | 217 | ## Debugging/Console Out 218 | 219 | #### Using Node environment variables (plays nicely with the hugely popular [debug](https://www.npmjs.com/package/debug) module) 220 | ``` 221 | $ export DEBUG=apicache 222 | $ export DEBUG=apicache,othermoduleThatDebugModuleWillPickUp,etc 223 | ``` 224 | 225 | #### By setting internal option 226 | ```js 227 | import apicache from 'apicache' 228 | 229 | apicache.options({ debug: true }) 230 | ``` 231 | 232 | ## Client-Side Bypass 233 | 234 | When sharing `GET` routes between admin and public sites, you'll likely want the 235 | routes to be cached from your public client, but NOT cached when from the admin client. This 236 | is achieved by sending a `"x-apicache-bypass": true` header along with the requst from the admin. 237 | The presence of this header flag will bypass the cache, ensuring you aren't looking at stale data. 238 | 239 | ## Contributors 240 | 241 | Special thanks to all those that use this library and report issues, but especially to the following active users that have helped add to the core functionality! 242 | 243 | - [@svozza](https://github.com/svozza) - added restify tests, test suite refactor, and fixed header issue with restify. Node v7 + Restify v5 conflict resolution, etc, etc. Triple thanks!!! 244 | - [@andredigenova](https://github.com/andredigenova) - Added header blacklist as options, correction to caching checks 245 | - [@peteboere](https://github.com/peteboere) - Node v7 headers update 246 | - [@rutgernation](https://github.com/rutgernation) - JSONP support 247 | - [@enricsangra](https://github.com/enricsangra) - added x-apicache-force-fetch header 248 | - [@tskillian](https://github.com/tskillian) - custom appendKey path support 249 | - [@agolden](https://github.com/agolden) - Content-Encoding preservation (for gzip, etc) 250 | - [@davidyang](https://github.com/davidyang) - express 4+ compatibility 251 | - [@nmors](https://github.com/nmors) - redis support 252 | - [@maytis](https://github.com/maytis), [@ashwinnaidu](https://github.com/ashwinnaidu) - redis expiration 253 | - [@killdash9](https://github.com/killdash9) - restify support and response accumulator method 254 | - [@ubergesundheit](https://github.com/ubergesundheit) - Corrected buffer accumulation using res.write with Buffers 255 | - [@danielsogl](https://github.com/danielsogl) - Keeping dev deps up to date 256 | - [@vectart](https://github.com/vectart) - Added middleware local options support 257 | - [@davebaol](https://github.com/davebaol) - Added string support to defaultDuration option (previously just numeric ms) 258 | 259 | ### Bugfixes, Documentation, etc. 260 | 261 | - @Amhri, @Webcascade, @conmarap, @cjfurelid, @scambier, @lukechilds, @Red-Lv, @gesposito, @viebel 262 | 263 | ### Changelog 264 | - **v0.4.0** - dropped lodash and memory-cache external dependencies, and bumped node version requirements to 4.0.0+ to allow Object.assign native support 265 | - **v0.5.0** - updated internals to use res.end instead of res.send/res.json/res.jsonp, allowing for any response type, adds redis tests 266 | - **v0.6.0** - removed final dependency (debug) and updated README 267 | - **v0.7.0** - internally sets cache-control/max-age headers of response object 268 | - **v0.8.0** - modifies response accumulation (thanks @killdash9) to support res.write + res.end accumulation, allowing integration with restify. Adds gzip support (Node v4.3.2+ now required) and tests. 269 | - **v0.8.1** - fixed restify support and added appropriate tests (thanks @svozza) 270 | - **v0.8.2** - test suite and mock API refactor (thanks @svozza) 271 | - **v0.8.3** - added tests for x-apicache-bypass and x-apicache-force-fetch (legacy) and fixed a bug in the latter (thanks @Red-Lv) 272 | - **v0.8.4** - corrected buffer accumulation, with test support (thanks @ubergesundheit) 273 | - **v0.8.5** - dev dependencies update (thanks @danielsogl) 274 | - **v0.8.6, v0.8.7** - README update 275 | - **v0.8.8** - corrected to use node v7+ headers (thanks @peteboere) 276 | - **v0.9.0** - corrected Node v7.7 & v8 conflicts with restify (huge thanks to @svozza 277 | for chasing this down and fixing upstream in restify itself). Added coveralls. Added 278 | middleware.localOptions support (thanks @vectart). Added ability to overwrite/embed headers 279 | (e.g. "cache-control": "no-cache") through options. 280 | - **v0.9.1** - added eslint in prep for v1.x branch, minor ES6 to ES5 in master branch tests 281 | - **v0.10.0** - added ability to blacklist headers (prevents caching) via options.headersBlacklist (thanks @andredigenova) 282 | - **v0.11.0** - Added string support to defaultDuration option, previously just numeric ms - thanks @davebaol 283 | - **v0.11.1** - correction to status code caching, and max-age headers are no longer sent when not cached. middlewareToggle now works as intended with example of statusCode checking (checks during shouldCacheResponse cycle) 284 | - **v0.11.2** - dev-deps update, courtesy of @danielsogl 285 | - **v1.0.0** - stamping v0.11.2 into official production version, will now begin developing on branch v2.x (redesign) 286 | - **v1.1.0** - added the much-requested feature of a custom appendKey function (previously only took a path to a single request attribute). Now takes (request, response) objects and returns some value to be appended to the cache key. 287 | -------------------------------------------------------------------------------- /src/apicache.js: -------------------------------------------------------------------------------- 1 | var url = require('url') 2 | var MemoryCache = require('./memory-cache') 3 | var pkg = require('../package.json') 4 | 5 | var t = { 6 | ms: 1, 7 | second: 1000, 8 | minute: 60000, 9 | hour: 3600000, 10 | day: 3600000 * 24, 11 | week: 3600000 * 24 * 7, 12 | month: 3600000 * 24 * 30, 13 | } 14 | 15 | var instances = [] 16 | 17 | var matches = function(a) { 18 | return function(b) { return a === b } 19 | } 20 | 21 | var doesntMatch = function(a) { 22 | return function(b) { return !matches(a)(b) } 23 | } 24 | 25 | var logDuration = function(d, prefix) { 26 | var str = (d > 1000) ? ((d/1000).toFixed(2) + 'sec') : (d + 'ms') 27 | return '\x1b[33m- ' + (prefix ? prefix + ' ' : '') + str + '\x1b[0m' 28 | } 29 | 30 | function ApiCache() { 31 | var memCache = new MemoryCache 32 | 33 | var globalOptions = { 34 | debug: false, 35 | defaultDuration: 3600000, 36 | enabled: true, 37 | appendKey: [], 38 | jsonp: false, 39 | redisClient: false, 40 | headerBlacklist: [], 41 | statusCodes: { 42 | include: [], 43 | exclude: [], 44 | }, 45 | events: { 46 | 'expire': undefined 47 | }, 48 | headers: { 49 | // 'cache-control': 'no-cache' // example of header overwrite 50 | } 51 | } 52 | 53 | var middlewareOptions = [] 54 | var instance = this 55 | var index = null 56 | 57 | instances.push(this) 58 | this.id = instances.length 59 | 60 | function debug(a,b,c,d) { 61 | var arr = (['\x1b[36m[apicache]\x1b[0m', a,b,c,d]).filter(function(arg) { return arg !== undefined }) 62 | var debugEnv = process.env.DEBUG && process.env.DEBUG.split(',').indexOf('apicache') !== -1 63 | 64 | return (globalOptions.debug || debugEnv) && console.log.apply(null, arr) 65 | } 66 | 67 | function shouldCacheResponse(request, response, toggle) { 68 | var opt = globalOptions 69 | var codes = opt.statusCodes 70 | 71 | if (!response) return false 72 | 73 | if (toggle && !toggle(request, response)) { 74 | return false 75 | } 76 | 77 | if (codes.exclude && codes.exclude.length && codes.exclude.indexOf(response.statusCode) !== -1) return false 78 | if (codes.include && codes.include.length && codes.include.indexOf(response.statusCode) === -1) return false 79 | 80 | return true 81 | } 82 | 83 | function addIndexEntries(key, req) { 84 | var groupName = req.apicacheGroup 85 | 86 | if (groupName) { 87 | debug('group detected "' + groupName + '"') 88 | var group = (index.groups[groupName] = index.groups[groupName] || []) 89 | group.unshift(key) 90 | } 91 | 92 | index.all.unshift(key) 93 | } 94 | 95 | function filterBlacklistedHeaders(headers) { 96 | return Object.keys(headers).filter(function (key) { 97 | return globalOptions.headerBlacklist.indexOf(key) === -1; 98 | }).reduce(function (acc, header) { 99 | acc[header] = headers[header]; 100 | return acc; 101 | }, {}); 102 | } 103 | 104 | function createCacheObject(status, headers, data, encoding) { 105 | return { 106 | status: status, 107 | headers: filterBlacklistedHeaders(headers), 108 | data: data, 109 | encoding: encoding 110 | } 111 | } 112 | 113 | function cacheResponse(key, value, duration) { 114 | var redis = globalOptions.redisClient 115 | var expireCallback = globalOptions.events.expire 116 | 117 | if (redis) { 118 | try { 119 | redis.hset(key, "response", JSON.stringify(value)) 120 | redis.hset(key, "duration", duration) 121 | redis.expire(key, duration/1000, expireCallback) 122 | } catch (err) { 123 | debug('[apicache] error in redis.hset()') 124 | } 125 | } else { 126 | memCache.add(key, value, duration, expireCallback) 127 | } 128 | 129 | // add automatic cache clearing from duration, includes max limit on setTimeout 130 | setTimeout(function() { instance.clear(key, true) }, Math.min(duration, 2147483647)) 131 | } 132 | 133 | function accumulateContent(res, content) { 134 | if (content) { 135 | if (typeof(content) == 'string') { 136 | res._apicache.content = (res._apicache.content || '') + content; 137 | } else if (Buffer.isBuffer(content)) { 138 | var oldContent = res._apicache.content 139 | if (!oldContent) { 140 | oldContent = !Buffer.alloc ? new Buffer(0) : Buffer.alloc(0); 141 | } 142 | res._apicache.content = Buffer.concat([oldContent, content], oldContent.length + content.length); 143 | } else { 144 | res._apicache.content = content 145 | // res._apicache.cacheable = false; 146 | } 147 | } 148 | } 149 | 150 | function makeResponseCacheable(req, res, next, key, duration, strDuration, toggle) { 151 | // monkeypatch res.end to create cache object 152 | res._apicache = { 153 | write: res.write, 154 | writeHead: res.writeHead, 155 | end: res.end, 156 | cacheable: true, 157 | content: undefined 158 | } 159 | 160 | // append header overwrites if applicable 161 | Object.keys(globalOptions.headers).forEach(function(name) { 162 | res.header(name, globalOptions.headers[name]) 163 | }) 164 | 165 | res.writeHead = function() { 166 | // add cache control headers 167 | if (!globalOptions.headers['cache-control']) { 168 | if(shouldCacheResponse(req, res, toggle)) { 169 | res.header('cache-control', 'max-age=' + (duration / 1000).toFixed(0)); 170 | } else { 171 | res.header('cache-control', 'no-cache, no-store, must-revalidate'); 172 | } 173 | } 174 | 175 | return res._apicache.writeHead.apply(this, arguments) 176 | } 177 | 178 | // patch res.write 179 | res.write = function(content) { 180 | accumulateContent(res, content); 181 | return res._apicache.write.apply(this, arguments); 182 | } 183 | 184 | // patch res.end 185 | res.end = function(content, encoding) { 186 | if (shouldCacheResponse(req, res, toggle)) { 187 | 188 | accumulateContent(res, content); 189 | 190 | if (res._apicache.cacheable && res._apicache.content) { 191 | addIndexEntries(key, req) 192 | var cacheObject = createCacheObject(res.statusCode, res._headers, res._apicache.content, encoding) 193 | cacheResponse(key, cacheObject, duration) 194 | 195 | // display log entry 196 | var elapsed = new Date() - req.apicacheTimer 197 | debug('adding cache entry for "' + key + '" @ ' + strDuration, logDuration(elapsed)) 198 | } 199 | } 200 | 201 | return res._apicache.end.apply(this, arguments); 202 | } 203 | 204 | next() 205 | } 206 | 207 | 208 | function sendCachedResponse(request, response, cacheObject, toggle) { 209 | if (toggle && !toggle(request, response)) { 210 | return false 211 | } 212 | 213 | var headers = (typeof response.getHeaders === 'function') ? response.getHeaders() : response._headers; 214 | 215 | Object.assign(headers, filterBlacklistedHeaders(cacheObject.headers || {}), { 216 | 'apicache-store': globalOptions.redisClient ? 'redis' : 'memory', 217 | 'apicache-version': pkg.version 218 | }) 219 | 220 | // unstringify buffers 221 | var data = cacheObject.data 222 | if (data && data.type === 'Buffer') { 223 | data = new Buffer(data.data) 224 | } 225 | 226 | response.writeHead(cacheObject.status || 200, headers) 227 | 228 | return response.end(data, cacheObject.encoding) 229 | } 230 | 231 | function syncOptions() { 232 | for (var i in middlewareOptions) { 233 | Object.assign(middlewareOptions[i].options, globalOptions, middlewareOptions[i].localOptions) 234 | } 235 | } 236 | 237 | this.clear = function(target, isAutomatic) { 238 | var group = index.groups[target] 239 | var redis = globalOptions.redisClient 240 | 241 | if (group) { 242 | debug('clearing group "' + target + '"') 243 | 244 | group.forEach(function(key) { 245 | debug('clearing cached entry for "' + key + '"') 246 | 247 | if (!globalOptions.redisClient) { 248 | memCache.delete(key) 249 | } else { 250 | try { 251 | redis.del(key) 252 | } catch(err) { 253 | console.log('[apicache] error in redis.del("' + key + '")') 254 | } 255 | } 256 | index.all = index.all.filter(doesntMatch(key)) 257 | }) 258 | 259 | delete index.groups[target] 260 | } else if (target) { 261 | debug('clearing ' + (isAutomatic ? 'expired' : 'cached') + ' entry for "' + target + '"') 262 | 263 | // clear actual cached entry 264 | if (!redis) { 265 | memCache.delete(target) 266 | } else { 267 | try { 268 | redis.del(target) 269 | } catch(err) { 270 | console.log('[apicache] error in redis.del("' + target + '")') 271 | } 272 | } 273 | 274 | // remove from global index 275 | index.all = index.all.filter(doesntMatch(target)) 276 | 277 | // remove target from each group that it may exist in 278 | Object.keys(index.groups).forEach(function(groupName) { 279 | index.groups[groupName] = index.groups[groupName].filter(doesntMatch(target)) 280 | 281 | // delete group if now empty 282 | if (!index.groups[groupName].length) { 283 | delete index.groups[groupName] 284 | } 285 | }) 286 | } else { 287 | debug('clearing entire index') 288 | 289 | if (!redis) { 290 | memCache.clear() 291 | } else { 292 | // clear redis keys one by one from internal index to prevent clearing non-apicache entries 293 | index.all.forEach(function(key) { 294 | try { 295 | redis.del(key) 296 | } catch(err) { 297 | console.log('[apicache] error in redis.del("' + key + '")') 298 | } 299 | }) 300 | } 301 | this.resetIndex() 302 | } 303 | 304 | return this.getIndex() 305 | } 306 | 307 | function parseDuration(duration, defaultDuration) { 308 | if (typeof duration === 'number') return duration 309 | 310 | if (typeof duration === 'string') { 311 | var split = duration.match(/^([\d\.,]+)\s?(\w+)$/) 312 | 313 | if (split.length === 3) { 314 | var len = parseFloat(split[1]) 315 | var unit = split[2].replace(/s$/i,'').toLowerCase() 316 | if (unit === 'm') { 317 | unit = 'ms' 318 | } 319 | 320 | return (len || 1) * (t[unit] || 0) 321 | } 322 | } 323 | 324 | return defaultDuration 325 | } 326 | 327 | this.getDuration = function(duration) { 328 | return parseDuration(duration, globalOptions.defaultDuration) 329 | } 330 | 331 | this.getIndex = function(group) { 332 | if (group) { 333 | return index.groups[group] 334 | } else { 335 | return index 336 | } 337 | } 338 | 339 | this.middleware = function cache(strDuration, middlewareToggle, localOptions) { 340 | var duration = instance.getDuration(strDuration) 341 | var opt = {} 342 | 343 | middlewareOptions.push({ 344 | options: opt 345 | }) 346 | 347 | var options = function (localOptions) { 348 | if (localOptions) { 349 | middlewareOptions.find(function (middleware) { 350 | return middleware.options === opt 351 | }).localOptions = localOptions 352 | } 353 | 354 | syncOptions() 355 | 356 | return opt 357 | } 358 | 359 | options(localOptions) 360 | 361 | var cache = function(req, res, next) { 362 | function bypass() { 363 | debug('bypass detected, skipping cache.') 364 | return next() 365 | } 366 | 367 | // initial bypass chances 368 | if (!opt.enabled) return bypass() 369 | if (req.headers['x-apicache-bypass'] || req.headers['x-apicache-force-fetch']) return bypass() 370 | 371 | // REMOVED IN 0.11.1 TO CORRECT MIDDLEWARE TOGGLE EXECUTE ORDER 372 | // if (typeof middlewareToggle === 'function') { 373 | // if (!middlewareToggle(req, res)) return bypass() 374 | // } else if (middlewareToggle !== undefined && !middlewareToggle) { 375 | // return bypass() 376 | // } 377 | 378 | // embed timer 379 | req.apicacheTimer = new Date() 380 | 381 | // In Express 4.x the url is ambigious based on where a router is mounted. originalUrl will give the full Url 382 | var key = req.originalUrl || req.url 383 | 384 | // Remove querystring from key if jsonp option is enabled 385 | if (opt.jsonp) { 386 | key = url.parse(key).pathname 387 | } 388 | 389 | // add appendKey (either custom function or response path) 390 | if (typeof opt.appendKey === 'function') { 391 | key += '$$appendKey=' + opt.appendKey(req, res) 392 | } else if (opt.appendKey.length > 0) { 393 | var appendKey = req 394 | 395 | for (var i = 0; i < opt.appendKey.length; i++) { 396 | appendKey = appendKey[opt.appendKey[i]] 397 | } 398 | key += '$$appendKey=' + appendKey 399 | } 400 | 401 | // attempt cache hit 402 | var redis = opt.redisClient 403 | var cached = !redis ? memCache.getValue(key) : null 404 | 405 | // send if cache hit from memory-cache 406 | if (cached) { 407 | var elapsed = new Date() - req.apicacheTimer 408 | debug('sending cached (memory-cache) version of', key, logDuration(elapsed)) 409 | 410 | return sendCachedResponse(req, res, cached, middlewareToggle) 411 | } 412 | 413 | // send if cache hit from redis 414 | if (redis) { 415 | try { 416 | redis.hgetall(key, function (err, obj) { 417 | if (!err && obj) { 418 | var elapsed = new Date() - req.apicacheTimer 419 | debug('sending cached (redis) version of', key, logDuration(elapsed)) 420 | 421 | return sendCachedResponse(req, res, JSON.parse(obj.response), middlewareToggle) 422 | } else { 423 | return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle) 424 | } 425 | }) 426 | } catch (err) { 427 | // bypass redis on error 428 | return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle) 429 | } 430 | } else { 431 | return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle) 432 | } 433 | } 434 | 435 | cache.options = options 436 | 437 | return cache 438 | } 439 | 440 | this.options = function(options) { 441 | if (options) { 442 | Object.assign(globalOptions, options) 443 | syncOptions() 444 | 445 | if ('defaultDuration' in options) { 446 | // Convert the default duration to a number in milliseconds (if needed) 447 | globalOptions.defaultDuration = parseDuration(globalOptions.defaultDuration, 3600000); 448 | } 449 | 450 | return this 451 | } else { 452 | return globalOptions 453 | } 454 | } 455 | 456 | this.resetIndex = function() { 457 | index = { 458 | all: [], 459 | groups: {} 460 | } 461 | } 462 | 463 | this.newInstance = function(config) { 464 | var instance = new ApiCache() 465 | 466 | if (config) { 467 | instance.options(config) 468 | } 469 | 470 | return instance 471 | } 472 | 473 | this.clone = function() { 474 | return this.newInstance(this.options()) 475 | } 476 | 477 | // initialize index 478 | this.resetIndex() 479 | } 480 | 481 | module.exports = new ApiCache() 482 | -------------------------------------------------------------------------------- /test/apicache_test.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai') 2 | var expect = chai.expect 3 | var request = require('supertest') 4 | var apicache = require('../src/apicache') 5 | var pkg = require('../package.json') 6 | var redis = require('fakeredis') 7 | var a = apicache.clone() 8 | var b = apicache.clone() 9 | var c = apicache.clone() 10 | var movies = require('./api/lib/data.json') 11 | 12 | var apis = [ 13 | { name: 'express', server: require('./api/express') }, 14 | { name: 'express+gzip', server: require('./api/express-gzip') }, 15 | 16 | // THESE TESTS ARE REMOVED AS RESTIFY 4 and 5 ARE CURRENTLY BREAKING IN THE ENVIRONMENT 17 | { name: 'restify', server: require('./api/restify') }, 18 | { name: 'restify+gzip', server: require('./api/restify-gzip') } 19 | ] 20 | 21 | function assertNumRequestsProcessed(app, n) { 22 | return function() { 23 | expect(app.requestsProcessed).to.equal(n) 24 | } 25 | } 26 | 27 | describe('.options(opt?) {GETTER/SETTER}', function() { 28 | var apicache = require('../src/apicache') 29 | 30 | it('is a function', function() { 31 | expect(typeof apicache.options).to.equal('function') 32 | }) 33 | 34 | describe('.options() {GETTER}', function() { 35 | it ('returns global options as object', function() { 36 | expect(typeof apicache.options()).to.equal('object') 37 | }) 38 | }) 39 | 40 | describe('.options(opt) {SETTER}', function() { 41 | it ('is chainable', function() { 42 | expect(apicache.options({})).to.equal(apicache) 43 | }) 44 | 45 | it ('extends defaults', function() { 46 | expect(apicache.options({ foo: 'bar' }).options().foo).to.equal('bar') 47 | }) 48 | 49 | it ('allows overrides of defaults', function() { 50 | var newDuration = 11 51 | 52 | expect(apicache.options()).to.have.property('defaultDuration') 53 | expect(apicache.options({ defaultDuration: newDuration }).options().defaultDuration).to.equal(newDuration) 54 | }) 55 | }) 56 | }) 57 | 58 | describe('.getDuration(stringOrNumber) {GETTER}', function() { 59 | var apicache = require('../src/apicache') 60 | 61 | it('is a function', function() { 62 | expect(typeof apicache.getDuration).to.equal('function') 63 | }) 64 | 65 | it('returns value unchanged if numeric', function() { 66 | expect(apicache.getDuration(77)).to.equal(77) 67 | }) 68 | 69 | it('returns default duration when uncertain', function() { 70 | apicache.options({ defaultDuration: 999 }) 71 | expect(apicache.getDuration(undefined)).to.equal(999) 72 | }) 73 | 74 | it('accepts singular or plural (e.g. "1 hour", "3 hours")', function() { 75 | expect(apicache.getDuration('3 seconds')).to.equal(3000) 76 | expect(apicache.getDuration('3 second')).to.equal(3000) 77 | }) 78 | 79 | it('accepts decimals (e.g. "1.5 hours")', function() { 80 | expect(apicache.getDuration('1.5 seconds')).to.equal(1500) 81 | }) 82 | 83 | describe('unit support', function() { 84 | it('numeric values as milliseconds', function() { 85 | expect(apicache.getDuration(43)).to.equal(43) 86 | }) 87 | it('milliseconds', function() { 88 | expect(apicache.getDuration('3 ms')).to.equal(3) 89 | }) 90 | it('seconds', function() { 91 | expect(apicache.getDuration('3 seconds')).to.equal(3000) 92 | }) 93 | it('minutes', function() { 94 | expect(apicache.getDuration('4 minutes')).to.equal(1000 * 60 * 4) 95 | }) 96 | it('hours', function() { 97 | expect(apicache.getDuration('2 hours')).to.equal(1000 * 60 * 60 * 2) 98 | }) 99 | it('days', function() { 100 | expect(apicache.getDuration('3 days')).to.equal(1000 * 60 * 60 * 24 * 3) 101 | }) 102 | it('weeks', function() { 103 | expect(apicache.getDuration('5 weeks')).to.equal(1000 * 60 * 60 * 24 * 7 * 5) 104 | }) 105 | it('months', function() { 106 | expect(apicache.getDuration('6 months')).to.equal(1000 * 60 * 60 * 24 * 30 * 6) 107 | }) 108 | }) 109 | 110 | }) 111 | 112 | describe('.getIndex([groupName]) {GETTER}', function() { 113 | var apicache = require('../src/apicache') 114 | 115 | it('is a function', function() { 116 | expect(typeof apicache.getIndex).to.equal('function') 117 | }) 118 | 119 | it('returns an object', function() { 120 | expect(typeof apicache.getIndex()).to.equal('object') 121 | }) 122 | 123 | it('can clear indexed cache groups', function() { 124 | var api = require('./api/express') 125 | var app = api.create('10 seconds') 126 | 127 | return request(app) 128 | .get('/api/testcachegroup') 129 | .then(function(res) { 130 | expect(app.apicache.getIndex('cachegroup').length).to.equal(1) 131 | }) 132 | }) 133 | }) 134 | 135 | describe('.resetIndex() {SETTER}', function() { 136 | var apicache = require('../src/apicache') 137 | 138 | it('is a function', function() { 139 | expect(typeof apicache.resetIndex).to.equal('function') 140 | }) 141 | 142 | }) 143 | 144 | describe('.middleware {MIDDLEWARE}', function() { 145 | 146 | it('is a function', function() { 147 | var apicache = require('../src/apicache') 148 | expect(typeof apicache.middleware).to.equal('function') 149 | expect(apicache.middleware.length).to.equal(3) 150 | }) 151 | 152 | it('returns the middleware function', function() { 153 | var middleware = require('../src/apicache').middleware('10 seconds') 154 | expect(typeof middleware).to.equal('function') 155 | expect(middleware.length).to.equal(3) 156 | }) 157 | 158 | describe('options', function() { 159 | var apicache = require('../src/apicache').newInstance() 160 | 161 | it('uses global options if local ones not provided', function() { 162 | apicache.options({ 163 | appendKey: ['test'] 164 | }) 165 | var middleware1 = apicache.middleware('10 seconds') 166 | var middleware2 = apicache.middleware('20 seconds') 167 | expect(middleware1.options()).to.eql({ 168 | debug: false, 169 | defaultDuration: 3600000, 170 | enabled: true, 171 | appendKey: [ 'test' ], 172 | jsonp: false, 173 | redisClient: false, 174 | headerBlacklist: [], 175 | statusCodes: { include: [], exclude: [] }, 176 | events: { expire: undefined }, 177 | headers: {} 178 | }) 179 | expect(middleware2.options()).to.eql({ 180 | debug: false, 181 | defaultDuration: 3600000, 182 | enabled: true, 183 | appendKey: [ 'test' ], 184 | jsonp: false, 185 | redisClient: false, 186 | headerBlacklist: [], 187 | statusCodes: { include: [], exclude: [] }, 188 | events: { expire: undefined }, 189 | headers: {} 190 | }) 191 | }) 192 | 193 | it('uses local options if they provided', function() { 194 | apicache.options({ 195 | appendKey: ['test'] 196 | }) 197 | var middleware1 = apicache.middleware('10 seconds', null, { 198 | debug: true, 199 | defaultDuration: 7200000, 200 | appendKey: ['bar'], 201 | statusCodes: { include: [], exclude: ['400'] }, 202 | events: { expire: undefined }, 203 | headers: { 204 | 'cache-control': 'no-cache' 205 | } 206 | }) 207 | var middleware2 = apicache.middleware('20 seconds', null, { 208 | debug: false, 209 | defaultDuration: 1800000, 210 | appendKey: ['foo'], 211 | statusCodes: { include: [], exclude: ['200'] }, 212 | events: { expire: undefined }, 213 | }) 214 | expect(middleware1.options()).to.eql({ 215 | debug: true, 216 | defaultDuration: 7200000, 217 | enabled: true, 218 | appendKey: [ 'bar' ], 219 | jsonp: false, 220 | redisClient: false, 221 | headerBlacklist: [], 222 | statusCodes: { include: [], exclude: ['400'] }, 223 | events: { expire: undefined }, 224 | headers: { 225 | 'cache-control': 'no-cache' 226 | } 227 | }) 228 | expect(middleware2.options()).to.eql({ 229 | debug: false, 230 | defaultDuration: 1800000, 231 | enabled: true, 232 | appendKey: [ 'foo' ], 233 | jsonp: false, 234 | redisClient: false, 235 | headerBlacklist: [], 236 | statusCodes: { include: [], exclude: ['200'] }, 237 | events: { expire: undefined }, 238 | headers: {} 239 | }) 240 | }) 241 | 242 | it('updates options if global ones changed', function() { 243 | apicache.options({ 244 | debug: true, 245 | appendKey: ['test'] 246 | }) 247 | var middleware1 = apicache.middleware('10 seconds', null, { 248 | defaultDuration: 7200000, 249 | statusCodes: { include: [], exclude: ['400'] } 250 | }) 251 | var middleware2 = apicache.middleware('20 seconds', null, { 252 | defaultDuration: 1800000, 253 | statusCodes: { include: [], exclude: ['200'] } 254 | }) 255 | apicache.options({ 256 | debug: false, 257 | appendKey: ['foo'] 258 | }) 259 | expect(middleware1.options()).to.eql({ 260 | debug: false, 261 | defaultDuration: 7200000, 262 | enabled: true, 263 | appendKey: [ 'foo' ], 264 | jsonp: false, 265 | redisClient: false, 266 | headerBlacklist: [], 267 | statusCodes: { include: [], exclude: ['400'] }, 268 | events: { expire: undefined }, 269 | headers: {} 270 | }) 271 | expect(middleware2.options()).to.eql({ 272 | debug: false, 273 | defaultDuration: 1800000, 274 | enabled: true, 275 | appendKey: [ 'foo' ], 276 | jsonp: false, 277 | redisClient: false, 278 | headerBlacklist: [], 279 | statusCodes: { include: [], exclude: ['200'] }, 280 | events: { expire: undefined }, 281 | headers: {} 282 | }) 283 | }) 284 | 285 | it('updates options if local ones changed', function() { 286 | apicache.options({ 287 | debug: true, 288 | appendKey: ['test'] 289 | }) 290 | var middleware1 = apicache.middleware('10 seconds', null, { 291 | defaultDuration: 7200000, 292 | statusCodes: { include: [], exclude: ['400'] } 293 | }) 294 | var middleware2 = apicache.middleware('20 seconds', null, { 295 | defaultDuration: 900000, 296 | statusCodes: { include: [], exclude: ['404'] } 297 | }) 298 | middleware1.options({ 299 | debug: false, 300 | defaultDuration: 1800000, 301 | appendKey: ['foo'], 302 | headers: { 303 | 'cache-control': 'no-cache' 304 | } 305 | }) 306 | middleware2.options({ 307 | defaultDuration: 450000, 308 | enabled: false, 309 | appendKey: ['foo'] 310 | }) 311 | expect(middleware1.options()).to.eql({ 312 | debug: false, 313 | defaultDuration: 1800000, 314 | enabled: true, 315 | appendKey: [ 'foo' ], 316 | jsonp: false, 317 | redisClient: false, 318 | headerBlacklist: [], 319 | statusCodes: { include: [], exclude: [] }, 320 | events: { expire: undefined }, 321 | headers: { 322 | 'cache-control': 'no-cache' 323 | } 324 | }) 325 | expect(middleware2.options()).to.eql({ 326 | debug: true, 327 | defaultDuration: 450000, 328 | enabled: false, 329 | appendKey: [ 'foo' ], 330 | jsonp: false, 331 | redisClient: false, 332 | headerBlacklist: [], 333 | statusCodes: { include: [], exclude: [] }, 334 | events: { expire: undefined }, 335 | headers: {} 336 | }) 337 | }) 338 | }) 339 | 340 | apis.forEach(function(api) { 341 | describe(api.name + ' tests', function() { 342 | var mockAPI = api.server 343 | 344 | it('does not interfere with initial request', function() { 345 | var app = mockAPI.create('10 seconds') 346 | 347 | return request(app) 348 | .get('/api/movies') 349 | .expect(200) 350 | .then(assertNumRequestsProcessed(app, 1)) 351 | }) 352 | 353 | it('properly returns a request while caching (first call)', function() { 354 | var app = mockAPI.create('10 seconds') 355 | 356 | return request(app) 357 | .get('/api/movies') 358 | .expect(200, movies) 359 | .then(assertNumRequestsProcessed(app, 1)) 360 | }) 361 | 362 | it('skips cache when using header "x-apicache-bypass"', function() { 363 | var app = mockAPI.create('10 seconds') 364 | 365 | return request(app) 366 | .get('/api/movies') 367 | .expect(200, movies) 368 | .then(assertNumRequestsProcessed(app, 1)) 369 | .then(function() { 370 | return request(app) 371 | .get('/api/movies') 372 | .set('x-apicache-bypass', true) 373 | .set('Accept', 'application/json') 374 | .expect('Content-Type', /json/) 375 | .expect(200, movies) 376 | .then(function(res) { 377 | expect(res.headers['apicache-store']).to.be.undefined 378 | expect(res.headers['apicache-version']).to.be.undefined 379 | expect(app.requestsProcessed).to.equal(2) 380 | }) 381 | }) 382 | }) 383 | 384 | it('skips cache when using header "x-apicache-force-fetch (legacy)"', function() { 385 | var app = mockAPI.create('10 seconds') 386 | 387 | return request(app) 388 | .get('/api/movies') 389 | .expect(200, movies) 390 | .then(assertNumRequestsProcessed(app, 1)) 391 | .then(function() { 392 | return request(app) 393 | .get('/api/movies') 394 | .set('x-apicache-force-fetch', true) 395 | .set('Accept', 'application/json') 396 | .expect('Content-Type', /json/) 397 | .expect(200, movies) 398 | .then(function(res) { 399 | expect(res.headers['apicache-store']).to.be.undefined 400 | expect(res.headers['apicache-version']).to.be.undefined 401 | expect(app.requestsProcessed).to.equal(2) 402 | }) 403 | }) 404 | }) 405 | 406 | it('does not cache header in headerBlacklist', function() { 407 | var app = mockAPI.create('10 seconds', {headerBlacklist: ['x-blacklisted']}) 408 | 409 | return request(app) 410 | .get('/api/testheaderblacklist') 411 | .expect(200, movies) 412 | .then(function(res) { 413 | expect(res.headers['x-blacklisted']).to.equal(res.headers['x-notblacklisted']) 414 | return request(app) 415 | .get('/api/testheaderblacklist') 416 | .set('Accept', 'application/json') 417 | .expect('Content-Type', /json/) 418 | .expect(200, movies) 419 | .then(function(res2) { 420 | expect(res2.headers['x-blacklisted']).to.not.equal(res2.headers['x-notblacklisted']) 421 | }) 422 | }) 423 | }) 424 | 425 | it('properly returns a cached JSON request', function() { 426 | var app = mockAPI.create('10 seconds') 427 | 428 | return request(app) 429 | .get('/api/movies') 430 | .expect(200, movies) 431 | .then(assertNumRequestsProcessed(app, 1)) 432 | .then(function() { 433 | return request(app) 434 | .get('/api/movies') 435 | .set('Accept', 'application/json') 436 | .expect('Content-Type', /json/) 437 | .expect(200, movies) 438 | .then(assertNumRequestsProcessed(app, 1)) 439 | }) 440 | }) 441 | 442 | it('properly uses appendKey params', function() { 443 | var app = mockAPI.create('10 seconds', { appendKey: ['method'] }) 444 | 445 | return request(app) 446 | .get('/api/movies') 447 | .expect(200, movies) 448 | .then(function() { 449 | expect(app.apicache.getIndex().all[0]).to.equal('/api/movies$$appendKey=GET') 450 | }) 451 | }) 452 | 453 | it('properly uses custom appendKey(req, res) function', function() { 454 | var appendKey = function(req, res) { 455 | return req.method + res.id 456 | } 457 | var app = mockAPI.create('10 seconds', { appendKey: appendKey }) 458 | 459 | return request(app) 460 | .get('/api/movies') 461 | .expect(200, movies) 462 | .then(function() { 463 | expect(app.apicache.getIndex().all[0]).to.equal('/api/movies$$appendKey=GET123') 464 | }) 465 | }) 466 | 467 | it('returns cached response from write+end', function() { 468 | var app = mockAPI.create('10 seconds') 469 | 470 | return request(app) 471 | .get('/api/writeandend') 472 | .expect(200, 'abc') 473 | .expect('Cache-Control', 'max-age=10') 474 | .then(assertNumRequestsProcessed(app, 1)) 475 | .then(function() { 476 | return request(app) 477 | .get('/api/writeandend') 478 | .expect(200, 'abc') 479 | .then(assertNumRequestsProcessed(app, 1)) 480 | }) 481 | }) 482 | 483 | it('returns cached response from write Buffer+end', function() { 484 | var app = mockAPI.create('10 seconds') 485 | 486 | return request(app) 487 | .get('/api/writebufferandend') 488 | .expect(200, 'abc') 489 | .expect('Cache-Control', 'max-age=10') 490 | .then(assertNumRequestsProcessed(app, 1)) 491 | .then(function() { 492 | return request(app) 493 | .get('/api/writebufferandend') 494 | .expect(200, 'abc') 495 | .then(assertNumRequestsProcessed(app, 1)) 496 | }) 497 | }) 498 | 499 | it('embeds store type and apicache version in cached responses', function() { 500 | var app = mockAPI.create('10 seconds') 501 | 502 | return request(app) 503 | .get('/api/movies') 504 | .expect(200, movies) 505 | .then(function(res) { 506 | expect(res.headers['apicache-store']).to.be.undefined 507 | expect(res.headers['apicache-version']).to.be.undefined 508 | expect(app.requestsProcessed).to.equal(1) 509 | }) 510 | .then(function() { 511 | return request(app) 512 | .get('/api/movies') 513 | .expect('apicache-store', 'memory') 514 | .expect('apicache-version', pkg.version) 515 | .expect(200, movies) 516 | .then(assertNumRequestsProcessed(app, 1)) 517 | }) 518 | }) 519 | 520 | it('embeds cache-control header', function() { 521 | var app = mockAPI.create('10 seconds') 522 | 523 | return request(app) 524 | .get('/api/movies') 525 | .expect('Cache-Control', 'max-age=10') 526 | .expect(200, movies) 527 | .then(function(res) { 528 | expect(res.headers['apicache-store']).to.be.undefined 529 | expect(res.headers['apicache-version']).to.be.undefined 530 | expect(app.requestsProcessed).to.equal(1) 531 | expect(res.headers['date']).to.exist 532 | }) 533 | .then(function() { 534 | return request(app) 535 | .get('/api/movies') 536 | .expect('apicache-store', 'memory') 537 | .expect('apicache-version', pkg.version) 538 | .expect(200, movies) 539 | .then(assertNumRequestsProcessed(app, 1)) 540 | }) 541 | }) 542 | 543 | it('allows cache-control header to be overwritten (e.g. "no-cache"', function() { 544 | var app = mockAPI.create('10 seconds', { headers: { 'cache-control': 'no-cache' }}) 545 | 546 | return request(app) 547 | .get('/api/movies') 548 | .expect('Cache-Control', 'no-cache') 549 | .expect(200, movies) 550 | .then(function(res) { 551 | expect(res.headers['apicache-store']).to.be.undefined 552 | expect(res.headers['apicache-version']).to.be.undefined 553 | expect(app.requestsProcessed).to.equal(1) 554 | expect(res.headers['date']).to.exist 555 | }) 556 | .then(function() { 557 | return request(app) 558 | .get('/api/movies') 559 | .expect('apicache-store', 'memory') 560 | .expect('apicache-version', pkg.version) 561 | .expect(200, movies) 562 | .then(assertNumRequestsProcessed(app, 1)) 563 | }) 564 | }) 565 | 566 | it('preserves etag header', function() { 567 | var app = mockAPI.create('10 seconds') 568 | 569 | return request(app) 570 | .get('/api/movies') 571 | .expect(200) 572 | .then(function(res) { 573 | var etag = res.headers['etag'] 574 | expect(etag).to.exist 575 | return etag 576 | }) 577 | .then(function(etag) { 578 | return request(app) 579 | .get('/api/movies') 580 | .expect(200) 581 | .expect('etag', etag) 582 | }) 583 | }) 584 | 585 | it('embeds returns content-type JSON from original response and cached response', function() { 586 | var app = mockAPI.create('10 seconds') 587 | 588 | return request(app) 589 | .get('/api/movies') 590 | .expect(200) 591 | .expect('Content-Type', 'application/json; charset=utf-8') 592 | .then(function() { 593 | return request(app) 594 | .get('/api/movies') 595 | .expect('Content-Type', 'application/json; charset=utf-8') 596 | }) 597 | }) 598 | 599 | it('does not cache a request when status code found in status code exclusions', function() { 600 | var app = mockAPI.create('2 seconds', { 601 | statusCodes: { exclude: [404] } 602 | }) 603 | 604 | return request(app) 605 | .get('/api/missing') 606 | .expect(404) 607 | .then(function(res) { 608 | expect(res.headers['cache-control']).to.equal('no-cache, no-store, must-revalidate') 609 | expect(app.apicache.getIndex().all.length).to.equal(0) 610 | }) 611 | }) 612 | 613 | it('does not cache a request when status code not found in status code inclusions', function() { 614 | var app = mockAPI.create('2 seconds', { 615 | statusCodes: { include: [200] } 616 | }) 617 | 618 | return request(app) 619 | .get('/api/missing') 620 | .expect(404) 621 | .then(function(res) { 622 | expect(res.headers['cache-control']).to.equal('no-cache, no-store, must-revalidate') 623 | expect(app.apicache.getIndex().all.length).to.equal(0) 624 | }) 625 | }) 626 | 627 | it('middlewareToggle works correctly to control statusCode caching (per example)', function() { 628 | var onlyStatusCode200 = function(req, res) { 629 | return res.statusCode === 200 630 | } 631 | 632 | var app = mockAPI.create('2 seconds', {}, onlyStatusCode200) 633 | 634 | return request(app) 635 | .get('/api/missing') 636 | .expect(404) 637 | .then(function(res) { 638 | expect(res.headers['cache-control']).to.equal('no-cache, no-store, must-revalidate') 639 | expect(app.apicache.getIndex().all.length).to.equal(0) 640 | }) 641 | }) 642 | 643 | it('removes a cache key after expiration', function(done) { 644 | var app = mockAPI.create(10) 645 | 646 | request(app) 647 | .get('/api/movies') 648 | .end(function(err, res) { 649 | expect(app.apicache.getIndex().all.length).to.equal(1) 650 | expect(app.apicache.getIndex().all).to.include('/api/movies') 651 | }) 652 | 653 | setTimeout(function() { 654 | expect(app.apicache.getIndex().all).to.have.length(0) 655 | done() 656 | }, 25) 657 | }) 658 | 659 | it('executes expiration callback from globalOptions.events.expire upon entry expiration', function(done) { 660 | var callbackResponse = undefined 661 | var cb = function(a,b) { 662 | callbackResponse = b 663 | } 664 | var app = mockAPI.create(10, { events: { expire: cb }}) 665 | 666 | request(app) 667 | .get('/api/movies') 668 | .end(function(err, res) { 669 | expect(app.apicache.getIndex().all.length).to.equal(1) 670 | expect(app.apicache.getIndex().all).to.include('/api/movies') 671 | }) 672 | 673 | setTimeout(function() { 674 | expect(app.apicache.getIndex().all).to.have.length(0) 675 | expect(callbackResponse).to.equal('/api/movies') 676 | done() 677 | }, 25) 678 | }) 679 | 680 | it('allows defaultDuration to be a parseable string (e.g. "1 week")', function(done) { 681 | var callbackResponse = undefined 682 | var cb = function(a,b) { 683 | callbackResponse = b 684 | } 685 | var app = mockAPI.create(null, { defaultDuration: '10ms', events: { expire: cb }}) 686 | 687 | request(app) 688 | .get('/api/movies') 689 | .end(function(err, res) { 690 | expect(app.apicache.getIndex().all.length).to.equal(1) 691 | expect(app.apicache.getIndex().all).to.include('/api/movies') 692 | }) 693 | 694 | setTimeout(function() { 695 | expect(app.apicache.getIndex().all).to.have.length(0) 696 | expect(callbackResponse).to.equal('/api/movies') 697 | done() 698 | }, 25) 699 | }) 700 | }) 701 | }) 702 | }) 703 | 704 | describe('Redis support', function() { 705 | 706 | function hgetallIsNull(db, key) { 707 | return new Promise(function(resolve, reject) { 708 | db.hgetall(key, function(err, reply) { 709 | if(err) { 710 | reject(err) 711 | } else { 712 | expect(reply).to.equal(null) 713 | db.flushdb() 714 | resolve() 715 | } 716 | }) 717 | }) 718 | } 719 | 720 | apis.forEach(function(api) { 721 | describe(api.name + ' tests', function() { 722 | var mockAPI = api.server 723 | 724 | it('properly caches a request', function() { 725 | var db = redis.createClient() 726 | var app = mockAPI.create('10 seconds', { redisClient: db }) 727 | 728 | return request(app) 729 | .get('/api/movies') 730 | .expect(200, movies) 731 | .then(function(res) { 732 | expect(res.headers['apicache-store']).to.be.undefined 733 | expect(res.headers['apicache-version']).to.be.undefined 734 | expect(app.requestsProcessed).to.equal(1) 735 | }) 736 | .then(function() { 737 | return request(app) 738 | .get('/api/movies') 739 | .expect(200, movies) 740 | .expect('apicache-store', 'redis') 741 | .expect('apicache-version', pkg.version) 742 | .then(assertNumRequestsProcessed(app, 1)) 743 | .then(function() { 744 | db.flushdb() 745 | }) 746 | }) 747 | }) 748 | 749 | it('can clear indexed cache groups', function() { 750 | var db = redis.createClient() 751 | var app = mockAPI.create('10 seconds', { redisClient: db }) 752 | 753 | return request(app) 754 | .get('/api/testcachegroup') 755 | .then(function(res) { 756 | expect(app.requestsProcessed).to.equal(1) 757 | expect(app.apicache.getIndex().all.length).to.equal(1) 758 | expect(app.apicache.getIndex().groups.cachegroup.length).to.equal(1) 759 | expect(Object.keys(app.apicache.clear('cachegroup').groups).length).to.equal(0) 760 | expect(app.apicache.getIndex().all.length).to.equal(0) 761 | return hgetallIsNull(db, '/api/testcachegroup') 762 | }) 763 | }) 764 | 765 | it('can clear indexed entries by url/key (non-group)', function() { 766 | var db = redis.createClient() 767 | var app = mockAPI.create('10 seconds', { redisClient: db }) 768 | 769 | return request(app) 770 | .get('/api/movies') 771 | .then(function(res) { 772 | expect(app.requestsProcessed).to.equal(1) 773 | expect(app.apicache.getIndex().all.length).to.equal(1) 774 | expect(app.apicache.clear('/api/movies').all.length).to.equal(0) 775 | return hgetallIsNull(db, '/api/movies') 776 | }) 777 | }) 778 | 779 | it('can clear all entries from index', function() { 780 | var db = redis.createClient() 781 | var app = mockAPI.create('10 seconds', { redisClient: db }) 782 | 783 | expect(app.apicache.getIndex().all.length).to.equal(0) 784 | expect(app.apicache.clear().all.length).to.equal(0) 785 | 786 | return request(app) 787 | .get('/api/movies') 788 | .then(function(res) { 789 | expect(app.requestsProcessed).to.equal(1) 790 | expect(app.apicache.getIndex().all.length).to.equal(1) 791 | expect(app.apicache.clear().all.length).to.equal(0) 792 | return hgetallIsNull(db, '/api/movies') 793 | }) 794 | }) 795 | 796 | it('sends a response even if redis failure', function() { 797 | var app = mockAPI.create('10 seconds', { redisClient: {} }) 798 | 799 | return request(app) 800 | .get('/api/movies') 801 | .expect(200, movies) 802 | }) 803 | }) 804 | }) 805 | }) 806 | 807 | describe('.clear(key?) {SETTER}', function() { 808 | 809 | it('is a function', function() { 810 | var apicache = require('../src/apicache') 811 | expect(typeof apicache.clear).to.equal('function') 812 | }) 813 | 814 | apis.forEach(function(api) { 815 | describe(api.name + ' tests', function() { 816 | var mockAPI = api.server 817 | 818 | it('works when called with group key', function() { 819 | var app = mockAPI.create('10 seconds') 820 | 821 | return request(app) 822 | .get('/api/testcachegroup') 823 | .then(function(res) { 824 | expect(app.requestsProcessed).to.equal(1) 825 | expect(app.apicache.getIndex().all.length).to.equal(1) 826 | expect(app.apicache.getIndex().groups.cachegroup.length).to.equal(1) 827 | expect(Object.keys(app.apicache.clear('cachegroup').groups).length).to.equal(0) 828 | expect(app.apicache.getIndex().all.length).to.equal(0) 829 | }) 830 | }) 831 | 832 | it('works when called with specific endpoint (non-group) key', function() { 833 | var app = mockAPI.create('10 seconds') 834 | 835 | return request(app) 836 | .get('/api/movies') 837 | .then(function(res) { 838 | expect(app.requestsProcessed).to.equal(1) 839 | expect(app.apicache.getIndex().all.length).to.equal(1) 840 | expect(app.apicache.clear('/api/movies').all.length).to.equal(0) 841 | }) 842 | }) 843 | 844 | it('clears empty group after removing last specific endpoint', function() { 845 | var app = mockAPI.create('10 seconds') 846 | 847 | return request(app) 848 | .get('/api/testcachegroup') 849 | .then(function(res) { 850 | expect(app.requestsProcessed).to.equal(1) 851 | expect(app.apicache.getIndex().all.length).to.equal(1) 852 | expect(app.apicache.getIndex().groups.cachegroup.length).to.equal(1) 853 | expect(Object.keys(app.apicache.clear('/api/testcachegroup').groups).length).to.equal(0) 854 | expect(app.apicache.getIndex().all.length).to.equal(0) 855 | }) 856 | }) 857 | 858 | it('works when called with no key', function() { 859 | var app = mockAPI.create('10 seconds') 860 | 861 | expect(app.apicache.getIndex().all.length).to.equal(0) 862 | expect(app.apicache.clear().all.length).to.equal(0) 863 | return request(app) 864 | .get('/api/movies') 865 | .then(function(res) { 866 | expect(app.requestsProcessed).to.equal(1) 867 | expect(app.apicache.getIndex().all.length).to.equal(1) 868 | expect(app.apicache.clear().all.length).to.equal(0) 869 | }) 870 | }) 871 | 872 | }) 873 | }) 874 | }) 875 | --------------------------------------------------------------------------------