├── .eslintrc ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── apicache.js └── memory-cache.js └── test ├── api ├── express-gzip.js ├── express.js ├── lib │ ├── data.json │ └── routes.js ├── restify-gzip.js └── restify.js ├── apicache_test.js └── mock_api_gzip_restify.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: kwhitley 4 | open_collective: kevinrwhitley 5 | # patreon: # Replace with a single Patreon username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # iberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/node_modules 2 | /node_modules 3 | /npm-debug.log 4 | .DS_STORE 5 | *.rdb 6 | .nyc_output 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "semi": false, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | - 10 5 | - 12 6 | - 14 7 | - 16 8 | after_success: npm run coverage 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 13 | Because route-caching of simple data/responses should ALSO be simple. 14 | 15 | ## Usage 16 | 17 | To use, simply inject the middleware (example: `apicache.middleware('5 minutes', [optionalMiddlewareToggle])`) into your routes. Everything else is automagic. 18 | 19 | #### Cache a route 20 | 21 | ```js 22 | import express from 'express' 23 | import apicache from 'apicache' 24 | 25 | let app = express() 26 | let cache = apicache.middleware 27 | 28 | app.get('/api/collection/:id?', cache('5 minutes'), (req, res) => { 29 | // do some work... this will only occur once per 5 minutes 30 | res.json({ foo: 'bar' }) 31 | }) 32 | ``` 33 | 34 | #### Cache all routes 35 | 36 | ```js 37 | let cache = apicache.middleware 38 | 39 | app.use(cache('5 minutes')) 40 | 41 | app.get('/will-be-cached', (req, res) => { 42 | res.json({ success: true }) 43 | }) 44 | ``` 45 | 46 | #### Use with Redis 47 | 48 | ```js 49 | import express from 'express' 50 | import apicache from 'apicache' 51 | import redis from 'redis' 52 | 53 | let app = express() 54 | 55 | // if redisClient option is defined, apicache will use redis client 56 | // instead of built-in memory store 57 | let cacheWithRedis = apicache.options({ redisClient: redis.createClient() }).middleware 58 | 59 | app.get('/will-be-cached', cacheWithRedis('5 minutes'), (req, res) => { 60 | res.json({ success: true }) 61 | }) 62 | ``` 63 | 64 | #### Cache grouping and manual controls 65 | 66 | ```js 67 | import apicache from 'apicache' 68 | let cache = apicache.middleware 69 | 70 | app.use(cache('5 minutes')) 71 | 72 | // routes are automatically added to index, but may be further added 73 | // to groups for quick deleting of collections 74 | app.get('/api/:collection/:item?', (req, res) => { 75 | req.apicacheGroup = req.params.collection 76 | res.json({ success: true }) 77 | }) 78 | 79 | // add route to display cache performance (courtesy of @killdash9) 80 | app.get('/api/cache/performance', (req, res) => { 81 | res.json(apicache.getPerformance()) 82 | }) 83 | 84 | // add route to display cache index 85 | app.get('/api/cache/index', (req, res) => { 86 | res.json(apicache.getIndex()) 87 | }) 88 | 89 | // add route to manually clear target/group 90 | app.get('/api/cache/clear/:target?', (req, res) => { 91 | res.json(apicache.clear(req.params.target)) 92 | }) 93 | 94 | /* 95 | 96 | GET /api/foo/bar --> caches entry at /api/foo/bar and adds a group called 'foo' to index 97 | GET /api/cache/index --> displays index 98 | GET /api/cache/clear/foo --> clears all cached entries for 'foo' group/collection 99 | 100 | */ 101 | ``` 102 | 103 | #### Use with middleware toggle for fine control 104 | 105 | ```js 106 | // higher-order function returns false for responses of other status codes (e.g. 403, 404, 500, etc) 107 | const onlyStatus200 = (req, res) => res.statusCode === 200 108 | 109 | const cacheSuccesses = cache('5 minutes', onlyStatus200) 110 | 111 | app.get('/api/missing', cacheSuccesses, (req, res) => { 112 | res.status(404).json({ results: 'will not be cached' }) 113 | }) 114 | 115 | app.get('/api/found', cacheSuccesses, (req, res) => { 116 | res.json({ results: 'will be cached' }) 117 | }) 118 | ``` 119 | 120 | #### Prevent cache-control header "max-age" from automatically being set to expiration age 121 | 122 | ```js 123 | let cache = apicache.options({ 124 | headers: { 125 | 'cache-control': 'no-cache', 126 | }, 127 | }).middleware 128 | 129 | let cache5min = cache('5 minute') // continue to use normally 130 | ``` 131 | 132 | ## API 133 | 134 | - `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. 135 | - `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. 136 | - `middleware.options([localOptions])` - getter/setter for middleware-specific options that will override global ones. 137 | - `apicache.getPerformance()` - returns current cache performance (cache hit rate) 138 | - `apicache.getIndex()` - returns current cache index [of keys] 139 | - `apicache.clear([target])` - clears cache target (key or group), or entire cache if no value passed, returns new index. 140 | - `apicache.newInstance([options])` - used to create a new ApiCache instance (by default, simply requiring this library shares a common instance) 141 | - `apicache.clone()` - used to create a new ApiCache instance with the same options as the current one 142 | 143 | #### Available Options (first value is default) 144 | 145 | ```js 146 | { 147 | debug: false|true, // if true, enables console output 148 | defaultDuration: '1 hour', // should be either a number (in ms) or a string, defaults to 1 hour 149 | enabled: true|false, // if false, turns off caching globally (useful on dev) 150 | 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) 151 | appendKey: fn(req, res), // appendKey takes the req/res objects and returns a custom value to extend the cache key 152 | headerBlacklist: [], // list of headers that should never be cached 153 | statusCodes: { 154 | exclude: [], // list status codes to specifically exclude (e.g. [404, 403] cache all responses unless they had a 404 or 403 status) 155 | include: [], // list status codes to require (e.g. [200] caches ONLY responses with a success/200 code) 156 | }, 157 | trackPerformance: false, // enable/disable performance tracking... WARNING: super cool feature, but may cause memory overhead issues 158 | headers: { 159 | // 'cache-control': 'no-cache' // example of header overwrite 160 | }, 161 | respectCacheControl: false|true // If true, 'Cache-Control: no-cache' in the request header will bypass the cache. 162 | } 163 | ``` 164 | 165 | ##### \*Optional: Typescript Types (courtesy of [@danielsogl](https://github.com/danielsogl)) 166 | 167 | ```bash 168 | $ npm install -D @types/apicache 169 | ``` 170 | 171 | ## Custom Cache Keys 172 | 173 | Sometimes you need custom keys (e.g. save routes per-session, or per method). 174 | We've made it easy! 175 | 176 | **Note:** All req/res attributes used in the generation of the key must have been set 177 | previously (upstream). The entire route logic block is skipped on future cache hits 178 | so it can't rely on those params. 179 | 180 | ```js 181 | apicache.options({ 182 | appendKey: (req, res) => req.method + res.session.id, 183 | }) 184 | ``` 185 | 186 | ## Cache Key Groups 187 | 188 | Oftentimes it benefits us to group cache entries, for example, by collection (in an API). This 189 | would enable us to clear all cached "post" requests if we updated something in the "post" collection 190 | for instance. Adding a simple `req.apicacheGroup = [somevalue];` to your route enables this. See example below: 191 | 192 | ```js 193 | var apicache = require('apicache') 194 | var cache = apicache.middleware 195 | 196 | // GET collection/id 197 | app.get('/api/:collection/:id?', cache('1 hour'), function(req, res, next) { 198 | req.apicacheGroup = req.params.collection 199 | // do some work 200 | res.send({ foo: 'bar' }) 201 | }) 202 | 203 | // POST collection/id 204 | app.post('/api/:collection/:id?', function(req, res, next) { 205 | // update model 206 | apicache.clear(req.params.collection) 207 | res.send('added a new item, so the cache has been cleared') 208 | }) 209 | ``` 210 | 211 | Additionally, you could add manual cache control to the previous project with routes such as these: 212 | 213 | ```js 214 | // GET apicache index (for the curious) 215 | app.get('/api/cache/index', function(req, res, next) { 216 | res.send(apicache.getIndex()) 217 | }) 218 | 219 | // GET apicache index (for the curious) 220 | app.get('/api/cache/clear/:key?', function(req, res, next) { 221 | res.send(200, apicache.clear(req.params.key || req.query.key)) 222 | }) 223 | ``` 224 | 225 | ## Debugging/Console Out 226 | 227 | #### Using Node environment variables (plays nicely with the hugely popular [debug](https://www.npmjs.com/package/debug) module) 228 | 229 | ``` 230 | $ export DEBUG=apicache 231 | $ export DEBUG=apicache,othermoduleThatDebugModuleWillPickUp,etc 232 | ``` 233 | 234 | #### By setting internal option 235 | 236 | ```js 237 | import apicache from 'apicache' 238 | 239 | apicache.options({ debug: true }) 240 | ``` 241 | 242 | ## Client-Side Bypass 243 | 244 | When sharing `GET` routes between admin and public sites, you'll likely want the 245 | routes to be cached from your public client, but NOT cached when from the admin client. This 246 | is achieved by sending a `"x-apicache-bypass": true` header along with the requst from the admin. 247 | The presence of this header flag will bypass the cache, ensuring you aren't looking at stale data. 248 | 249 | ## Contributors 250 | 251 | 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! 252 | 253 | - [@Chocobozzz](https://github.com/Chocobozzz) - the savior of getting this to pass all the Node 14/15 tests again... thanks for everyone's patience!!! 254 | - [@killdash9](https://github.com/killdash9) - restify support, performance/stats system, and too much else at this point to list 255 | - [@svozza](https://github.com/svozza) - added restify tests, test suite refactor, and fixed header issue with restify. Node v7 + Restify v5 conflict resolution, etag/if-none-match support, etcetc, etc. Triple thanks!!! 256 | - [@andredigenova](https://github.com/andredigenova) - Added header blacklist as options, correction to caching checks 257 | - [@peteboere](https://github.com/peteboere) - Node v7 headers update 258 | - [@rutgernation](https://github.com/rutgernation) - JSONP support 259 | - [@enricsangra](https://github.com/enricsangra) - added x-apicache-force-fetch header 260 | - [@tskillian](https://github.com/tskillian) - custom appendKey path support 261 | - [@agolden](https://github.com/agolden) - Content-Encoding preservation (for gzip, etc) 262 | - [@davidyang](https://github.com/davidyang) - express 4+ compatibility 263 | - [@nmors](https://github.com/nmors) - redis support 264 | - [@maytis](https://github.com/maytis), [@ashwinnaidu](https://github.com/ashwinnaidu) - redis expiration 265 | - [@ubergesundheit](https://github.com/ubergesundheit) - Corrected buffer accumulation using res.write with Buffers 266 | - [@danielsogl](https://github.com/danielsogl) - Keeping dev deps up to date, Typescript Types 267 | - [@vectart](https://github.com/vectart) - Added middleware local options support 268 | - [@davebaol](https://github.com/davebaol) - Added string support to defaultDuration option (previously just numeric ms) 269 | - [@Rauttis](https://github.com/rauttis) - Added ioredis support 270 | - [@fernandolguevara](https://github.com/fernandolguevara) - Added opt-out for performance tracking, great emergency fix, thank you!! 271 | 272 | ### Bugfixes, tweaks, documentation, etc. 273 | 274 | - @Amhri, @Webcascade, @conmarap, @cjfurelid, @scambier, @lukechilds, @Red-Lv, @gesposito, @viebel, @RowanMeara, @GoingFast, @luin, @keithws, @daveross, @apascal, @guybrush 275 | 276 | ### Changelog 277 | 278 | - **v1.6.0** - added respectCacheControl option flag to force honoring no-cache (thanks [@NaridaL](https://github.com/NaridaL)!) 279 | - **v1.5.4** - up to Node v15 support, HUGE thanks to [@Chocobozzz](https://github.com/Chocobozzz) and all the folks on the PR thread! <3 280 | - **v1.5.3** - multiple fixes: Redis should be connected before using (thanks @guybrush) 281 | - **v1.5.2** - multiple fixes: Buffer deprecation and \_headers deprecation, { trackPerformance: false } by default per discussion (sorry semver...) 282 | - **v1.5.1** - adds { trackPerformance } option to enable/disable performance tracking (thanks @fernandolguevara) 283 | - **v1.5.0** - exposes apicache.getPerformance() for per-route cache metrics (@killdash9 continues to deliver) 284 | - **v1.4.0** - cache-control header now auto-decrements in cached responses (thanks again, @killdash9) 285 | - **v1.3.0** - [securityfix] apicache headers no longer embedded in cached responses when NODE_ENV === 'production' (thanks for feedback @satya-jugran, @smddzcy, @adamelliotfields). Updated deps, now requiring Node v6.00+. 286 | - **v1.2.6** - middlewareToggle() now prevents response block on cache hit + falsy toggle (thanks @apascal) 287 | - **v1.2.5** - uses native Node setHeader() rather than express.js header() (thanks @keithws and @daveross) 288 | - **v1.2.4** - force content type to Buffer, using old and new Buffer creation syntax 289 | - **v1.2.3** - add etag to if-none-match 304 support (thanks for the test/issue @svozza) 290 | - **v1.2.2** - bugfix: ioredis.expire params (thanks @GoingFast and @luin) 291 | - **v1.2.1** - Updated deps 292 | - **v1.2.0** - Supports ioredis (thanks @Rauttis) 293 | - **v1.1.1** - bugfixes in expiration timeout clearing and content header preservation under compression (thanks @RowanMeara and @samimakicc). 294 | - **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. 295 | - **v1.0.0** - stamping v0.11.2 into official production version, will now begin developing on branch v2.x (redesign) 296 | - **v0.11.2** - dev-deps update, courtesy of @danielsogl 297 | - **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) 298 | - **v0.11.0** - Added string support to defaultDuration option, previously just numeric ms - thanks @davebaol 299 | - **v0.10.0** - added ability to blacklist headers (prevents caching) via options.headersBlacklist (thanks @andredigenova) 300 | - **v0.9.1** - added eslint in prep for v1.x branch, minor ES6 to ES5 in master branch tests 301 | - **v0.9.0** - corrected Node v7.7 & v8 conflicts with restify (huge thanks to @svozza 302 | for chasing this down and fixing upstream in restify itself). Added coveralls. Added 303 | middleware.localOptions support (thanks @vectart). Added ability to overwrite/embed headers 304 | (e.g. "cache-control": "no-cache") through options. 305 | - **v0.8.8** - corrected to use node v7+ headers (thanks @peteboere) 306 | - **v0.8.6, v0.8.7** - README update 307 | - **v0.8.5** - dev dependencies update (thanks @danielsogl) 308 | - **v0.8.4** - corrected buffer accumulation, with test support (thanks @ubergesundheit) 309 | - **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) 310 | - **v0.8.2** - test suite and mock API refactor (thanks @svozza) 311 | - **v0.8.1** - fixed restify support and added appropriate tests (thanks @svozza) 312 | - **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. 313 | - **v0.7.0** - internally sets cache-control/max-age headers of response object 314 | - **v0.6.0** - removed final dependency (debug) and updated README 315 | - **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 316 | - **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 317 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apicache", 3 | "version": "1.6.3", 4 | "scripts": { 5 | "lint": "eslint .", 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": ">=8" 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": "krwhitley@gmail.com" 38 | }, 39 | "devDependencies": { 40 | "chai": "^4.2.0", 41 | "compression": "^1.7.4", 42 | "coveralls": "^3.0.6", 43 | "eslint": "^4.18.0", 44 | "express": "^4.17.1", 45 | "fakeredis": "^2.0.0", 46 | "husky": "^3.0.4", 47 | "mocha": "^7.0.0", 48 | "nyc": "^13.3.0", 49 | "prettier": "^1.18.2", 50 | "pretty-quick": "^1.11.1", 51 | "restify": "^7.7.0", 52 | "restify-etag-cache": "^1.0.12", 53 | "supertest": "^4.0.2" 54 | }, 55 | "husky": { 56 | "hooks": { 57 | "pre-commit": "pretty-quick --staged && npm run test" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /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) { 19 | return a === b 20 | } 21 | } 22 | 23 | var doesntMatch = function(a) { 24 | return function(b) { 25 | return !matches(a)(b) 26 | } 27 | } 28 | 29 | var logDuration = function(d, prefix) { 30 | var str = d > 1000 ? (d / 1000).toFixed(2) + 'sec' : d + 'ms' 31 | return '\x1b[33m- ' + (prefix ? prefix + ' ' : '') + str + '\x1b[0m' 32 | } 33 | 34 | function getSafeHeaders(res) { 35 | return res.getHeaders ? res.getHeaders() : res._headers 36 | } 37 | 38 | function ApiCache() { 39 | var memCache = new MemoryCache() 40 | 41 | var globalOptions = { 42 | debug: false, 43 | defaultDuration: 3600000, 44 | enabled: true, 45 | appendKey: [], 46 | jsonp: false, 47 | redisClient: false, 48 | headerBlacklist: [], 49 | statusCodes: { 50 | include: [], 51 | exclude: [], 52 | }, 53 | events: { 54 | expire: undefined, 55 | }, 56 | headers: { 57 | // 'cache-control': 'no-cache' // example of header overwrite 58 | }, 59 | trackPerformance: false, 60 | respectCacheControl: false, 61 | } 62 | 63 | var middlewareOptions = [] 64 | var instance = this 65 | var index = null 66 | var timers = {} 67 | var performanceArray = [] // for tracking cache hit rate 68 | 69 | instances.push(this) 70 | this.id = instances.length 71 | 72 | function debug(a, b, c, d) { 73 | var arr = ['\x1b[36m[apicache]\x1b[0m', a, b, c, d].filter(function(arg) { 74 | return arg !== undefined 75 | }) 76 | var debugEnv = process.env.DEBUG && process.env.DEBUG.split(',').indexOf('apicache') !== -1 77 | 78 | return (globalOptions.debug || debugEnv) && console.log.apply(null, arr) 79 | } 80 | 81 | function shouldCacheResponse(request, response, toggle) { 82 | var opt = globalOptions 83 | var codes = opt.statusCodes 84 | 85 | if (!response) return false 86 | 87 | if (toggle && !toggle(request, response)) { 88 | return false 89 | } 90 | 91 | if (codes.exclude && codes.exclude.length && codes.exclude.indexOf(response.statusCode) !== -1) 92 | return false 93 | if (codes.include && codes.include.length && codes.include.indexOf(response.statusCode) === -1) 94 | return false 95 | 96 | return true 97 | } 98 | 99 | function addIndexEntries(key, req) { 100 | var groupName = req.apicacheGroup 101 | 102 | if (groupName) { 103 | debug('group detected "' + groupName + '"') 104 | var group = (index.groups[groupName] = index.groups[groupName] || []) 105 | group.unshift(key) 106 | } 107 | 108 | index.all.unshift(key) 109 | } 110 | 111 | function filterBlacklistedHeaders(headers) { 112 | return Object.keys(headers) 113 | .filter(function(key) { 114 | return globalOptions.headerBlacklist.indexOf(key) === -1 115 | }) 116 | .reduce(function(acc, header) { 117 | acc[header] = headers[header] 118 | return acc 119 | }, {}) 120 | } 121 | 122 | function createCacheObject(status, headers, data, encoding) { 123 | return { 124 | status: status, 125 | headers: filterBlacklistedHeaders(headers), 126 | data: data, 127 | encoding: encoding, 128 | timestamp: new Date().getTime() / 1000, // seconds since epoch. This is used to properly decrement max-age headers in cached responses. 129 | } 130 | } 131 | 132 | function cacheResponse(key, value, duration) { 133 | var redis = globalOptions.redisClient 134 | var expireCallback = globalOptions.events.expire 135 | 136 | if (redis && redis.connected) { 137 | try { 138 | redis.hset(key, 'response', JSON.stringify(value)) 139 | redis.hset(key, 'duration', duration) 140 | redis.expire(key, duration / 1000, expireCallback || function() {}) 141 | } catch (err) { 142 | debug('[apicache] error in redis.hset()') 143 | } 144 | } else { 145 | memCache.add(key, value, duration, expireCallback) 146 | } 147 | 148 | // add automatic cache clearing from duration, includes max limit on setTimeout 149 | timers[key] = setTimeout(function() { 150 | instance.clear(key, true) 151 | }, Math.min(duration, 2147483647)) 152 | } 153 | 154 | function accumulateContent(res, content) { 155 | if (content) { 156 | if (typeof content == 'string') { 157 | res._apicache.content = (res._apicache.content || '') + content 158 | } else if (Buffer.isBuffer(content)) { 159 | var oldContent = res._apicache.content 160 | 161 | if (typeof oldContent === 'string') { 162 | oldContent = !Buffer.from ? new Buffer(oldContent) : Buffer.from(oldContent) 163 | } 164 | 165 | if (!oldContent) { 166 | oldContent = !Buffer.alloc ? new Buffer(0) : Buffer.alloc(0) 167 | } 168 | 169 | res._apicache.content = Buffer.concat( 170 | [oldContent, content], 171 | oldContent.length + content.length 172 | ) 173 | } else { 174 | res._apicache.content = content 175 | } 176 | } 177 | } 178 | 179 | function makeResponseCacheable(req, res, next, key, duration, strDuration, toggle) { 180 | // monkeypatch res.end to create cache object 181 | res._apicache = { 182 | write: res.write, 183 | writeHead: res.writeHead, 184 | end: res.end, 185 | cacheable: true, 186 | content: undefined, 187 | } 188 | 189 | // append header overwrites if applicable 190 | Object.keys(globalOptions.headers).forEach(function(name) { 191 | res.setHeader(name, globalOptions.headers[name]) 192 | }) 193 | 194 | res.writeHead = function() { 195 | // add cache control headers 196 | if (!globalOptions.headers['cache-control']) { 197 | if (shouldCacheResponse(req, res, toggle)) { 198 | res.setHeader('cache-control', 'max-age=' + (duration / 1000).toFixed(0)) 199 | } else { 200 | res.setHeader('cache-control', 'no-cache, no-store, must-revalidate') 201 | } 202 | } 203 | 204 | res._apicache.headers = Object.assign({}, getSafeHeaders(res)) 205 | return res._apicache.writeHead.apply(this, arguments) 206 | } 207 | 208 | // patch res.write 209 | res.write = function(content) { 210 | accumulateContent(res, content) 211 | return res._apicache.write.apply(this, arguments) 212 | } 213 | 214 | // patch res.end 215 | res.end = function(content, encoding) { 216 | if (shouldCacheResponse(req, res, toggle)) { 217 | accumulateContent(res, content) 218 | 219 | if (res._apicache.cacheable && res._apicache.content) { 220 | addIndexEntries(key, req) 221 | var headers = res._apicache.headers || getSafeHeaders(res) 222 | var cacheObject = createCacheObject( 223 | res.statusCode, 224 | headers, 225 | res._apicache.content, 226 | encoding 227 | ) 228 | cacheResponse(key, cacheObject, duration) 229 | 230 | // display log entry 231 | var elapsed = new Date() - req.apicacheTimer 232 | debug('adding cache entry for "' + key + '" @ ' + strDuration, logDuration(elapsed)) 233 | debug('_apicache.headers: ', res._apicache.headers) 234 | debug('res.getHeaders(): ', getSafeHeaders(res)) 235 | debug('cacheObject: ', cacheObject) 236 | } 237 | } 238 | 239 | return res._apicache.end.apply(this, arguments) 240 | } 241 | 242 | next() 243 | } 244 | 245 | function sendCachedResponse(request, response, cacheObject, toggle, next, duration) { 246 | if (toggle && !toggle(request, response)) { 247 | return next() 248 | } 249 | 250 | var headers = getSafeHeaders(response) 251 | 252 | Object.assign(headers, filterBlacklistedHeaders(cacheObject.headers || {}), { 253 | // set properly-decremented max-age header. This ensures that max-age is in sync with the cache expiration. 254 | 'cache-control': 255 | 'max-age=' + 256 | Math.max( 257 | 0, 258 | (duration / 1000 - (new Date().getTime() / 1000 - cacheObject.timestamp)).toFixed(0) 259 | ), 260 | }) 261 | 262 | // only embed apicache headers when not in production environment 263 | if (process.env.NODE_ENV !== 'production') { 264 | Object.assign(headers, { 265 | 'apicache-store': globalOptions.redisClient ? 'redis' : 'memory', 266 | 'apicache-version': pkg.version, 267 | }) 268 | } 269 | 270 | // unstringify buffers 271 | var data = cacheObject.data 272 | if (data && data.type === 'Buffer') { 273 | data = 274 | typeof data.data === 'number' ? new Buffer.alloc(data.data) : new Buffer.from(data.data) 275 | } 276 | 277 | // test Etag against If-None-Match for 304 278 | var cachedEtag = cacheObject.headers.etag 279 | var requestEtag = request.headers['if-none-match'] 280 | 281 | if (requestEtag && cachedEtag === requestEtag) { 282 | response.writeHead(304, headers) 283 | return response.end() 284 | } 285 | 286 | response.writeHead(cacheObject.status || 200, headers) 287 | 288 | return response.end(data, cacheObject.encoding) 289 | } 290 | 291 | function syncOptions() { 292 | for (var i in middlewareOptions) { 293 | Object.assign(middlewareOptions[i].options, globalOptions, middlewareOptions[i].localOptions) 294 | } 295 | } 296 | 297 | this.clear = function(target, isAutomatic) { 298 | var group = index.groups[target] 299 | var redis = globalOptions.redisClient 300 | 301 | if (group) { 302 | debug('clearing group "' + target + '"') 303 | 304 | group.forEach(function(key) { 305 | debug('clearing cached entry for "' + key + '"') 306 | clearTimeout(timers[key]) 307 | delete timers[key] 308 | if (!globalOptions.redisClient) { 309 | memCache.delete(key) 310 | } else { 311 | try { 312 | redis.del(key) 313 | } catch (err) { 314 | console.log('[apicache] error in redis.del("' + key + '")') 315 | } 316 | } 317 | index.all = index.all.filter(doesntMatch(key)) 318 | }) 319 | 320 | delete index.groups[target] 321 | } else if (target) { 322 | debug('clearing ' + (isAutomatic ? 'expired' : 'cached') + ' entry for "' + target + '"') 323 | clearTimeout(timers[target]) 324 | delete timers[target] 325 | // clear actual cached entry 326 | if (!redis) { 327 | memCache.delete(target) 328 | } else { 329 | try { 330 | redis.del(target) 331 | } catch (err) { 332 | console.log('[apicache] error in redis.del("' + target + '")') 333 | } 334 | } 335 | 336 | // remove from global index 337 | index.all = index.all.filter(doesntMatch(target)) 338 | 339 | // remove target from each group that it may exist in 340 | Object.keys(index.groups).forEach(function(groupName) { 341 | index.groups[groupName] = index.groups[groupName].filter(doesntMatch(target)) 342 | 343 | // delete group if now empty 344 | if (!index.groups[groupName].length) { 345 | delete index.groups[groupName] 346 | } 347 | }) 348 | } else { 349 | debug('clearing entire index') 350 | 351 | if (!redis) { 352 | memCache.clear() 353 | } else { 354 | // clear redis keys one by one from internal index to prevent clearing non-apicache entries 355 | index.all.forEach(function(key) { 356 | clearTimeout(timers[key]) 357 | delete timers[key] 358 | try { 359 | redis.del(key) 360 | } catch (err) { 361 | console.log('[apicache] error in redis.del("' + key + '")') 362 | } 363 | }) 364 | } 365 | this.resetIndex() 366 | } 367 | 368 | return this.getIndex() 369 | } 370 | 371 | function parseDuration(duration, defaultDuration) { 372 | if (typeof duration === 'number') return duration 373 | 374 | if (typeof duration === 'string') { 375 | var split = duration.match(/^([\d\.,]+)\s?(\w+)$/) 376 | 377 | if (split.length === 3) { 378 | var len = parseFloat(split[1]) 379 | var unit = split[2].replace(/s$/i, '').toLowerCase() 380 | if (unit === 'm') { 381 | unit = 'ms' 382 | } 383 | 384 | return (len || 1) * (t[unit] || 0) 385 | } 386 | } 387 | 388 | return defaultDuration 389 | } 390 | 391 | this.getDuration = function(duration) { 392 | return parseDuration(duration, globalOptions.defaultDuration) 393 | } 394 | 395 | /** 396 | * Return cache performance statistics (hit rate). Suitable for putting into a route: 397 | * 398 | * app.get('/api/cache/performance', (req, res) => { 399 | * res.json(apicache.getPerformance()) 400 | * }) 401 | * 402 | */ 403 | this.getPerformance = function() { 404 | return performanceArray.map(function(p) { 405 | return p.report() 406 | }) 407 | } 408 | 409 | this.getIndex = function(group) { 410 | if (group) { 411 | return index.groups[group] 412 | } else { 413 | return index 414 | } 415 | } 416 | 417 | this.middleware = function cache(strDuration, middlewareToggle, localOptions) { 418 | var duration = instance.getDuration(strDuration) 419 | var opt = {} 420 | 421 | middlewareOptions.push({ 422 | options: opt, 423 | }) 424 | 425 | var options = function(localOptions) { 426 | if (localOptions) { 427 | middlewareOptions.find(function(middleware) { 428 | return middleware.options === opt 429 | }).localOptions = localOptions 430 | } 431 | 432 | syncOptions() 433 | 434 | return opt 435 | } 436 | 437 | options(localOptions) 438 | 439 | /** 440 | * A Function for non tracking performance 441 | */ 442 | function NOOPCachePerformance() { 443 | this.report = this.hit = this.miss = function() {} // noop; 444 | } 445 | 446 | /** 447 | * A function for tracking and reporting hit rate. These statistics are returned by the getPerformance() call above. 448 | */ 449 | function CachePerformance() { 450 | /** 451 | * Tracks the hit rate for the last 100 requests. 452 | * If there have been fewer than 100 requests, the hit rate just considers the requests that have happened. 453 | */ 454 | this.hitsLast100 = new Uint8Array(100 / 4) // each hit is 2 bits 455 | 456 | /** 457 | * Tracks the hit rate for the last 1000 requests. 458 | * If there have been fewer than 1000 requests, the hit rate just considers the requests that have happened. 459 | */ 460 | this.hitsLast1000 = new Uint8Array(1000 / 4) // each hit is 2 bits 461 | 462 | /** 463 | * Tracks the hit rate for the last 10000 requests. 464 | * If there have been fewer than 10000 requests, the hit rate just considers the requests that have happened. 465 | */ 466 | this.hitsLast10000 = new Uint8Array(10000 / 4) // each hit is 2 bits 467 | 468 | /** 469 | * Tracks the hit rate for the last 100000 requests. 470 | * If there have been fewer than 100000 requests, the hit rate just considers the requests that have happened. 471 | */ 472 | this.hitsLast100000 = new Uint8Array(100000 / 4) // each hit is 2 bits 473 | 474 | /** 475 | * The number of calls that have passed through the middleware since the server started. 476 | */ 477 | this.callCount = 0 478 | 479 | /** 480 | * The total number of hits since the server started 481 | */ 482 | this.hitCount = 0 483 | 484 | /** 485 | * The key from the last cache hit. This is useful in identifying which route these statistics apply to. 486 | */ 487 | this.lastCacheHit = null 488 | 489 | /** 490 | * The key from the last cache miss. This is useful in identifying which route these statistics apply to. 491 | */ 492 | this.lastCacheMiss = null 493 | 494 | /** 495 | * Return performance statistics 496 | */ 497 | this.report = function() { 498 | return { 499 | lastCacheHit: this.lastCacheHit, 500 | lastCacheMiss: this.lastCacheMiss, 501 | callCount: this.callCount, 502 | hitCount: this.hitCount, 503 | missCount: this.callCount - this.hitCount, 504 | hitRate: this.callCount == 0 ? null : this.hitCount / this.callCount, 505 | hitRateLast100: this.hitRate(this.hitsLast100), 506 | hitRateLast1000: this.hitRate(this.hitsLast1000), 507 | hitRateLast10000: this.hitRate(this.hitsLast10000), 508 | hitRateLast100000: this.hitRate(this.hitsLast100000), 509 | } 510 | } 511 | 512 | /** 513 | * Computes a cache hit rate from an array of hits and misses. 514 | * @param {Uint8Array} array An array representing hits and misses. 515 | * @returns a number between 0 and 1, or null if the array has no hits or misses 516 | */ 517 | this.hitRate = function(array) { 518 | var hits = 0 519 | var misses = 0 520 | for (var i = 0; i < array.length; i++) { 521 | var n8 = array[i] 522 | for (j = 0; j < 4; j++) { 523 | switch (n8 & 3) { 524 | case 1: 525 | hits++ 526 | break 527 | case 2: 528 | misses++ 529 | break 530 | } 531 | n8 >>= 2 532 | } 533 | } 534 | var total = hits + misses 535 | if (total == 0) return null 536 | return hits / total 537 | } 538 | 539 | /** 540 | * Record a hit or miss in the given array. It will be recorded at a position determined 541 | * by the current value of the callCount variable. 542 | * @param {Uint8Array} array An array representing hits and misses. 543 | * @param {boolean} hit true for a hit, false for a miss 544 | * Each element in the array is 8 bits, and encodes 4 hit/miss records. 545 | * Each hit or miss is encoded as to bits as follows: 546 | * 00 means no hit or miss has been recorded in these bits 547 | * 01 encodes a hit 548 | * 10 encodes a miss 549 | */ 550 | this.recordHitInArray = function(array, hit) { 551 | var arrayIndex = ~~(this.callCount / 4) % array.length 552 | var bitOffset = (this.callCount % 4) * 2 // 2 bits per record, 4 records per uint8 array element 553 | var clearMask = ~(3 << bitOffset) 554 | var record = (hit ? 1 : 2) << bitOffset 555 | array[arrayIndex] = (array[arrayIndex] & clearMask) | record 556 | } 557 | 558 | /** 559 | * Records the hit or miss in the tracking arrays and increments the call count. 560 | * @param {boolean} hit true records a hit, false records a miss 561 | */ 562 | this.recordHit = function(hit) { 563 | this.recordHitInArray(this.hitsLast100, hit) 564 | this.recordHitInArray(this.hitsLast1000, hit) 565 | this.recordHitInArray(this.hitsLast10000, hit) 566 | this.recordHitInArray(this.hitsLast100000, hit) 567 | if (hit) this.hitCount++ 568 | this.callCount++ 569 | } 570 | 571 | /** 572 | * Records a hit event, setting lastCacheMiss to the given key 573 | * @param {string} key The key that had the cache hit 574 | */ 575 | this.hit = function(key) { 576 | this.recordHit(true) 577 | this.lastCacheHit = key 578 | } 579 | 580 | /** 581 | * Records a miss event, setting lastCacheMiss to the given key 582 | * @param {string} key The key that had the cache miss 583 | */ 584 | this.miss = function(key) { 585 | this.recordHit(false) 586 | this.lastCacheMiss = key 587 | } 588 | } 589 | 590 | var perf = globalOptions.trackPerformance ? new CachePerformance() : new NOOPCachePerformance() 591 | 592 | performanceArray.push(perf) 593 | 594 | var cache = function(req, res, next) { 595 | function bypass() { 596 | debug('bypass detected, skipping cache.') 597 | return next() 598 | } 599 | 600 | // initial bypass chances 601 | if (!opt.enabled) return bypass() 602 | if ( 603 | req.headers['x-apicache-bypass'] || 604 | req.headers['x-apicache-force-fetch'] || 605 | (opt.respectCacheControl && req.headers['cache-control'] == 'no-cache') 606 | ) 607 | return bypass() 608 | 609 | // REMOVED IN 0.11.1 TO CORRECT MIDDLEWARE TOGGLE EXECUTE ORDER 610 | // if (typeof middlewareToggle === 'function') { 611 | // if (!middlewareToggle(req, res)) return bypass() 612 | // } else if (middlewareToggle !== undefined && !middlewareToggle) { 613 | // return bypass() 614 | // } 615 | 616 | // embed timer 617 | req.apicacheTimer = new Date() 618 | 619 | // In Express 4.x the url is ambigious based on where a router is mounted. originalUrl will give the full Url 620 | var key = req.originalUrl || req.url 621 | 622 | // Remove querystring from key if jsonp option is enabled 623 | if (opt.jsonp) { 624 | key = url.parse(key).pathname 625 | } 626 | 627 | // add appendKey (either custom function or response path) 628 | if (typeof opt.appendKey === 'function') { 629 | key += '$$appendKey=' + opt.appendKey(req, res) 630 | } else if (opt.appendKey.length > 0) { 631 | var appendKey = req 632 | 633 | for (var i = 0; i < opt.appendKey.length; i++) { 634 | appendKey = appendKey[opt.appendKey[i]] 635 | } 636 | key += '$$appendKey=' + appendKey 637 | } 638 | 639 | // attempt cache hit 640 | var redis = opt.redisClient 641 | var cached = !redis ? memCache.getValue(key) : null 642 | 643 | // send if cache hit from memory-cache 644 | if (cached) { 645 | var elapsed = new Date() - req.apicacheTimer 646 | debug('sending cached (memory-cache) version of', key, logDuration(elapsed)) 647 | 648 | perf.hit(key) 649 | return sendCachedResponse(req, res, cached, middlewareToggle, next, duration) 650 | } 651 | 652 | // send if cache hit from redis 653 | if (redis && redis.connected) { 654 | try { 655 | redis.hgetall(key, function(err, obj) { 656 | if (!err && obj && obj.response) { 657 | var elapsed = new Date() - req.apicacheTimer 658 | debug('sending cached (redis) version of', key, logDuration(elapsed)) 659 | 660 | perf.hit(key) 661 | return sendCachedResponse( 662 | req, 663 | res, 664 | JSON.parse(obj.response), 665 | middlewareToggle, 666 | next, 667 | duration 668 | ) 669 | } else { 670 | perf.miss(key) 671 | return makeResponseCacheable( 672 | req, 673 | res, 674 | next, 675 | key, 676 | duration, 677 | strDuration, 678 | middlewareToggle 679 | ) 680 | } 681 | }) 682 | } catch (err) { 683 | // bypass redis on error 684 | perf.miss(key) 685 | return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle) 686 | } 687 | } else { 688 | perf.miss(key) 689 | return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle) 690 | } 691 | } 692 | 693 | cache.options = options 694 | 695 | return cache 696 | } 697 | 698 | this.options = function(options) { 699 | if (options) { 700 | Object.assign(globalOptions, options) 701 | syncOptions() 702 | 703 | if ('defaultDuration' in options) { 704 | // Convert the default duration to a number in milliseconds (if needed) 705 | globalOptions.defaultDuration = parseDuration(globalOptions.defaultDuration, 3600000) 706 | } 707 | 708 | if (globalOptions.trackPerformance) { 709 | debug('WARNING: using trackPerformance flag can cause high memory usage!') 710 | } 711 | 712 | return this 713 | } else { 714 | return globalOptions 715 | } 716 | } 717 | 718 | this.resetIndex = function() { 719 | index = { 720 | all: [], 721 | groups: {}, 722 | } 723 | } 724 | 725 | this.newInstance = function(config) { 726 | var instance = new ApiCache() 727 | 728 | if (config) { 729 | instance.options(config) 730 | } 731 | 732 | return instance 733 | } 734 | 735 | this.clone = function() { 736 | return this.newInstance(this.options()) 737 | } 738 | 739 | // initialize index 740 | this.resetIndex() 741 | } 742 | 743 | module.exports = new ApiCache() 744 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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.setHeader('Content-Type', 'text/plain') 23 | res.write('a') 24 | res.write('b') 25 | res.write('c') 26 | 27 | res.end() 28 | }) 29 | 30 | app.get('/api/writebufferandend', function(req, res) { 31 | app.requestsProcessed++ 32 | 33 | 34 | res.setHeader('Content-Type', 'text/plain') 35 | if (process.versions.node.indexOf('4') === 0) { 36 | res.write(new Buffer([0x61])) 37 | res.write(new Buffer([0x62])) 38 | res.write(new Buffer([0x63])) 39 | } else { 40 | res.write(Buffer.from('a')) 41 | res.write(Buffer.from('b')) 42 | res.write(Buffer.from('c')) 43 | } 44 | 45 | res.end() 46 | }) 47 | 48 | app.get('/api/testheaderblacklist', function(req, res) { 49 | app.requestsProcessed++ 50 | res.set('x-blacklisted', app.requestsProcessed) 51 | res.set('x-notblacklisted', app.requestsProcessed) 52 | 53 | res.json(movies) 54 | }) 55 | 56 | app.get('/api/testcachegroup', function(req, res) { 57 | app.requestsProcessed++ 58 | req.apicacheGroup = 'cachegroup' 59 | 60 | res.json(movies) 61 | }) 62 | 63 | app.get('/api/text', function(req, res) { 64 | app.requestsProcessed++ 65 | res.setHeader('Content-Type', 'text/plain') 66 | 67 | res.send('plaintext') 68 | }) 69 | 70 | app.get('/api/html', function(req, res) { 71 | app.requestsProcessed++ 72 | res.setHeader('Content-Type', 'text/html') 73 | 74 | res.send('') 75 | }) 76 | 77 | app.get('/api/missing', function(req, res) { 78 | app.requestsProcessed++ 79 | 80 | res.status(404) 81 | res.json({ success: false, message: 'Resource not found' }) 82 | }) 83 | 84 | app.get('/api/movies/:index', function(req, res) { 85 | app.requestsProcessed++ 86 | 87 | res.json(movies[index]) 88 | }) 89 | 90 | return app 91 | } 92 | -------------------------------------------------------------------------------- /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 | // EMBED UPSTREAM RESPONSE PARAM 9 | app.use(function(req, res, next) { 10 | res.id = 123 11 | next() 12 | }) 13 | 14 | app.use(function(req, res, next) { 15 | res.charSet('utf-8') 16 | next() 17 | }) 18 | 19 | // ENABLE APICACHE 20 | app.use(apicache.middleware(expiration, toggle)) 21 | app.apicache = apicache 22 | 23 | // ENABLE COMPRESSION 24 | var whichGzip = (restify.gzipResponse && restify.gzipResponse()) || restify.plugins.gzipResponse() 25 | app.use(whichGzip) 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) { 37 | return new MockAPI(expiration, config, toggle) 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /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/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( 54 | newDuration 55 | ) 56 | }) 57 | }) 58 | }) 59 | 60 | describe('.getDuration(stringOrNumber) {GETTER}', function() { 61 | var apicache = require('../src/apicache') 62 | 63 | it('is a function', function() { 64 | expect(typeof apicache.getDuration).to.equal('function') 65 | }) 66 | 67 | it('returns value unchanged if numeric', function() { 68 | expect(apicache.getDuration(77)).to.equal(77) 69 | }) 70 | 71 | it('returns default duration when uncertain', function() { 72 | apicache.options({ defaultDuration: 999 }) 73 | expect(apicache.getDuration(undefined)).to.equal(999) 74 | }) 75 | 76 | it('accepts singular or plural (e.g. "1 hour", "3 hours")', function() { 77 | expect(apicache.getDuration('3 seconds')).to.equal(3000) 78 | expect(apicache.getDuration('3 second')).to.equal(3000) 79 | }) 80 | 81 | it('accepts decimals (e.g. "1.5 hours")', function() { 82 | expect(apicache.getDuration('1.5 seconds')).to.equal(1500) 83 | }) 84 | 85 | describe('unit support', function() { 86 | it('numeric values as milliseconds', function() { 87 | expect(apicache.getDuration(43)).to.equal(43) 88 | }) 89 | it('milliseconds', function() { 90 | expect(apicache.getDuration('3 ms')).to.equal(3) 91 | }) 92 | it('seconds', function() { 93 | expect(apicache.getDuration('3 seconds')).to.equal(3000) 94 | }) 95 | it('minutes', function() { 96 | expect(apicache.getDuration('4 minutes')).to.equal(1000 * 60 * 4) 97 | }) 98 | it('hours', function() { 99 | expect(apicache.getDuration('2 hours')).to.equal(1000 * 60 * 60 * 2) 100 | }) 101 | it('days', function() { 102 | expect(apicache.getDuration('3 days')).to.equal(1000 * 60 * 60 * 24 * 3) 103 | }) 104 | it('weeks', function() { 105 | expect(apicache.getDuration('5 weeks')).to.equal(1000 * 60 * 60 * 24 * 7 * 5) 106 | }) 107 | it('months', function() { 108 | expect(apicache.getDuration('6 months')).to.equal(1000 * 60 * 60 * 24 * 30 * 6) 109 | }) 110 | }) 111 | }) 112 | 113 | describe('.getPerformance()', function() { 114 | var apicache = require('../src/apicache') 115 | 116 | it('is a function', function() { 117 | expect(typeof apicache.getPerformance).to.equal('function') 118 | }) 119 | 120 | it('returns an array', function() { 121 | expect(Array.isArray(apicache.getPerformance())).to.be.true 122 | }) 123 | 124 | it('returns a null hit rate if the api has not been called', function() { 125 | var api = require('./api/express') 126 | var app = api.create('10 seconds', { trackPerformance: true }) 127 | expect(app.apicache.getPerformance()[0]).to.deep.equal({ 128 | callCount: 0, 129 | hitCount: 0, 130 | missCount: 0, 131 | hitRate: null, 132 | hitRateLast100: null, 133 | hitRateLast1000: null, 134 | hitRateLast10000: null, 135 | hitRateLast100000: null, 136 | lastCacheHit: null, 137 | lastCacheMiss: null, 138 | }) 139 | }) 140 | 141 | it('returns a 0 hit rate if the api has been called once', function() { 142 | var api = require('./api/express') 143 | var app = api.create('10 seconds', { trackPerformance: true }) 144 | 145 | return request(app) 146 | .get('/api/movies') 147 | .then(function(res) { 148 | expect(app.apicache.getPerformance()[0]).to.deep.equal({ 149 | callCount: 1, 150 | hitCount: 0, 151 | missCount: 1, 152 | hitRate: 0, 153 | hitRateLast100: 0, 154 | hitRateLast1000: 0, 155 | hitRateLast10000: 0, 156 | hitRateLast100000: 0, 157 | lastCacheHit: null, 158 | lastCacheMiss: '/api/movies', 159 | }) 160 | }) 161 | }) 162 | 163 | it('returns a 0.5 hit rate if the api has been called twice', function() { 164 | var api = require('./api/express') 165 | var app = api.create('10 seconds', { trackPerformance: true }) 166 | var requests = [] 167 | for (var i = 0; i < 2; i++) { 168 | requests.push(request(app).get('/api/movies')) 169 | } 170 | return Promise.all(requests).then(function(res) { 171 | expect(app.apicache.getPerformance()[0]).to.deep.equal({ 172 | callCount: 2, 173 | hitCount: 1, 174 | missCount: 1, 175 | hitRate: 0.5, 176 | hitRateLast100: 0.5, 177 | hitRateLast1000: 0.5, 178 | hitRateLast10000: 0.5, 179 | hitRateLast100000: 0.5, 180 | lastCacheHit: '/api/movies', 181 | lastCacheMiss: '/api/movies', 182 | }) 183 | }) 184 | }) 185 | }) 186 | 187 | describe('.getIndex([groupName]) {GETTER}', function() { 188 | var apicache = require('../src/apicache') 189 | 190 | it('is a function', function() { 191 | expect(typeof apicache.getIndex).to.equal('function') 192 | }) 193 | 194 | it('returns an object', function() { 195 | expect(typeof apicache.getIndex()).to.equal('object') 196 | }) 197 | 198 | it('can clear indexed cache groups', function() { 199 | var api = require('./api/express') 200 | var app = api.create('10 seconds') 201 | 202 | return request(app) 203 | .get('/api/testcachegroup') 204 | .then(function(res) { 205 | expect(app.apicache.getIndex('cachegroup').length).to.equal(1) 206 | }) 207 | }) 208 | }) 209 | 210 | describe('.resetIndex() {SETTER}', function() { 211 | var apicache = require('../src/apicache') 212 | 213 | it('is a function', function() { 214 | expect(typeof apicache.resetIndex).to.equal('function') 215 | }) 216 | }) 217 | 218 | describe('.middleware {MIDDLEWARE}', function() { 219 | it('is a function', function() { 220 | var apicache = require('../src/apicache') 221 | expect(typeof apicache.middleware).to.equal('function') 222 | expect(apicache.middleware.length).to.equal(3) 223 | }) 224 | 225 | it('returns the middleware function', function() { 226 | var middleware = require('../src/apicache').middleware('10 seconds') 227 | expect(typeof middleware).to.equal('function') 228 | expect(middleware.length).to.equal(3) 229 | }) 230 | 231 | describe('options', function() { 232 | var apicache = require('../src/apicache').newInstance() 233 | 234 | it('uses global options if local ones not provided', function() { 235 | apicache.options({ 236 | appendKey: ['test'], 237 | }) 238 | var middleware1 = apicache.middleware('10 seconds') 239 | var middleware2 = apicache.middleware('20 seconds') 240 | expect(middleware1.options()).to.eql({ 241 | debug: false, 242 | defaultDuration: 3600000, 243 | enabled: true, 244 | appendKey: ['test'], 245 | jsonp: false, 246 | redisClient: false, 247 | headerBlacklist: [], 248 | statusCodes: { include: [], exclude: [] }, 249 | events: { expire: undefined }, 250 | headers: {}, 251 | trackPerformance: false, 252 | respectCacheControl: false, 253 | }) 254 | expect(middleware2.options()).to.eql({ 255 | debug: false, 256 | defaultDuration: 3600000, 257 | enabled: true, 258 | appendKey: ['test'], 259 | jsonp: false, 260 | redisClient: false, 261 | headerBlacklist: [], 262 | statusCodes: { include: [], exclude: [] }, 263 | events: { expire: undefined }, 264 | headers: {}, 265 | trackPerformance: false, 266 | respectCacheControl: false, 267 | }) 268 | }) 269 | 270 | it('uses local options if provided', function() { 271 | apicache.options({ 272 | appendKey: ['test'], 273 | }) 274 | var middleware1 = apicache.middleware('10 seconds', null, { 275 | debug: true, 276 | defaultDuration: 7200000, 277 | appendKey: ['bar'], 278 | statusCodes: { include: [], exclude: ['400'] }, 279 | events: { expire: undefined }, 280 | headers: { 281 | 'cache-control': 'no-cache', 282 | }, 283 | respectCacheControl: true, 284 | }) 285 | var middleware2 = apicache.middleware('20 seconds', null, { 286 | debug: false, 287 | defaultDuration: 1800000, 288 | appendKey: ['foo'], 289 | statusCodes: { include: [], exclude: ['200'] }, 290 | events: { expire: undefined }, 291 | }) 292 | expect(middleware1.options()).to.eql({ 293 | debug: true, 294 | defaultDuration: 7200000, 295 | enabled: true, 296 | appendKey: ['bar'], 297 | jsonp: false, 298 | redisClient: false, 299 | headerBlacklist: [], 300 | statusCodes: { include: [], exclude: ['400'] }, 301 | events: { expire: undefined }, 302 | headers: { 303 | 'cache-control': 'no-cache', 304 | }, 305 | trackPerformance: false, 306 | respectCacheControl: true, 307 | }) 308 | expect(middleware2.options()).to.eql({ 309 | debug: false, 310 | defaultDuration: 1800000, 311 | enabled: true, 312 | appendKey: ['foo'], 313 | jsonp: false, 314 | redisClient: false, 315 | headerBlacklist: [], 316 | statusCodes: { include: [], exclude: ['200'] }, 317 | events: { expire: undefined }, 318 | headers: {}, 319 | trackPerformance: false, 320 | respectCacheControl: false, 321 | }) 322 | }) 323 | 324 | it('updates options if global ones changed', function() { 325 | apicache.options({ 326 | debug: true, 327 | appendKey: ['test'], 328 | }) 329 | var middleware1 = apicache.middleware('10 seconds', null, { 330 | defaultDuration: 7200000, 331 | statusCodes: { include: [], exclude: ['400'] }, 332 | }) 333 | var middleware2 = apicache.middleware('20 seconds', null, { 334 | defaultDuration: 1800000, 335 | statusCodes: { include: [], exclude: ['200'] }, 336 | }) 337 | apicache.options({ 338 | debug: false, 339 | appendKey: ['foo'], 340 | }) 341 | expect(middleware1.options()).to.eql({ 342 | debug: false, 343 | defaultDuration: 7200000, 344 | enabled: true, 345 | appendKey: ['foo'], 346 | jsonp: false, 347 | redisClient: false, 348 | headerBlacklist: [], 349 | statusCodes: { include: [], exclude: ['400'] }, 350 | events: { expire: undefined }, 351 | headers: {}, 352 | trackPerformance: false, 353 | respectCacheControl: false, 354 | }) 355 | expect(middleware2.options()).to.eql({ 356 | debug: false, 357 | defaultDuration: 1800000, 358 | enabled: true, 359 | appendKey: ['foo'], 360 | jsonp: false, 361 | redisClient: false, 362 | headerBlacklist: [], 363 | statusCodes: { include: [], exclude: ['200'] }, 364 | events: { expire: undefined }, 365 | headers: {}, 366 | trackPerformance: false, 367 | respectCacheControl: false, 368 | }) 369 | }) 370 | 371 | it('updates options if local ones changed', function() { 372 | apicache.options({ 373 | debug: true, 374 | appendKey: ['test'], 375 | }) 376 | var middleware1 = apicache.middleware('10 seconds', null, { 377 | defaultDuration: 7200000, 378 | statusCodes: { include: [], exclude: ['400'] }, 379 | }) 380 | var middleware2 = apicache.middleware('20 seconds', null, { 381 | defaultDuration: 900000, 382 | statusCodes: { include: [], exclude: ['404'] }, 383 | }) 384 | middleware1.options({ 385 | debug: false, 386 | defaultDuration: 1800000, 387 | appendKey: ['foo'], 388 | headers: { 389 | 'cache-control': 'no-cache', 390 | }, 391 | }) 392 | middleware2.options({ 393 | defaultDuration: 450000, 394 | enabled: false, 395 | appendKey: ['foo'], 396 | }) 397 | expect(middleware1.options()).to.eql({ 398 | debug: false, 399 | defaultDuration: 1800000, 400 | enabled: true, 401 | appendKey: ['foo'], 402 | jsonp: false, 403 | redisClient: false, 404 | headerBlacklist: [], 405 | statusCodes: { include: [], exclude: [] }, 406 | events: { expire: undefined }, 407 | headers: { 408 | 'cache-control': 'no-cache', 409 | }, 410 | trackPerformance: false, 411 | respectCacheControl: false, 412 | }) 413 | expect(middleware2.options()).to.eql({ 414 | debug: true, 415 | defaultDuration: 450000, 416 | enabled: false, 417 | appendKey: ['foo'], 418 | jsonp: false, 419 | redisClient: false, 420 | headerBlacklist: [], 421 | statusCodes: { include: [], exclude: [] }, 422 | events: { expire: undefined }, 423 | headers: {}, 424 | trackPerformance: false, 425 | respectCacheControl: false, 426 | }) 427 | }) 428 | }) 429 | 430 | apis.forEach(function(api) { 431 | describe(api.name + ' tests', function() { 432 | var mockAPI = api.server 433 | 434 | it('does not interfere with initial request', function() { 435 | var app = mockAPI.create('10 seconds') 436 | 437 | return request(app) 438 | .get('/api/movies') 439 | .expect(200) 440 | .then(assertNumRequestsProcessed(app, 1)) 441 | }) 442 | 443 | it('properly returns a request while caching (first call)', function() { 444 | var app = mockAPI.create('10 seconds') 445 | 446 | return request(app) 447 | .get('/api/movies') 448 | .set('Cache-Control', 'no-cache') 449 | .expect(200, movies) 450 | .then(assertNumRequestsProcessed(app, 1)) 451 | }) 452 | 453 | it('returns max-age header on first request', function() { 454 | var app = mockAPI.create('10 seconds') 455 | 456 | return request(app) 457 | .get('/api/movies') 458 | .expect(200, movies) 459 | .expect('Cache-Control', /max-age/) 460 | }) 461 | 462 | it('returns properly decremented max-age header on cached response', function(done) { 463 | var app = mockAPI.create('10 seconds') 464 | 465 | request(app) 466 | .get('/api/movies') 467 | .expect(200, movies) 468 | .expect('Cache-Control', 'max-age=10') 469 | .then(function(res) { 470 | setTimeout(function() { 471 | request(app) 472 | .get('/api/movies') 473 | .expect(200, movies) 474 | .expect('Cache-Control', 'max-age=9') 475 | .then(function() { 476 | expect(app.requestsProcessed).to.equal(1) 477 | done() 478 | }) 479 | .catch(function(err) { 480 | done(err) 481 | }) 482 | }, 500) 483 | }) 484 | }) 485 | 486 | it('skips cache when using header "x-apicache-bypass"', function() { 487 | var app = mockAPI.create('10 seconds') 488 | 489 | return request(app) 490 | .get('/api/movies') 491 | .expect(200, movies) 492 | .then(assertNumRequestsProcessed(app, 1)) 493 | .then(function() { 494 | return request(app) 495 | .get('/api/movies') 496 | .set('x-apicache-bypass', true) 497 | .set('Accept', 'application/json') 498 | .expect('Content-Type', /json/) 499 | .expect(200, movies) 500 | .then(function(res) { 501 | expect(res.headers['apicache-store']).to.be.undefined 502 | expect(res.headers['apicache-version']).to.be.undefined 503 | expect(app.requestsProcessed).to.equal(2) 504 | }) 505 | }) 506 | }) 507 | 508 | it('skips cache with respectCacheControl', function() { 509 | var app = mockAPI.create('10 seconds', { respectCacheControl: true }) 510 | 511 | return request(app) 512 | .get('/api/movies') 513 | .expect(200, movies) 514 | .then(assertNumRequestsProcessed(app, 1)) 515 | .then(function() { 516 | return request(app) 517 | .get('/api/movies') 518 | .set('Cache-Control', 'no-cache') 519 | .set('Accept', 'application/json') 520 | .expect('Content-Type', /json/) 521 | .expect(200, movies) 522 | .then(function(res) { 523 | expect(res.headers['apicache-store']).to.be.undefined 524 | expect(res.headers['apicache-version']).to.be.undefined 525 | expect(app.requestsProcessed).to.equal(2) 526 | }) 527 | }) 528 | }) 529 | 530 | it('skips cache when using header "x-apicache-force-fetch (legacy)"', function() { 531 | var app = mockAPI.create('10 seconds') 532 | 533 | return request(app) 534 | .get('/api/movies') 535 | .expect(200, movies) 536 | .then(assertNumRequestsProcessed(app, 1)) 537 | .then(function() { 538 | return request(app) 539 | .get('/api/movies') 540 | .set('x-apicache-force-fetch', true) 541 | .set('Accept', 'application/json') 542 | .expect('Content-Type', /json/) 543 | .expect(200, movies) 544 | .then(function(res) { 545 | expect(res.headers['apicache-store']).to.be.undefined 546 | expect(res.headers['apicache-version']).to.be.undefined 547 | expect(app.requestsProcessed).to.equal(2) 548 | }) 549 | }) 550 | }) 551 | 552 | it('does not cache header in headerBlacklist', function() { 553 | var app = mockAPI.create('10 seconds', { headerBlacklist: ['x-blacklisted'] }) 554 | 555 | return request(app) 556 | .get('/api/testheaderblacklist') 557 | .expect(200, movies) 558 | .then(function(res) { 559 | expect(res.headers['x-blacklisted']).to.equal(res.headers['x-notblacklisted']) 560 | return request(app) 561 | .get('/api/testheaderblacklist') 562 | .set('Accept', 'application/json') 563 | .expect('Content-Type', /json/) 564 | .expect(200, movies) 565 | .then(function(res2) { 566 | expect(res2.headers['x-blacklisted']).to.not.equal(res2.headers['x-notblacklisted']) 567 | }) 568 | }) 569 | }) 570 | 571 | it('properly returns a cached JSON request', function() { 572 | var app = mockAPI.create('10 seconds') 573 | 574 | return request(app) 575 | .get('/api/movies') 576 | .expect(200, movies) 577 | .then(assertNumRequestsProcessed(app, 1)) 578 | .then(function() { 579 | return request(app) 580 | .get('/api/movies') 581 | .set('Accept', 'application/json') 582 | .expect('Content-Type', /json/) 583 | .expect(200, movies) 584 | .then(assertNumRequestsProcessed(app, 1)) 585 | }) 586 | }) 587 | 588 | it('properly uses appendKey params', function() { 589 | var app = mockAPI.create('10 seconds', { appendKey: ['method'] }) 590 | 591 | return request(app) 592 | .get('/api/movies') 593 | .expect(200, movies) 594 | .then(function() { 595 | expect(app.apicache.getIndex().all[0]).to.equal('/api/movies$$appendKey=GET') 596 | }) 597 | }) 598 | 599 | it('properly uses custom appendKey(req, res) function', function() { 600 | var appendKey = function(req, res) { 601 | return req.method + res.id 602 | } 603 | var app = mockAPI.create('10 seconds', { appendKey: appendKey }) 604 | 605 | return request(app) 606 | .get('/api/movies') 607 | .expect(200, movies) 608 | .then(function() { 609 | expect(app.apicache.getIndex().all[0]).to.equal('/api/movies$$appendKey=GET123') 610 | }) 611 | }) 612 | 613 | it('returns cached response from write+end', function() { 614 | var app = mockAPI.create('10 seconds') 615 | 616 | return request(app) 617 | .get('/api/writeandend') 618 | .expect(200, 'abc') 619 | .expect('Cache-Control', 'max-age=10') 620 | .then(assertNumRequestsProcessed(app, 1)) 621 | .then(function() { 622 | return request(app) 623 | .get('/api/writeandend') 624 | .expect(200, 'abc') 625 | .then(assertNumRequestsProcessed(app, 1)) 626 | }) 627 | }) 628 | 629 | it('returns cached response from write Buffer+end', function() { 630 | var app = mockAPI.create('10 seconds') 631 | 632 | return request(app) 633 | .get('/api/writebufferandend') 634 | .expect(200, 'abc') 635 | .expect('Cache-Control', 'max-age=10') 636 | .then(assertNumRequestsProcessed(app, 1)) 637 | .then(function() { 638 | return request(app) 639 | .get('/api/writebufferandend') 640 | .expect(200, 'abc') 641 | .then(assertNumRequestsProcessed(app, 1)) 642 | }) 643 | }) 644 | 645 | it('embeds store type and apicache version in cached responses', function() { 646 | var app = mockAPI.create('10 seconds') 647 | 648 | return request(app) 649 | .get('/api/movies') 650 | .expect(200, movies) 651 | .then(function(res) { 652 | expect(res.headers['apicache-store']).to.be.undefined 653 | expect(res.headers['apicache-version']).to.be.undefined 654 | expect(app.requestsProcessed).to.equal(1) 655 | }) 656 | .then(function() { 657 | return request(app) 658 | .get('/api/movies') 659 | .expect('apicache-store', 'memory') 660 | .expect('apicache-version', pkg.version) 661 | .expect(200, movies) 662 | .then(assertNumRequestsProcessed(app, 1)) 663 | }) 664 | }) 665 | 666 | it('does NOT store type and apicache version in cached responses when NODE_ENV === "production"', function() { 667 | var app = mockAPI.create('10 seconds') 668 | process.env.NODE_ENV = 'production' 669 | 670 | return request(app) 671 | .get('/api/movies') 672 | .expect(200, movies) 673 | .then(function(res) { 674 | expect(res.headers['apicache-store']).to.be.undefined 675 | expect(res.headers['apicache-version']).to.be.undefined 676 | expect(app.requestsProcessed).to.equal(1) 677 | }) 678 | .then(function() { 679 | return request(app) 680 | .get('/api/movies') 681 | .expect(200, movies) 682 | .then(function(res) { 683 | expect(res.headers['apicache-store']).to.be.undefined 684 | expect(res.headers['apicache-version']).to.be.undefined 685 | expect(app.requestsProcessed).to.equal(1) 686 | 687 | process.env.NODE_ENV = undefined 688 | }) 689 | }) 690 | }) 691 | 692 | it('embeds cache-control header', function() { 693 | var app = mockAPI.create('10 seconds') 694 | 695 | return request(app) 696 | .get('/api/movies') 697 | .expect('Cache-Control', 'max-age=10') 698 | .expect(200, movies) 699 | .then(function(res) { 700 | expect(res.headers['apicache-store']).to.be.undefined 701 | expect(res.headers['apicache-version']).to.be.undefined 702 | expect(app.requestsProcessed).to.equal(1) 703 | expect(res.headers['date']).to.exist 704 | }) 705 | .then(function() { 706 | return request(app) 707 | .get('/api/movies') 708 | .expect('apicache-store', 'memory') 709 | .expect('apicache-version', pkg.version) 710 | .expect(200, movies) 711 | .then(assertNumRequestsProcessed(app, 1)) 712 | }) 713 | }) 714 | 715 | it('allows cache-control header to be overwritten (e.g. "no-cache"', function() { 716 | var app = mockAPI.create('10 seconds', { headers: { 'cache-control': 'no-cache' } }) 717 | 718 | return request(app) 719 | .get('/api/movies') 720 | .expect('Cache-Control', 'no-cache') 721 | .expect(200, movies) 722 | .then(function(res) { 723 | expect(res.headers['apicache-store']).to.be.undefined 724 | expect(res.headers['apicache-version']).to.be.undefined 725 | expect(app.requestsProcessed).to.equal(1) 726 | expect(res.headers['date']).to.exist 727 | }) 728 | .then(function() { 729 | return request(app) 730 | .get('/api/movies') 731 | .expect('apicache-store', 'memory') 732 | .expect('apicache-version', pkg.version) 733 | .expect(200, movies) 734 | .then(assertNumRequestsProcessed(app, 1)) 735 | }) 736 | }) 737 | 738 | it('preserves etag header', function() { 739 | var app = mockAPI.create('10 seconds') 740 | 741 | return request(app) 742 | .get('/api/movies') 743 | .expect(200) 744 | .then(function(res) { 745 | var etag = res.headers['etag'] 746 | expect(etag).to.exist 747 | return etag 748 | }) 749 | .then(function(etag) { 750 | return request(app) 751 | .get('/api/movies') 752 | .expect(200) 753 | .expect('etag', etag) 754 | }) 755 | }) 756 | 757 | it('respects if-none-match header', function() { 758 | var app = mockAPI.create('10 seconds') 759 | 760 | return request(app) 761 | .get('/api/movies') 762 | .expect(200) 763 | .then(function(res) { 764 | return res.headers['etag'] 765 | }) 766 | .then(function(etag) { 767 | return request(app) 768 | .get('/api/movies') 769 | .set('if-none-match', etag) 770 | .expect(304) 771 | .expect('etag', etag) 772 | }) 773 | }) 774 | 775 | it('embeds returns content-type JSON from original response and cached response', function() { 776 | var app = mockAPI.create('10 seconds') 777 | 778 | return request(app) 779 | .get('/api/movies') 780 | .expect(200) 781 | .expect('Content-Type', 'application/json; charset=utf-8') 782 | .then(function() { 783 | return request(app) 784 | .get('/api/movies') 785 | .expect('Content-Type', 'application/json; charset=utf-8') 786 | }) 787 | }) 788 | 789 | it('does not cache a request when status code found in status code exclusions', function() { 790 | var app = mockAPI.create('2 seconds', { 791 | statusCodes: { exclude: [404] }, 792 | }) 793 | 794 | return request(app) 795 | .get('/api/missing') 796 | .expect(404) 797 | .then(function(res) { 798 | expect(res.headers['cache-control']).to.equal('no-cache, no-store, must-revalidate') 799 | expect(app.apicache.getIndex().all.length).to.equal(0) 800 | }) 801 | }) 802 | 803 | it('does not cache a request when status code not found in status code inclusions', function() { 804 | var app = mockAPI.create('2 seconds', { 805 | statusCodes: { include: [200] }, 806 | }) 807 | 808 | return request(app) 809 | .get('/api/missing') 810 | .expect(404) 811 | .then(function(res) { 812 | expect(res.headers['cache-control']).to.equal('no-cache, no-store, must-revalidate') 813 | expect(app.apicache.getIndex().all.length).to.equal(0) 814 | }) 815 | }) 816 | 817 | it('middlewareToggle does not block response on falsy middlewareToggle', function() { 818 | var hits = 0 819 | 820 | var onlyOnce = function(req, res) { 821 | return hits++ === 0 822 | } 823 | 824 | var app = mockAPI.create('2 seconds', {}, onlyOnce) 825 | 826 | return request(app) 827 | .get('/api/movies') 828 | .then(function(res) { 829 | return request(app) 830 | .get('/api/movies') 831 | .expect(200, movies) 832 | .then(function(res) { 833 | expect(res.headers['apicache-version']).to.be.undefined 834 | }) 835 | }) 836 | }) 837 | 838 | it('middlewareToggle works correctly to control statusCode caching (per example)', function() { 839 | var onlyStatusCode200 = function(req, res) { 840 | return res.statusCode === 200 841 | } 842 | 843 | var app = mockAPI.create('2 seconds', {}, onlyStatusCode200) 844 | 845 | return request(app) 846 | .get('/api/missing') 847 | .expect(404) 848 | .then(function(res) { 849 | expect(res.headers['cache-control']).to.equal('no-cache, no-store, must-revalidate') 850 | expect(app.apicache.getIndex().all.length).to.equal(0) 851 | }) 852 | }) 853 | 854 | it('removes a cache key after expiration', function(done) { 855 | var app = mockAPI.create(10) 856 | 857 | request(app) 858 | .get('/api/movies') 859 | .end(function(err, res) { 860 | expect(app.apicache.getIndex().all.length).to.equal(1) 861 | expect(app.apicache.getIndex().all).to.include('/api/movies') 862 | }) 863 | 864 | setTimeout(function() { 865 | expect(app.apicache.getIndex().all).to.have.length(0) 866 | done() 867 | }, 25) 868 | }) 869 | 870 | it('executes expiration callback from globalOptions.events.expire upon entry expiration', function(done) { 871 | var callbackResponse = undefined 872 | var cb = function(a, b) { 873 | callbackResponse = b 874 | } 875 | var app = mockAPI.create(10, { events: { expire: cb } }) 876 | 877 | request(app) 878 | .get('/api/movies') 879 | .end(function(err, res) { 880 | expect(app.apicache.getIndex().all.length).to.equal(1) 881 | expect(app.apicache.getIndex().all).to.include('/api/movies') 882 | }) 883 | 884 | setTimeout(function() { 885 | expect(app.apicache.getIndex().all).to.have.length(0) 886 | expect(callbackResponse).to.equal('/api/movies') 887 | done() 888 | }, 25) 889 | }) 890 | 891 | it('clearing cache cancels expiration callback', function(done) { 892 | var app = mockAPI.create(100) 893 | 894 | request(app) 895 | .get('/api/movies') 896 | .end(function(err, res) { 897 | expect(app.apicache.getIndex().all.length).to.equal(1) 898 | expect(app.apicache.clear('/api/movies').all.length).to.equal(0) 899 | }) 900 | 901 | setTimeout(function() { 902 | request(app) 903 | .get('/api/movies') 904 | .end(function(err, res) { 905 | expect(app.apicache.getIndex().all.length).to.equal(1) 906 | expect(app.apicache.getIndex().all).to.include('/api/movies') 907 | }) 908 | }, 50) 909 | 910 | setTimeout(function() { 911 | expect(app.apicache.getIndex().all.length).to.equal(1) 912 | expect(app.apicache.getIndex().all).to.include('/api/movies') 913 | done() 914 | }, 150) 915 | }) 916 | 917 | it('allows defaultDuration to be a parseable string (e.g. "1 week")', function(done) { 918 | var callbackResponse = undefined 919 | var cb = function(a, b) { 920 | callbackResponse = b 921 | } 922 | var app = mockAPI.create(null, { defaultDuration: '100ms', events: { expire: cb } }) 923 | 924 | request(app) 925 | .get('/api/movies') 926 | .end(function(err, res) { 927 | expect(app.apicache.getIndex().all.length).to.equal(1) 928 | expect(app.apicache.getIndex().all).to.include('/api/movies') 929 | }) 930 | 931 | setTimeout(function() { 932 | expect(app.apicache.getIndex().all).to.have.length(0) 933 | expect(callbackResponse).to.equal('/api/movies') 934 | done() 935 | }, 150) 936 | }) 937 | }) 938 | }) 939 | }) 940 | 941 | describe('Redis support', function() { 942 | function hgetallIsNull(db, key) { 943 | return new Promise(function(resolve, reject) { 944 | db.hgetall(key, function(err, reply) { 945 | if (err) { 946 | reject(err) 947 | } else { 948 | expect(reply).to.equal(null) 949 | db.flushdb() 950 | resolve() 951 | } 952 | }) 953 | }) 954 | } 955 | 956 | apis.forEach(function(api) { 957 | describe(api.name + ' tests', function() { 958 | var mockAPI = api.server 959 | 960 | it('properly caches a request', function() { 961 | var db = redis.createClient() 962 | var app = mockAPI.create('10 seconds', { redisClient: db }) 963 | 964 | return request(app) 965 | .get('/api/movies') 966 | .expect(200, movies) 967 | .then(function(res) { 968 | expect(res.headers['apicache-store']).to.be.undefined 969 | expect(res.headers['apicache-version']).to.be.undefined 970 | expect(app.requestsProcessed).to.equal(1) 971 | }) 972 | .then(function() { 973 | return request(app) 974 | .get('/api/movies') 975 | .expect(200, movies) 976 | .expect('apicache-store', 'redis') 977 | .expect('apicache-version', pkg.version) 978 | .then(assertNumRequestsProcessed(app, 1)) 979 | .then(function() { 980 | db.flushdb() 981 | }) 982 | }) 983 | }) 984 | 985 | it('can clear indexed cache groups', function() { 986 | var db = redis.createClient() 987 | var app = mockAPI.create('10 seconds', { redisClient: db }) 988 | 989 | return request(app) 990 | .get('/api/testcachegroup') 991 | .then(function(res) { 992 | expect(app.requestsProcessed).to.equal(1) 993 | expect(app.apicache.getIndex().all.length).to.equal(1) 994 | expect(app.apicache.getIndex().groups.cachegroup.length).to.equal(1) 995 | expect(Object.keys(app.apicache.clear('cachegroup').groups).length).to.equal(0) 996 | expect(app.apicache.getIndex().all.length).to.equal(0) 997 | return hgetallIsNull(db, '/api/testcachegroup') 998 | }) 999 | }) 1000 | 1001 | it('can clear indexed entries by url/key (non-group)', function() { 1002 | var db = redis.createClient() 1003 | var app = mockAPI.create('10 seconds', { redisClient: db }) 1004 | 1005 | return request(app) 1006 | .get('/api/movies') 1007 | .then(function(res) { 1008 | expect(app.requestsProcessed).to.equal(1) 1009 | expect(app.apicache.getIndex().all.length).to.equal(1) 1010 | expect(app.apicache.clear('/api/movies').all.length).to.equal(0) 1011 | return hgetallIsNull(db, '/api/movies') 1012 | }) 1013 | }) 1014 | 1015 | it('can clear all entries from index', function() { 1016 | var db = redis.createClient() 1017 | var app = mockAPI.create('10 seconds', { redisClient: db }) 1018 | 1019 | expect(app.apicache.getIndex().all.length).to.equal(0) 1020 | expect(app.apicache.clear().all.length).to.equal(0) 1021 | 1022 | return request(app) 1023 | .get('/api/movies') 1024 | .then(function(res) { 1025 | expect(app.requestsProcessed).to.equal(1) 1026 | expect(app.apicache.getIndex().all.length).to.equal(1) 1027 | expect(app.apicache.clear().all.length).to.equal(0) 1028 | return hgetallIsNull(db, '/api/movies') 1029 | }) 1030 | }) 1031 | 1032 | it('sends a response even if redis failure', function() { 1033 | var app = mockAPI.create('10 seconds', { redisClient: {} }) 1034 | 1035 | return request(app) 1036 | .get('/api/movies') 1037 | .expect(200, movies) 1038 | }) 1039 | }) 1040 | }) 1041 | }) 1042 | 1043 | describe('.clear(key?) {SETTER}', function() { 1044 | it('is a function', function() { 1045 | var apicache = require('../src/apicache') 1046 | expect(typeof apicache.clear).to.equal('function') 1047 | }) 1048 | 1049 | apis.forEach(function(api) { 1050 | describe(api.name + ' tests', function() { 1051 | var mockAPI = api.server 1052 | 1053 | it('works when called with group key', function() { 1054 | var app = mockAPI.create('10 seconds') 1055 | 1056 | return request(app) 1057 | .get('/api/testcachegroup') 1058 | .then(function(res) { 1059 | expect(app.requestsProcessed).to.equal(1) 1060 | expect(app.apicache.getIndex().all.length).to.equal(1) 1061 | expect(app.apicache.getIndex().groups.cachegroup.length).to.equal(1) 1062 | expect(Object.keys(app.apicache.clear('cachegroup').groups).length).to.equal(0) 1063 | expect(app.apicache.getIndex().all.length).to.equal(0) 1064 | }) 1065 | }) 1066 | 1067 | it('works when called with specific endpoint (non-group) key', function() { 1068 | var app = mockAPI.create('10 seconds') 1069 | 1070 | return request(app) 1071 | .get('/api/movies') 1072 | .then(function(res) { 1073 | expect(app.requestsProcessed).to.equal(1) 1074 | expect(app.apicache.getIndex().all.length).to.equal(1) 1075 | expect(app.apicache.clear('/api/movies').all.length).to.equal(0) 1076 | }) 1077 | }) 1078 | 1079 | it('clears empty group after removing last specific endpoint', function() { 1080 | var app = mockAPI.create('10 seconds') 1081 | 1082 | return request(app) 1083 | .get('/api/testcachegroup') 1084 | .then(function(res) { 1085 | expect(app.requestsProcessed).to.equal(1) 1086 | expect(app.apicache.getIndex().all.length).to.equal(1) 1087 | expect(app.apicache.getIndex().groups.cachegroup.length).to.equal(1) 1088 | expect(Object.keys(app.apicache.clear('/api/testcachegroup').groups).length).to.equal(0) 1089 | expect(app.apicache.getIndex().all.length).to.equal(0) 1090 | }) 1091 | }) 1092 | 1093 | it('works when called with no key', function() { 1094 | var app = mockAPI.create('10 seconds') 1095 | 1096 | expect(app.apicache.getIndex().all.length).to.equal(0) 1097 | expect(app.apicache.clear().all.length).to.equal(0) 1098 | return request(app) 1099 | .get('/api/movies') 1100 | .then(function(res) { 1101 | expect(app.requestsProcessed).to.equal(1) 1102 | expect(app.apicache.getIndex().all.length).to.equal(1) 1103 | expect(app.apicache.clear().all.length).to.equal(0) 1104 | }) 1105 | }) 1106 | }) 1107 | }) 1108 | }) 1109 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------