diff --git a/common/models/key-value-model.js b/common/models/key-value-model.js new file mode 100644 index 00000000..200465ca --- /dev/null +++ b/common/models/key-value-model.js @@ -0,0 +1,76 @@ +var g = require('strong-globalize')(); + +module.exports = function(KeyValueModel) { + // TODO add api docs + KeyValueModel.get = function(key, options, callback) { + throwNotAttached(this.modelName, 'get'); + }; + + // TODO add api docs + KeyValueModel.set = function(key, value, options, callback) { + throwNotAttached(this.modelName, 'set'); + }; + + // TODO add api docs + KeyValueModel.expire = function(key, ttl, options, callback) { + throwNotAttached(this.modelName, 'expire'); + }; + + KeyValueModel.setup = function() { + KeyValueModel.base.setup.apply(this, arguments); + + this.remoteMethod('get', { + accepts: { + arg: 'key', type: 'string', required: true, + http: { source: 'path' }, + }, + returns: { arg: 'value', type: 'any', root: true }, + http: { path: '/:key', verb: 'get' }, + rest: { after: convertNullToNotFoundError }, + }); + + this.remoteMethod('set', { + accepts: [ + { arg: 'key', type: 'string', required: true, + http: { source: 'path' }}, + { arg: 'value', type: 'any', required: true, + http: { source: 'body' }}, + { arg: 'ttl', type: 'number', + http: { source: 'query' }, + description: 'time to live in milliseconds' }, + ], + http: { path: '/:key', verb: 'put' }, + }); + + this.remoteMethod('expire', { + accepts: [ + { arg: 'key', type: 'string', required: true, + http: { source: 'path' }}, + { arg: 'ttl', type: 'number', required: true, + http: { source: 'form' }}, + ], + http: { path: '/:key/expire', verb: 'put' }, + }); + }; +}; + +function throwNotAttached(modelName, methodName) { + throw new Error(g.f( + 'Cannot call %s.%s(). ' + + 'The %s method has not been setup. ' + + 'The {{KeyValueModel}} has not been correctly attached ' + + 'to a {{DataSource}}!', + modelName, methodName, methodName)); +} + +function convertNullToNotFoundError(ctx, cb) { + if (ctx.result !== null) return cb(); + + var modelName = ctx.method.sharedClass.name; + var id = ctx.getArgByName('id'); + var msg = g.f('Unknown "%s" {{key}} "%s".', modelName, id); + var error = new Error(msg); + error.statusCode = error.status = 404; + error.code = 'KEY_NOT_FOUND'; + cb(error); +} diff --git a/common/models/key-value-model.json b/common/models/key-value-model.json new file mode 100644 index 00000000..72884e87 --- /dev/null +++ b/common/models/key-value-model.json @@ -0,0 +1,4 @@ +{ + "name": "KeyValueModel", + "base": "Model" +} diff --git a/lib/builtin-models.js b/lib/builtin-models.js index 59f0ff65..dc23b409 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -6,6 +6,10 @@ module.exports = function(registry) { // NOTE(bajtos) we must use static require() due to browserify limitations + registry.KeyValueModel = createModel( + require('../common/models/key-value-model.json'), + require('../common/models/key-value-model.js')); + registry.Email = createModel( require('../common/models/email.json'), require('../common/models/email.js')); diff --git a/test/key-value-model.test.js b/test/key-value-model.test.js new file mode 100644 index 00000000..e81ed63c --- /dev/null +++ b/test/key-value-model.test.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +var http = require('http'); +var loopback = require('..'); +var supertest = require('supertest'); + +var AN_OBJECT_VALUE = { name: 'an-object' }; + +describe('KeyValueModel', function() { + var request, app, CacheItem; + beforeEach(setupAppAndCacheItem); + + describe('REST API', function() { + before(setupSharedHttpServer); + + it('provides "get(key)" at "GET /key"', function(done) { + CacheItem.set('get-key', AN_OBJECT_VALUE); + request.get('/CacheItems/get-key') + .end(function(err, res) { + if (err) return done(err); + expect(res.body).to.eql(AN_OBJECT_VALUE); + done(); + }); + }); + + it('returns 404 when getting a key that does not exist', function(done) { + request.get('/CacheItems/key-does-not-exist') + .expect(404, done); + }); + + it('provides "set(key)" at "PUT /key"', function(done) { + request.put('/CacheItems/set-key') + .send(AN_OBJECT_VALUE) + .expect(204) + .end(function(err, res) { + if (err) return done(err); + CacheItem.get('set-key', function(err, value) { + if (err) return done(err); + expect(value).to.eql(AN_OBJECT_VALUE); + done(); + }); + }); + }); + + it('provides "set(key, ttl)" at "PUT /key?ttl={num}"', function(done) { + request.put('/CacheItems/set-key-ttl?ttl=10') + .send(AN_OBJECT_VALUE) + .end(function(err, res) { + if (err) return done(err); + setTimeout(function() { + CacheItem.get('set-key-ttl', function(err, value) { + if (err) return done(err); + expect(value).to.equal(null); + done(); + }); + }, 20); + }); + }); + + it('provides "expire(key, ttl)" at "PUT /key/expire"', + function(done) { + CacheItem.set('expire-key', AN_OBJECT_VALUE, function(err) { + if (err) return done(err); + request.put('/CacheItems/expire-key/expire') + .send({ ttl: 10 }) + .end(function(err, res) { + if (err) return done(err); + setTimeout(function() { + CacheItem.get('set-key-ttl', function(err, value) { + if (err) return done(err); + expect(value).to.equal(null); + done(); + }); + }, 20); + }); + }); + }); + + it('returns 404 when expiring a key that does not exist', function(done) { + request.put('/CacheItems/key-does-not-exist/expire') + .send({ ttl: 10 }) + .expect(404, done); + }); + }); + + function setupAppAndCacheItem() { + app = loopback({ localRegistry: true, loadBuiltinModels: true }); + app.use(loopback.rest()); + + CacheItem = app.registry.createModel({ + name: 'CacheItem', + base: 'KeyValueModel', + }); + + app.dataSource('kv', { connector: 'kv-memory' }); + app.model(CacheItem, { dataSource: 'kv' }); + } + + var _server, _requestHandler; // eslint-disable-line one-var + function setupSharedHttpServer(done) { + _server = http.createServer(function(req, res) { + app(req, res); + }); + _server.listen(0, '127.0.0.1') + .once('listening', function() { + request = supertest('http://127.0.0.1:' + this.address().port); + done(); + }) + .once('error', function(err) { done(err); }); + } +}); diff --git a/test/loopback.test.js b/test/loopback.test.js index b3aea149..3f833b0a 100644 --- a/test/loopback.test.js +++ b/test/loopback.test.js @@ -45,6 +45,7 @@ describe('loopback', function() { 'DataSource', 'Email', 'GeoPoint', + 'KeyValueModel', 'Mail', 'Memory', 'Model',