From 687e16ae5ff011db454a99b54fcbbe2b2fe8efab Mon Sep 17 00:00:00 2001 From: Hector Virgen Date: Wed, 30 Sep 2015 13:03:24 -0700 Subject: [PATCH] Adds basic support for caching GET requests `GET` requests that contain the properties `$id` and `$expires` will automatically be cached for the given duration. Future `GET` requests will return the cached value instead of making an HTTP request. --- TODO.md | 2 +- demo/index.js | 6 +- demo/public/angular.html | 36 +++- demo/public/monocle-client-angular.js | 281 ++++++++++++++++++++++++-- lib/cache/memory.js | 18 +- lib/cache/store.js | 23 +++ lib/http_adapter/node.js | 2 +- lib/monocle.js | 60 +++++- lib/resource/resource.js | 27 +++ lib/wrappers/angular.js | 13 +- monocle-client-angular-min.js | 2 +- monocle-client-angular.js | 281 ++++++++++++++++++++++++-- test/test_runner.js | 3 + test/unit/lib/cache/memory_test.js | 17 ++ test/unit/lib/cache/store_test.js | 20 ++ test/unit/lib/monocle_test.js | 48 +++++ 16 files changed, 784 insertions(+), 55 deletions(-) create mode 100644 lib/cache/store.js create mode 100644 lib/resource/resource.js create mode 100644 test/unit/lib/cache/store_test.js diff --git a/TODO.md b/TODO.md index 23a2ea1..738135a 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,7 @@ Core ---- - [x] Support "props" options - [ ] Support "pluck" option -- [ ] Add caching layer +- [x] Add caching layer - [ ] Auto-batch requests Angular Adapter diff --git a/demo/index.js b/demo/index.js index 82c509c..e7b7a5b 100644 --- a/demo/index.js +++ b/demo/index.js @@ -34,15 +34,15 @@ api.route('/users/:userId', { var userId = req.getParam('userId'); if (userId > 0 && userId < 100) { // within range of acceptible user ids - return resolve({ + return resolve(new Monocle.Resource('/users/' + userId, { userId: userId, displayName: 'FPO Display Name ' + userId, age: 27 - }); + }, 60000)); } reject('Invalid user id'); - }, 200); + }, 500); }); } }); diff --git a/demo/public/angular.html b/demo/public/angular.html index d443039..063bb1d 100644 --- a/demo/public/angular.html +++ b/demo/public/angular.html @@ -90,13 +90,25 @@ + +
+

Error

+
{{ demo.error | json:2 }}
+
+
-

User

+

User

{{ demo.user | json:2 }}
-
-

Error

-
{{ demo.error | json:2 }}
+ +
+

Cache

+
    +
  • +

    {{ key }}

    +
    {{ cache | json:2 }}
    +
  • +
@@ -134,26 +146,36 @@

Error

.filter(function(prop) { return this.properties[prop]; }.bind(this)); + return monocle.get('/users/' + id, { props: props - }); + }) + .finally(function() { + // Expose the cached items for demo purposes + this.cached = monocle.getCache().getAll(); + }.bind(this)); }; }); app.controller('DemoCtrl', function(Users, $scope) { this.Users = Users; this.userId = 1; + this.fetch = function() { this.error = null; this.fetching = true; - Users.get(this.userId).then(function(user) { + + Users.get(this.userId) + .then(function(user) { this.user = user; - }.bind(this)).catch(function(error) { + }.bind(this)) + .catch(function(error) { this.user = null; this.error = error; }.bind(this)) .finally(function() { this.fetching = false; + this.cached = Users.cached; }.bind(this)); }; }); diff --git a/demo/public/monocle-client-angular.js b/demo/public/monocle-client-angular.js index 04e4e83..f4ea8d9 100644 --- a/demo/public/monocle-client-angular.js +++ b/demo/public/monocle-client-angular.js @@ -45,19 +45,23 @@ /***/ function(module, exports, __webpack_require__) { var monocle = __webpack_require__(1); - var wrapper = __webpack_require__(2); + var wrapper = __webpack_require__(4); wrapper(angular, monocle); /***/ }, /* 1 */ -/***/ function(module, exports) { +/***/ function(module, exports, __webpack_require__) { 'use strict'; - var Monocle = function(http) { + var Store = __webpack_require__(2); + var MemoryBackend = __webpack_require__(3); + + function Monocle(http) { this._http = http; this._base = '/'; + this._cache = new Store(new MemoryBackend('monocle', { capacity: 5 })); }; Monocle.prototype.setBase = function(base) { @@ -65,24 +69,62 @@ return this; }; + Monocle.prototype.getCache = function() { + return this._cache; + }; + ['get', 'post', 'put', 'patch', 'delete', 'options'].forEach(function(method) { Monocle.prototype[method] = function(path, options) { - var fullPath = (this._base + path).replace(/\/{2,}/g, '/'); - var query = {}; + switch (method) { + // Check cache if attempting to get resource + case 'get': + var cached = this._cache.get(path); + if (cached) { + return Promise.resolve(cached); + } + break; + + // Remove from cache when resource is being updated or removed + case 'post': + case 'put': + case 'delete': + case 'patch': + this._cache.remove(path); + break; + } - if (options && options.props) { - query.props = options.props.join(','); + var fullPath = buildFullPath(this._base, path); + var query = buildQuery(options); + + if (query) fullPath += '?' + query; + + return this._http.request(method.toUpperCase(), fullPath, options) + .then(cacheResource.bind(this, method)); + }; + + var cacheResource = function(method, resource) { + if ('get' === method && resource.$id && resource.$expires) { + this._cache.put(resource.$id, resource, resource.$expires); } + return resource; + }; + + var buildFullPath = function(base, path) { + return (base + path).replace(/\/{2,}/g, '/'); + }; + + function buildQuery(options) { + var query = {}; + + if (options && Array.isArray(options.props)) query.props = options.props.join(','); var queryStringParts = []; + for (var i in query) { queryStringParts.push(encodeURIComponent(i) + '=' + encodeURIComponent(query[i])); } - if (queryStringParts.length) { - fullPath += '?' + queryStringParts.join('&'); - } - return this._http.request(method.toUpperCase(), fullPath, options); + return queryStringParts.join('&'); }; }); @@ -91,12 +133,212 @@ /***/ }, /* 2 */ +/***/ function(module, exports) { + + 'use strict'; + + function Store(backend) { + this._backend = backend; + }; + + Store.prototype.get = function(cacheKey) { + return this._backend.get(cacheKey); + }; + + Store.prototype.put = function(cacheKey, value, ttl) { + return this._backend.put(cacheKey, value, ttl); + }; + + Store.prototype.remove = function(cacheKey) { + return this._backend.remove(cacheKey); + }; + + Store.prototype.getAll = function() { + return this._backend.getAll(); + }; + + module.exports = Store; + + +/***/ }, +/* 3 */ +/***/ function(module, exports) { + + 'use strict'; + + function MemoryCache(cacheId, options) { + this._cacheId = cacheId; + this._cache = {}; + this._head = null; + this._tail = null; + this._options = options || {}; + if (!this._options.hasOwnProperty('capacity')) { + this._options.capacity = false; + } + }; + + MemoryCache.prototype.get = function(cacheKey) { + if (!this._cache.hasOwnProperty(cacheKey)) { + return undefined; + } + + var entry = this._cache[cacheKey]; + + if (entry.expiration) { + var now = new Date(); + if (now.getTime() > entry.expiration.getTime()) { + this.remove(cacheKey); + return undefined; + } + } + + moveToHead.call(this, entry); + + return entry.value; + }; + + MemoryCache.prototype.getAll = function() { + // return Object.keys(this._cache).map(function(key) { + // return this._cache[key]; + // }.bind(this)); + + var all = {}; + for (var i in this._cache) { + var cached = this._cache[i]; + all[i] = { + value: cached.value, + expiration: cached.expiration + }; + } + return all; + }; + + MemoryCache.prototype.put = function(cacheKey, value, ttl, tags) { + if (!Array.isArray(tags)) { + tags = toString.call(tags) == '[object String]' ? [tags] : []; + } + + var entry = { + key: cacheKey, + value: value, + expiration: false, + tags: tags + }; + + ttl = parseInt(ttl, 10); + + if (isFinite(ttl) && ttl > 0) { + entry.expiration = new Date(new Date().getTime() + ttl); + } + + moveToHead.call(this, entry); + + this._cache[cacheKey] = entry; + + var size = Object.keys(this._cache).length; + if (this._options.capacity > 0 && size > this._options.capacity) { + clearExpired.call(this); + + if (Object.keys(this._cache).length > this._options.capacity) { + purgeTail.call(this); + } + } + }; + + var moveToHead = function(entry) { + if (this._head) { + entry.next = this._head; + this._head.previous = entry; + } else { + entry.next = null; + } + + // Head has no previous + entry.previous = null; + + this._head = entry; + + if (!this._tail) { + this._tail = entry; + } + }; + + var purgeTail = function() { + if (this._head === this._tail) { + // Do not purge + return; + } + + var tail = this._tail; + var previous = tail.previous; + previous.next = null; + this._tail = previous; + delete this._cache[tail.key]; + }; + + var clearExpired = function() { + var now = new Date(); + Object.keys(this._cache).forEach(function(cacheKey) { + var entry = this._cache[cacheKey]; + if (entry.expiration) { + if (now.getTime() > entry.expiration.getTime()) { + this.remove(cacheKey); + } + } + }.bind(this)); + }; + + MemoryCache.prototype.remove = function(cacheKey) { + if (this._cache.hasOwnProperty(cacheKey)) { + var entry = this._cache[cacheKey]; + + // Update the doubly-linked list pointers + var previous = entry.previous; + var next = entry.next; + + if (previous) { + previous.next = next; + } + + if (next) { + next.previous = previous; + } + + if (this._tail === entry) { + this._tail = previous; + } + + delete this._cache[cacheKey]; + } + }; + + MemoryCache.prototype.removeAll = function() { + this._cache = {}; + this._head = null; + this._tail = null; + }; + + MemoryCache.prototype.removeMatchingTag = function(tag) { + // TODO: Use a faster lookup, perhaps a map? + Object.keys(this._cache).forEach(function(cacheKey) { + var entry = this._cache[cacheKey]; + if (-1 !== entry.tags.indexOf(tag)) { + this.remove(cacheKey); + } + }.bind(this)); + }; + + module.exports = MemoryCache; + + +/***/ }, +/* 4 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; - module.exports = function(angular, Monocle) { - var AngularAdapter = __webpack_require__(3); + function angularWrapper(angular, Monocle) { + var AngularAdapter = __webpack_require__(5); // ## Module: monocle // Registers the module `monocle` with Angular, // allowing Angular apps to declare this module as a dependency. @@ -126,9 +368,16 @@ angularAdapter.setTimeout(this._timeout); angularAdapter.setHeaders(this._headers); - var monocle = new Monocle(angularAdapter); + var monocle = new Monocle(angularAdapter, $q); monocle.setBase(this._base); + // Wrap all promises in Angular promises + ['get', 'post', 'put', 'patch', 'delete', 'options'].forEach(function(method) { + monocle[method] = function(path, options) { + return $q.when(Monocle.prototype[method].call(monocle, path, options)); + }; + }); + return monocle; }; @@ -136,9 +385,11 @@ }); }; + module.exports = angularWrapper; + /***/ }, -/* 3 */ +/* 5 */ /***/ function(module, exports) { 'use strict'; diff --git a/lib/cache/memory.js b/lib/cache/memory.js index 0a4cafd..afe4d6a 100644 --- a/lib/cache/memory.js +++ b/lib/cache/memory.js @@ -1,6 +1,6 @@ 'use strict'; -var MemoryCache = function(cacheId, options) { +function MemoryCache(cacheId, options) { this._cacheId = cacheId; this._cache = {}; this._head = null; @@ -31,6 +31,22 @@ MemoryCache.prototype.get = function(cacheKey) { return entry.value; }; +MemoryCache.prototype.getAll = function() { + // return Object.keys(this._cache).map(function(key) { + // return this._cache[key]; + // }.bind(this)); + + var all = {}; + for (var i in this._cache) { + var cached = this._cache[i]; + all[i] = { + value: cached.value, + expiration: cached.expiration + }; + } + return all; +}; + MemoryCache.prototype.put = function(cacheKey, value, ttl, tags) { if (!Array.isArray(tags)) { tags = toString.call(tags) == '[object String]' ? [tags] : []; diff --git a/lib/cache/store.js b/lib/cache/store.js new file mode 100644 index 0000000..9456186 --- /dev/null +++ b/lib/cache/store.js @@ -0,0 +1,23 @@ +'use strict'; + +function Store(backend) { + this._backend = backend; +}; + +Store.prototype.get = function(cacheKey) { + return this._backend.get(cacheKey); +}; + +Store.prototype.put = function(cacheKey, value, ttl) { + return this._backend.put(cacheKey, value, ttl); +}; + +Store.prototype.remove = function(cacheKey) { + return this._backend.remove(cacheKey); +}; + +Store.prototype.getAll = function() { + return this._backend.getAll(); +}; + +module.exports = Store; diff --git a/lib/http_adapter/node.js b/lib/http_adapter/node.js index 2d4d0d1..9316be5 100644 --- a/lib/http_adapter/node.js +++ b/lib/http_adapter/node.js @@ -3,7 +3,7 @@ var Promise = require('bluebird'); var DEFAULT_TIMEOUT = 30000; -var NodeAdapter = function(request) { +function NodeAdapter(request) { this._request = request || require('request'); Promise.promisifyAll(this._request); this._timeout = DEFAULT_TIMEOUT; diff --git a/lib/monocle.js b/lib/monocle.js index d515eb3..2865394 100644 --- a/lib/monocle.js +++ b/lib/monocle.js @@ -1,8 +1,12 @@ 'use strict'; -var Monocle = function(http) { +var Store = require('./cache/store'); +var MemoryBackend = require('./cache/memory'); + +function Monocle(http) { this._http = http; this._base = '/'; + this._cache = new Store(new MemoryBackend('monocle', { capacity: 100 })); }; Monocle.prototype.setBase = function(base) { @@ -10,24 +14,62 @@ Monocle.prototype.setBase = function(base) { return this; }; +Monocle.prototype.getCache = function() { + return this._cache; +}; + ['get', 'post', 'put', 'patch', 'delete', 'options'].forEach(function(method) { Monocle.prototype[method] = function(path, options) { - var fullPath = (this._base + path).replace(/\/{2,}/g, '/'); - var query = {}; + switch (method) { + // Check cache if attempting to get resource + case 'get': + var cached = this._cache.get(path); + if (cached) { + return Promise.resolve(cached); + } + break; + + // Remove from cache when resource is being updated or removed + case 'post': + case 'put': + case 'delete': + case 'patch': + this._cache.remove(path); + break; + } + + var fullPath = buildFullPath(this._base, path); + var query = buildQuery(options); + + if (query) fullPath += '?' + query; + + return this._http.request(method.toUpperCase(), fullPath, options) + .then(cacheResource.bind(this, method)); + }; - if (options && options.props) { - query.props = options.props.join(','); + var cacheResource = function(method, resource) { + if ('get' === method && resource.$id && resource.$expires) { + this._cache.put(resource.$id, resource, resource.$expires); } + return resource; + }; + + var buildFullPath = function(base, path) { + return (base + path).replace(/\/{2,}/g, '/'); + }; + + function buildQuery(options) { + var query = {}; + + if (options && Array.isArray(options.props)) query.props = options.props.join(','); var queryStringParts = []; + for (var i in query) { queryStringParts.push(encodeURIComponent(i) + '=' + encodeURIComponent(query[i])); } - if (queryStringParts.length) { - fullPath += '?' + queryStringParts.join('&'); - } - return this._http.request(method.toUpperCase(), fullPath, options); + return queryStringParts.join('&'); }; }); diff --git a/lib/resource/resource.js b/lib/resource/resource.js new file mode 100644 index 0000000..0798171 --- /dev/null +++ b/lib/resource/resource.js @@ -0,0 +1,27 @@ +'use strict'; + +function Resource(resourceId, data) { + this._resourceId = resourceId; + this.update(data); +} + +Resource.prototype.getResourceId = function() { + return this._resourceId; +}; + +Resource.prototype.setResourceId = function(resourceId) { + this._resourceId = resourceId; +}; + +Resource.prototype.update = function(data) { + for (var i in data) { + // skip "private" properties + if ('_' === i[0]) { + continue; + } + + this[i] = data[i]; + } +}; + +module.exports = Resource; diff --git a/lib/wrappers/angular.js b/lib/wrappers/angular.js index be64ccb..03f8512 100644 --- a/lib/wrappers/angular.js +++ b/lib/wrappers/angular.js @@ -1,6 +1,6 @@ 'use strict'; -module.exports = function(angular, Monocle) { +function angularWrapper(angular, Monocle) { var AngularAdapter = require('../http_adapter/angular'); // ## Module: monocle // Registers the module `monocle` with Angular, @@ -31,12 +31,21 @@ module.exports = function(angular, Monocle) { angularAdapter.setTimeout(this._timeout); angularAdapter.setHeaders(this._headers); - var monocle = new Monocle(angularAdapter); + var monocle = new Monocle(angularAdapter, $q); monocle.setBase(this._base); + // Wrap all promises in Angular promises + ['get', 'post', 'put', 'patch', 'delete', 'options'].forEach(function(method) { + monocle[method] = function(path, options) { + return $q.when(Monocle.prototype[method].call(monocle, path, options)); + }; + }); + return monocle; }; this.$get.$provide = ['$http', '$q', '$window']; }); }; + +module.exports = angularWrapper; diff --git a/monocle-client-angular-min.js b/monocle-client-angular-min.js index 401cc20..4fa7d82 100644 --- a/monocle-client-angular-min.js +++ b/monocle-client-angular-min.js @@ -1 +1 @@ -!function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)}([function(a,b,c){var d=c(1),e=c(2);e(angular,d)},function(a,b){"use strict";var c=function(a){this._http=a,this._base="/"};c.prototype.setBase=function(a){return this._base=a,this},["get","post","put","patch","delete","options"].forEach(function(a){c.prototype[a]=function(b,c){var d=(this._base+b).replace(/\/{2,}/g,"/"),e={};c&&c.props&&(e.props=c.props.join(","));var f=[];for(var g in e)f.push(encodeURIComponent(g)+"="+encodeURIComponent(e[g]));return f.length&&(d+="?"+f.join("&")),this._http.request(a.toUpperCase(),d,c)}}),a.exports=c},function(a,b,c){"use strict";a.exports=function(a,b){var d=c(3),e=a.module("monocle",[]);e.provider("monocle",function(){this._base="/",this._timeout=3e4,this._headers={},this.setBase=function(a){this._base=a},this.setTimeout=function(a){this._timeout=parseInt(a,10)||3e4},this.setHeader=function(a,b){this._headers[a]=b},this.$get=function(a,c,e){var f=new d(a,c,e);f.setTimeout(this._timeout),f.setHeaders(this._headers);var g=new b(f);return g.setBase(this._base),g},this.$get.$provide=["$http","$q","$window"]})}},function(a,b){"use strict";function c(a,b,c){this._$http=a,this._$q=b,this._$window=c,this._timeout=3e4,this._headers={"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8","X-Requested-With":"XMLHttpRequest"}}c.prototype.setTimeout=function(a){return this._timeout=parseInt(a,10)||3e4,this},c.prototype.setHeader=function(a,b){this._headers[a]=b},c.prototype.setHeaders=function(a){for(var b in a)a.hasOwnProperty(b)&&this.setHeader(b,a[b])},c.prototype.request=function(a,b,c){var d=[],e=[];for(var f in this._headers)this._headers.hasOwnProperty(f)&&("function"!=typeof this._headers[f]?(d.push(this._headers[f]),e.push(f)):(d.push(this._headers[f]()),e.push(f)));return this._$q.all(d).then(function(c){for(var d={},f=0,g=c.length;g>f;f++)d[e[f]]=c[f];return this._$http({method:a.toUpperCase(),url:b,timeout:this._timeout,headers:d})["catch"](function(a){return this._$q.reject(a.data)}.bind(this)).then(function(a){return a.data})}.bind(this))},a.exports=c}]); \ No newline at end of file +!function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)}([function(a,b,c){var d=c(1),e=c(4);e(angular,d)},function(a,b,c){"use strict";function d(a){this._http=a,this._base="/",this._cache=new e(new f("monocle",{capacity:100}))}var e=c(2),f=c(3);d.prototype.setBase=function(a){return this._base=a,this},d.prototype.getCache=function(){return this._cache},["get","post","put","patch","delete","options"].forEach(function(a){function b(a){var b={};a&&Array.isArray(a.props)&&(b.props=a.props.join(","));var c=[];for(var d in b)c.push(encodeURIComponent(d)+"="+encodeURIComponent(b[d]));return c.join("&")}d.prototype[a]=function(d,f){switch(a){case"get":var g=this._cache.get(d);if(g)return Promise.resolve(g);break;case"post":case"put":case"delete":case"patch":this._cache.remove(d)}var h=e(this._base,d),i=b(f);return i&&(h+="?"+i),this._http.request(a.toUpperCase(),h,f).then(c.bind(this,a))};var c=function(a,b){return"get"===a&&b.$id&&b.$expires&&this._cache.put(b.$id,b,b.$expires),b},e=function(a,b){return(a+b).replace(/\/{2,}/g,"/")}}),a.exports=d},function(a,b){"use strict";function c(a){this._backend=a}c.prototype.get=function(a){return this._backend.get(a)},c.prototype.put=function(a,b,c){return this._backend.put(a,b,c)},c.prototype.remove=function(a){return this._backend.remove(a)},c.prototype.getAll=function(){return this._backend.getAll()},a.exports=c},function(a,b){"use strict";function c(a,b){this._cacheId=a,this._cache={},this._head=null,this._tail=null,this._options=b||{},this._options.hasOwnProperty("capacity")||(this._options.capacity=!1)}c.prototype.get=function(a){if(!this._cache.hasOwnProperty(a))return void 0;var b=this._cache[a];if(b.expiration){var c=new Date;if(c.getTime()>b.expiration.getTime())return void this.remove(a)}return d.call(this,b),b.value},c.prototype.getAll=function(){var a={};for(var b in this._cache){var c=this._cache[b];a[b]={value:c.value,expiration:c.expiration}}return a},c.prototype.put=function(a,b,c,g){Array.isArray(g)||(g="[object String]"==toString.call(g)?[g]:[]);var h={key:a,value:b,expiration:!1,tags:g};c=parseInt(c,10),isFinite(c)&&c>0&&(h.expiration=new Date((new Date).getTime()+c)),d.call(this,h),this._cache[a]=h;var i=Object.keys(this._cache).length;this._options.capacity>0&&i>this._options.capacity&&(f.call(this),Object.keys(this._cache).length>this._options.capacity&&e.call(this))};var d=function(a){this._head?(a.next=this._head,this._head.previous=a):a.next=null,a.previous=null,this._head=a,this._tail||(this._tail=a)},e=function(){if(this._head!==this._tail){var a=this._tail,b=a.previous;b.next=null,this._tail=b,delete this._cache[a.key]}},f=function(){var a=new Date;Object.keys(this._cache).forEach(function(b){var c=this._cache[b];c.expiration&&a.getTime()>c.expiration.getTime()&&this.remove(b)}.bind(this))};c.prototype.remove=function(a){if(this._cache.hasOwnProperty(a)){var b=this._cache[a],c=b.previous,d=b.next;c&&(c.next=d),d&&(d.previous=c),this._tail===b&&(this._tail=c),delete this._cache[a]}},c.prototype.removeAll=function(){this._cache={},this._head=null,this._tail=null},c.prototype.removeMatchingTag=function(a){Object.keys(this._cache).forEach(function(b){var c=this._cache[b];-1!==c.tags.indexOf(a)&&this.remove(b)}.bind(this))},a.exports=c},function(a,b,c){"use strict";function d(a,b){var d=c(5),e=a.module("monocle",[]);e.provider("monocle",function(){this._base="/",this._timeout=3e4,this._headers={},this.setBase=function(a){this._base=a},this.setTimeout=function(a){this._timeout=parseInt(a,10)||3e4},this.setHeader=function(a,b){this._headers[a]=b},this.$get=function(a,c,e){var f=new d(a,c,e);f.setTimeout(this._timeout),f.setHeaders(this._headers);var g=new b(f,c);return g.setBase(this._base),["get","post","put","patch","delete","options"].forEach(function(a){g[a]=function(d,e){return c.when(b.prototype[a].call(g,d,e))}}),g},this.$get.$provide=["$http","$q","$window"]})}a.exports=d},function(a,b){"use strict";function c(a,b,c){this._$http=a,this._$q=b,this._$window=c,this._timeout=3e4,this._headers={"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8","X-Requested-With":"XMLHttpRequest"}}c.prototype.setTimeout=function(a){return this._timeout=parseInt(a,10)||3e4,this},c.prototype.setHeader=function(a,b){this._headers[a]=b},c.prototype.setHeaders=function(a){for(var b in a)a.hasOwnProperty(b)&&this.setHeader(b,a[b])},c.prototype.request=function(a,b,c){var d=[],e=[];for(var f in this._headers)this._headers.hasOwnProperty(f)&&("function"!=typeof this._headers[f]?(d.push(this._headers[f]),e.push(f)):(d.push(this._headers[f]()),e.push(f)));return this._$q.all(d).then(function(c){for(var d={},f=0,g=c.length;g>f;f++)d[e[f]]=c[f];return this._$http({method:a.toUpperCase(),url:b,timeout:this._timeout,headers:d})["catch"](function(a){return this._$q.reject(a.data)}.bind(this)).then(function(a){return a.data})}.bind(this))},a.exports=c}]); \ No newline at end of file diff --git a/monocle-client-angular.js b/monocle-client-angular.js index 04e4e83..c974765 100644 --- a/monocle-client-angular.js +++ b/monocle-client-angular.js @@ -45,19 +45,23 @@ /***/ function(module, exports, __webpack_require__) { var monocle = __webpack_require__(1); - var wrapper = __webpack_require__(2); + var wrapper = __webpack_require__(4); wrapper(angular, monocle); /***/ }, /* 1 */ -/***/ function(module, exports) { +/***/ function(module, exports, __webpack_require__) { 'use strict'; - var Monocle = function(http) { + var Store = __webpack_require__(2); + var MemoryBackend = __webpack_require__(3); + + function Monocle(http) { this._http = http; this._base = '/'; + this._cache = new Store(new MemoryBackend('monocle', { capacity: 100 })); }; Monocle.prototype.setBase = function(base) { @@ -65,24 +69,62 @@ return this; }; + Monocle.prototype.getCache = function() { + return this._cache; + }; + ['get', 'post', 'put', 'patch', 'delete', 'options'].forEach(function(method) { Monocle.prototype[method] = function(path, options) { - var fullPath = (this._base + path).replace(/\/{2,}/g, '/'); - var query = {}; + switch (method) { + // Check cache if attempting to get resource + case 'get': + var cached = this._cache.get(path); + if (cached) { + return Promise.resolve(cached); + } + break; + + // Remove from cache when resource is being updated or removed + case 'post': + case 'put': + case 'delete': + case 'patch': + this._cache.remove(path); + break; + } - if (options && options.props) { - query.props = options.props.join(','); + var fullPath = buildFullPath(this._base, path); + var query = buildQuery(options); + + if (query) fullPath += '?' + query; + + return this._http.request(method.toUpperCase(), fullPath, options) + .then(cacheResource.bind(this, method)); + }; + + var cacheResource = function(method, resource) { + if ('get' === method && resource.$id && resource.$expires) { + this._cache.put(resource.$id, resource, resource.$expires); } + return resource; + }; + + var buildFullPath = function(base, path) { + return (base + path).replace(/\/{2,}/g, '/'); + }; + + function buildQuery(options) { + var query = {}; + + if (options && Array.isArray(options.props)) query.props = options.props.join(','); var queryStringParts = []; + for (var i in query) { queryStringParts.push(encodeURIComponent(i) + '=' + encodeURIComponent(query[i])); } - if (queryStringParts.length) { - fullPath += '?' + queryStringParts.join('&'); - } - return this._http.request(method.toUpperCase(), fullPath, options); + return queryStringParts.join('&'); }; }); @@ -91,12 +133,212 @@ /***/ }, /* 2 */ +/***/ function(module, exports) { + + 'use strict'; + + function Store(backend) { + this._backend = backend; + }; + + Store.prototype.get = function(cacheKey) { + return this._backend.get(cacheKey); + }; + + Store.prototype.put = function(cacheKey, value, ttl) { + return this._backend.put(cacheKey, value, ttl); + }; + + Store.prototype.remove = function(cacheKey) { + return this._backend.remove(cacheKey); + }; + + Store.prototype.getAll = function() { + return this._backend.getAll(); + }; + + module.exports = Store; + + +/***/ }, +/* 3 */ +/***/ function(module, exports) { + + 'use strict'; + + function MemoryCache(cacheId, options) { + this._cacheId = cacheId; + this._cache = {}; + this._head = null; + this._tail = null; + this._options = options || {}; + if (!this._options.hasOwnProperty('capacity')) { + this._options.capacity = false; + } + }; + + MemoryCache.prototype.get = function(cacheKey) { + if (!this._cache.hasOwnProperty(cacheKey)) { + return undefined; + } + + var entry = this._cache[cacheKey]; + + if (entry.expiration) { + var now = new Date(); + if (now.getTime() > entry.expiration.getTime()) { + this.remove(cacheKey); + return undefined; + } + } + + moveToHead.call(this, entry); + + return entry.value; + }; + + MemoryCache.prototype.getAll = function() { + // return Object.keys(this._cache).map(function(key) { + // return this._cache[key]; + // }.bind(this)); + + var all = {}; + for (var i in this._cache) { + var cached = this._cache[i]; + all[i] = { + value: cached.value, + expiration: cached.expiration + }; + } + return all; + }; + + MemoryCache.prototype.put = function(cacheKey, value, ttl, tags) { + if (!Array.isArray(tags)) { + tags = toString.call(tags) == '[object String]' ? [tags] : []; + } + + var entry = { + key: cacheKey, + value: value, + expiration: false, + tags: tags + }; + + ttl = parseInt(ttl, 10); + + if (isFinite(ttl) && ttl > 0) { + entry.expiration = new Date(new Date().getTime() + ttl); + } + + moveToHead.call(this, entry); + + this._cache[cacheKey] = entry; + + var size = Object.keys(this._cache).length; + if (this._options.capacity > 0 && size > this._options.capacity) { + clearExpired.call(this); + + if (Object.keys(this._cache).length > this._options.capacity) { + purgeTail.call(this); + } + } + }; + + var moveToHead = function(entry) { + if (this._head) { + entry.next = this._head; + this._head.previous = entry; + } else { + entry.next = null; + } + + // Head has no previous + entry.previous = null; + + this._head = entry; + + if (!this._tail) { + this._tail = entry; + } + }; + + var purgeTail = function() { + if (this._head === this._tail) { + // Do not purge + return; + } + + var tail = this._tail; + var previous = tail.previous; + previous.next = null; + this._tail = previous; + delete this._cache[tail.key]; + }; + + var clearExpired = function() { + var now = new Date(); + Object.keys(this._cache).forEach(function(cacheKey) { + var entry = this._cache[cacheKey]; + if (entry.expiration) { + if (now.getTime() > entry.expiration.getTime()) { + this.remove(cacheKey); + } + } + }.bind(this)); + }; + + MemoryCache.prototype.remove = function(cacheKey) { + if (this._cache.hasOwnProperty(cacheKey)) { + var entry = this._cache[cacheKey]; + + // Update the doubly-linked list pointers + var previous = entry.previous; + var next = entry.next; + + if (previous) { + previous.next = next; + } + + if (next) { + next.previous = previous; + } + + if (this._tail === entry) { + this._tail = previous; + } + + delete this._cache[cacheKey]; + } + }; + + MemoryCache.prototype.removeAll = function() { + this._cache = {}; + this._head = null; + this._tail = null; + }; + + MemoryCache.prototype.removeMatchingTag = function(tag) { + // TODO: Use a faster lookup, perhaps a map? + Object.keys(this._cache).forEach(function(cacheKey) { + var entry = this._cache[cacheKey]; + if (-1 !== entry.tags.indexOf(tag)) { + this.remove(cacheKey); + } + }.bind(this)); + }; + + module.exports = MemoryCache; + + +/***/ }, +/* 4 */ /***/ function(module, exports, __webpack_require__) { 'use strict'; - module.exports = function(angular, Monocle) { - var AngularAdapter = __webpack_require__(3); + function angularWrapper(angular, Monocle) { + var AngularAdapter = __webpack_require__(5); // ## Module: monocle // Registers the module `monocle` with Angular, // allowing Angular apps to declare this module as a dependency. @@ -126,9 +368,16 @@ angularAdapter.setTimeout(this._timeout); angularAdapter.setHeaders(this._headers); - var monocle = new Monocle(angularAdapter); + var monocle = new Monocle(angularAdapter, $q); monocle.setBase(this._base); + // Wrap all promises in Angular promises + ['get', 'post', 'put', 'patch', 'delete', 'options'].forEach(function(method) { + monocle[method] = function(path, options) { + return $q.when(Monocle.prototype[method].call(monocle, path, options)); + }; + }); + return monocle; }; @@ -136,9 +385,11 @@ }); }; + module.exports = angularWrapper; + /***/ }, -/* 3 */ +/* 5 */ /***/ function(module, exports) { 'use strict'; diff --git a/test/test_runner.js b/test/test_runner.js index db5aff0..fc68bd4 100644 --- a/test/test_runner.js +++ b/test/test_runner.js @@ -3,6 +3,9 @@ var path = require('path'); // Use constant to help with resolving path to lib code within test files GLOBAL.LIB_DIR = path.join(process.cwd(), 'lib'); +// Set up promises +GLOBAL.Promise = require('bluebird'); + // Set up in-place instrumentation for code coverage require('blanket')({ pattern: LIB_DIR }); diff --git a/test/unit/lib/cache/memory_test.js b/test/unit/lib/cache/memory_test.js index 6d0e60d..1f72f2a 100644 --- a/test/unit/lib/cache/memory_test.js +++ b/test/unit/lib/cache/memory_test.js @@ -39,6 +39,23 @@ describe('Memory Cache', function() { expect(this.cache.get('test_key_1')).to.be.undefined; expect(this.cache.get('test_key_2')).to.be.undefined; }); + + it('can get all', function() { + this.cache.put('test_key_1', 42); + this.cache.put('test_key_2', 84, 1000); + var all = this.cache.getAll(); + all.should.be.an('object'); + all.should.have.property('test_key_1'); + all.should.have.property('test_key_2'); + + all.test_key_1.should.have.property('value', 42); + all.test_key_1.should.have.property('expiration', false); + + all.test_key_2.should.have.property('value', 84); + all.test_key_2.should.have.property('expiration'); + all.test_key_2.expiration.should.be.a('date'); + all.test_key_2.expiration.should.eql(new Date(1000)); // loose comparison check + }); }); describe('expiration', function() { diff --git a/test/unit/lib/cache/store_test.js b/test/unit/lib/cache/store_test.js new file mode 100644 index 0000000..454aac4 --- /dev/null +++ b/test/unit/lib/cache/store_test.js @@ -0,0 +1,20 @@ +/*jshint expr: true*/ +var Store = require(LIB_DIR + '/cache/store'); + +describe('Store', function() { + beforeEach(function() { + this.mockBackend = { + get: function() {} + }; + sinon.stub(this.mockBackend, 'get') + .withArgs('test_cache_key_1').returns('test_cache_value_1') + .withArgs('test_cache_key_2').returns('test_cache_value_2');; + + this.store = new Store(this.mockBackend); + }); + + it('can get items from the provided backend', function() { + this.store.get('test_cache_key_1').should.equal('test_cache_value_1'); + this.store.get('test_cache_key_2').should.equal('test_cache_value_2'); + }); +}); diff --git a/test/unit/lib/monocle_test.js b/test/unit/lib/monocle_test.js index 91dcfce..0e51019 100644 --- a/test/unit/lib/monocle_test.js +++ b/test/unit/lib/monocle_test.js @@ -74,4 +74,52 @@ describe('Monocle API Client', function() { }); }); }); + + describe('caching', function() { + beforeEach(function() { + this.http.mock('GET', '/cacheable').resolvesWith({ + foo: 'test cacheable', + $expires: 5000, + $id: '/cacheable' + }); + + this.http.mock('GET', '/uncacheable').resolvesWith({ + foo: 'test uncacheable' + }); + }); + + it('returns cached value if resource is cacheable and within time limit', function() { + return this.api.get('/cacheable') + .then(function(result1) { + return this.api.get('/cacheable') + .then(function(result2) { + this.http.request.calledOnce.should.be.true; + result1.should.equal(result2); + }.bind(this)); + }.bind(this)); + }); + + it('makes new http request if cached resource is expired', function() { + return this.api.get('/cacheable') + .then(function(result1) { + this.clock.tick(5001); + return this.api.get('/cacheable') + .then(function(result2) { + this.http.request.calledTwice.should.be.true; + result1.should.equal(result2); + }.bind(this)); + }.bind(this)); + }); + + it('makes new http request if resource is not cacheable', function() { + return this.api.get('/uncacheable') + .then(function(result1) { + return this.api.get('/uncacheable') + .then(function(result2) { + this.http.request.calledTwice.should.be.true; + result1.should.equal(result2); + }.bind(this)); + }.bind(this)); + }); + }); });