diff --git a/lib/loopback.js b/lib/loopback.js index a854ca601..754484d4b 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -353,3 +353,5 @@ loopback.memory = function(name) { require('./builtin-models')(loopback); loopback.DataSource = juggler.DataSource; + +loopback.optionsFromContext = require('./options-from-context'); diff --git a/lib/model.js b/lib/model.js index 1926628b0..2b3865e18 100644 --- a/lib/model.js +++ b/lib/model.js @@ -14,6 +14,7 @@ var RemoteObjects = require('strong-remoting'); var SharedClass = require('strong-remoting').SharedClass; var extend = require('util')._extend; var format = require('util').format; +var optionsFromContext = require('./options-from-context'); module.exports = function(registry) { /** @@ -135,15 +136,29 @@ module.exports = function(registry) { ); // support remoting prototype methods - ModelCtor.sharedCtor = function(data, id, fn) { + ModelCtor.sharedCtor = function(data, id, options, fn) { var ModelCtor = this; - if (typeof data === 'function') { + var isRemoteInvocationWithOptions = typeof data !== 'object' && + typeof id === 'object' && + typeof options === 'function'; + if (isRemoteInvocationWithOptions) { + // sharedCtor(id, options, fn) + fn = options; + options = id; + id = data; + data = null; + } else if (typeof data === 'function') { + // sharedCtor(fn) fn = data; data = null; id = null; + options = null; } else if (typeof id === 'function') { + // sharedCtor(data, fn) + // sharedCtor(id, fn) fn = id; + options = null; if (typeof data !== 'object') { id = data; @@ -160,7 +175,8 @@ module.exports = function(registry) { } else if (data) { fn(null, new ModelCtor(data)); } else if (id) { - ModelCtor.findById(id, function(err, model) { + var filter = {}; + ModelCtor.findById(id, filter, options, function(err, model) { if (err) { fn(err); } else if (model) { @@ -182,6 +198,7 @@ module.exports = function(registry) { { arg: 'id', type: 'any', required: true, http: { source: 'path' }, description: idDesc }, // {arg: 'instance', type: 'object', http: {source: 'body'}} + { arg: 'options', type: 'object', http: optionsFromContext }, ]; ModelCtor.sharedCtor.http = [ diff --git a/lib/options-from-context.js b/lib/options-from-context.js new file mode 100644 index 000000000..29d545345 --- /dev/null +++ b/lib/options-from-context.js @@ -0,0 +1,17 @@ +// Copyright IBM Corp. 2013,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = buildOptionsFromRemotingContext; + +function buildOptionsFromRemotingContext(ctx) { + var accessToken = ctx.req.accessToken; + var options = { + remotingContext: ctx, + accessToken: accessToken, + currentUserId: accessToken ? accessToken.userId : null, + }; + + return options; +} diff --git a/lib/persisted-model.js b/lib/persisted-model.js index 8f33b60da..28bf435db 100644 --- a/lib/persisted-model.js +++ b/lib/persisted-model.js @@ -13,6 +13,7 @@ var assert = require('assert'); var async = require('async'); var deprecated = require('depd')('loopback'); var debug = require('debug')('loopback:persisted-model'); +var optionsFromContext = require('./options-from-context.js'); var PassThrough = require('stream').PassThrough; var utils = require('./utils'); @@ -639,11 +640,14 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'create', { description: 'Create a new instance of the model and persist it into the data source.', accessType: 'WRITE', - accepts: { - arg: 'data', type: 'object', model: typeName, - description: 'Model instance data', - http: { source: 'body' }, - }, + accepts: [ + { + arg: 'data', type: 'object', model: typeName, + description: 'Model instance data', + http: { source: 'body' }, + }, + { arg: 'options', type: 'object', http: optionsFromContext }, + ], returns: { arg: 'data', type: typeName, root: true }, http: { verb: 'post', path: '/' }, }); @@ -653,10 +657,13 @@ module.exports = function(registry) { description: 'Patch an existing model instance or insert a new one ' + 'into the data source.', accessType: 'WRITE', - accepts: { - arg: 'data', type: 'object', model: typeName, http: { source: 'body' }, - description: 'Model instance data', - }, + accepts: [ + { + arg: 'data', type: 'object', model: typeName, http: { source: 'body' }, + description: 'Model instance data', + }, + { arg: 'options', type: 'object', http: optionsFromContext }, + ], returns: { arg: 'data', type: typeName, root: true }, http: [{ verb: 'patch', path: '/' }], }; @@ -669,11 +676,14 @@ module.exports = function(registry) { var replaceOrCreateOptions = { description: 'Replace an existing model instance or insert a new one into the data source.', accessType: 'WRITE', - accepts: { - arg: 'data', type: 'object', model: typeName, - http: { source: 'body' }, - description: 'Model instance data', - }, + accepts: [ + { + arg: 'data', type: 'object', model: typeName, + http: { source: 'body' }, + description: 'Model instance data', + }, + { arg: 'options', type: 'object', http: optionsFromContext }, + ], returns: { arg: 'data', type: typeName, root: true }, http: [{ verb: 'post', path: '/replaceOrCreate' }], }; @@ -694,6 +704,7 @@ module.exports = function(registry) { description: 'Criteria to match model instances' }, { arg: 'data', type: 'object', model: typeName, http: { source: 'body' }, description: 'An object of model property name/value pairs' }, + { arg: 'options', type: 'object', http: optionsFromContext }, ], returns: { arg: 'data', type: typeName, root: true }, http: { verb: 'post', path: '/upsertWithWhere' }, @@ -702,7 +713,10 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'exists', { description: 'Check whether a model instance exists in the data source.', accessType: 'READ', - accepts: { arg: 'id', type: 'any', description: 'Model id', required: true }, + accepts: [ + { arg: 'id', type: 'any', description: 'Model id', required: true }, + { arg: 'options', type: 'object', http: optionsFromContext }, + ], returns: { arg: 'exists', type: 'boolean' }, http: [ { verb: 'get', path: '/:id/exists' }, @@ -738,6 +752,7 @@ module.exports = function(registry) { http: { source: 'path' }}, { arg: 'filter', type: 'object', description: 'Filter defining fields and include' }, + { arg: 'options', type: 'object', http: optionsFromContext }, ], returns: { arg: 'data', type: typeName, root: true }, http: { verb: 'get', path: '/:id' }, @@ -750,8 +765,9 @@ module.exports = function(registry) { accepts: [ { arg: 'id', type: 'any', description: 'Model id', required: true, http: { source: 'path' }}, - { arg: 'data', type: 'object', model: typeName, http: { source: 'body' }, description: - 'Model instance data' }, + { arg: 'data', type: 'object', model: typeName, http: { source: 'body' }, description: + 'Model instance data' }, + { arg: 'options', type: 'object', http: optionsFromContext }, ], returns: { arg: 'data', type: typeName, root: true }, http: [{ verb: 'post', path: '/:id/replace' }], @@ -767,8 +783,11 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'find', { description: 'Find all instances of the model matched by filter from the data source.', accessType: 'READ', - accepts: { arg: 'filter', type: 'object', description: - 'Filter defining fields, where, include, order, offset, and limit' }, + accepts: [ + { arg: 'filter', type: 'object', description: + 'Filter defining fields, where, include, order, offset, and limit' }, + { arg: 'options', type: 'object', http: optionsFromContext }, + ], returns: { arg: 'data', type: [typeName], root: true }, http: { verb: 'get', path: '/' }, }); @@ -776,8 +795,11 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'findOne', { description: 'Find first instance of the model matched by filter from the data source.', accessType: 'READ', - accepts: { arg: 'filter', type: 'object', description: - 'Filter defining fields, where, include, order, offset, and limit' }, + accepts: [ + { arg: 'filter', type: 'object', description: + 'Filter defining fields, where, include, order, offset, and limit' }, + { arg: 'options', type: 'object', http: optionsFromContext }, + ], returns: { arg: 'data', type: typeName, root: true }, http: { verb: 'get', path: '/findOne' }, rest: { after: convertNullToNotFoundError }, @@ -786,7 +808,10 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'destroyAll', { description: 'Delete all matching records.', accessType: 'WRITE', - accepts: { arg: 'where', type: 'object', description: 'filter.where object' }, + accepts: [ + { arg: 'where', type: 'object', description: 'filter.where object' }, + { arg: 'options', type: 'object', http: optionsFromContext }, + ], returns: { arg: 'count', type: 'object', @@ -806,6 +831,7 @@ module.exports = function(registry) { description: 'Criteria to match model instances' }, { arg: 'data', type: 'object', model: typeName, http: { source: 'body' }, description: 'An object of model property name/value pairs' }, + { arg: 'options', type: 'object', http: optionsFromContext }, ], returns: { arg: 'count', @@ -820,8 +846,11 @@ module.exports = function(registry) { aliases: ['destroyById', 'removeById'], description: 'Delete a model instance by {{id}} from the data source.', accessType: 'WRITE', - accepts: { arg: 'id', type: 'any', description: 'Model id', required: true, - http: { source: 'path' }}, + accepts: [ + { arg: 'id', type: 'any', description: 'Model id', required: true, + http: { source: 'path' }}, + { arg: 'options', type: 'object', http: optionsFromContext }, + ], http: { verb: 'del', path: '/:id' }, returns: { arg: 'count', type: 'object', root: true }, }); @@ -829,7 +858,10 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'count', { description: 'Count instances of the model matched by where from the data source.', accessType: 'READ', - accepts: { arg: 'where', type: 'object', description: 'Criteria to match model instances' }, + accepts: [ + { arg: 'where', type: 'object', description: 'Criteria to match model instances' }, + { arg: 'options', type: 'object', http: optionsFromContext }, + ], returns: { arg: 'count', type: 'number' }, http: { verb: 'get', path: '/count' }, }); @@ -839,11 +871,14 @@ module.exports = function(registry) { description: 'Patch attributes for a model instance and persist it into ' + 'the data source.', accessType: 'WRITE', - accepts: { - arg: 'data', type: 'object', model: typeName, - http: { source: 'body' }, - description: 'An object of model property name/value pairs', - }, + accepts: [ + { + arg: 'data', type: 'object', model: typeName, + http: { source: 'body' }, + description: 'An object of model property name/value pairs', + }, + { arg: 'options', type: 'object', http: optionsFromContext }, + ], returns: { arg: 'data', type: typeName, root: true }, http: [{ verb: 'patch', path: '/' }], }; diff --git a/test/loopback.test.js b/test/loopback.test.js index 08d2de1a5..3817f7ff2 100644 --- a/test/loopback.test.js +++ b/test/loopback.test.js @@ -82,6 +82,7 @@ describe('loopback', function() { 'memory', 'modelBuilder', 'name', + 'optionsFromContext', 'prototype', 'query', 'registry', diff --git a/test/options-from-context.test.js b/test/options-from-context.test.js new file mode 100644 index 000000000..3cf8b4f45 --- /dev/null +++ b/test/options-from-context.test.js @@ -0,0 +1,114 @@ +// Copyright IBM Corp. 2013,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var expect = require('chai').expect; +var loopback = require('..'); +var supertest = require('supertest'); + +var optionsFromContext = loopback.optionsFromContext; + +describe('loopback.optionsFromContext', function() { + var app, request, TestModel, User, accessToken, userId, actualOptions; + + before(setupAppAndRequest); + before(createUserAndAccessToken); + + it('sets options.remotingContext', function(done) { + request.get('/TestModels/saveOptions') + .expect(204, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + expect(actualOptions.remotingContext).to.have.property('req'); + expect(actualOptions.remotingContext).to.have.property('res'); + expect(actualOptions.remotingContext) + .to.have.deep.property('constructor.name', 'HttpContext'); + done(); + }); + }); + + it('sets options.accessToken for authorized requests', function(done) { + request.get('/TestModels/saveOptions') + .set('Authorization', accessToken.id) + .expect(204, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('accessToken'); + expect(actualOptions.accessToken.toObject()) + .to.eql(accessToken.toObject()); + done(); + }); + }); + + it('sets options.currentUserId for authorized requests', function(done) { + request.get('/TestModels/saveOptions') + .set('Authorization', accessToken.id) + .expect(204, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('currentUserId', userId); + done(); + }); + }); + + it('handles anonymous requests', function(done) { + request.get('/TestModels/saveOptions') + .expect(204, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('accessToken', null); + expect(actualOptions).to.have.property('currentUserId', null); + done(); + }); + }); + + it('allows "beforeRemote" hooks to contribute options', function(done) { + TestModel.beforeRemote('saveOptions', function(ctx, unused, next) { + ctx.args.options.hooked = true; + next(); + }); + + request.get('/TestModels/saveOptions') + .expect(204, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('hooked', true); + done(); + }); + }); + + function setupAppAndRequest() { + app = loopback({ localRegistry: true, loadBuiltinModels: true }); + + app.dataSource('db', { connector: 'memory' }); + + TestModel = app.registry.createModel('TestModel', { base: 'Model' }); + TestModel.saveOptions = function(options, cb) { + actualOptions = options; + cb(); + }; + + TestModel.remoteMethod('saveOptions', { + accepts: { arg: 'options', type: 'object', http: optionsFromContext }, + http: { verb: 'GET', path: '/saveOptions' }, + }); + + app.model(TestModel, { dataSource: null }); + + User = app.registry.getModel('User'); + app.model(User, { dataSource: 'db' }); + app.enableAuth({ dataSource: 'db' }); + + app.use(loopback.token()); + app.use(loopback.rest()); + request = supertest(app); + } + + function createUserAndAccessToken() { + var CREDENTIALS = { email: 'context@example.com', password: 'pass' }; + return User.create(CREDENTIALS) + .then(function(u) { + return User.login(CREDENTIALS); + }).then(function(token) { + accessToken = token; + userId = token.userId; + }); + } +}); diff --git a/test/persisted-model.test.js b/test/persisted-model.test.js new file mode 100644 index 000000000..1ea7a00ee --- /dev/null +++ b/test/persisted-model.test.js @@ -0,0 +1,220 @@ +// Copyright IBM Corp. 2013,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var expect = require('chai').expect; +var loopback = require('..'); +var supertest = require('supertest'); + +describe('PersistedModel', function() { + var app, request, accessToken, userId, Product, actualOptions; + + beforeEach(setupAppAndRequest); + + context('when making updates via REST', function() { + beforeEach(observeOptionsBeforeSave); + + it('injects options to create()', function(done) { + request.post('/products') + .send({ name: 'Pen' }) + .expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + + it('injects options to patchOrCreate()', function(done) { + request.patch('/products') + .send({ id: 1, name: 'Pen' }) + .expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + + it('injects options to replaceOrCreate()', function(done) { + request.put('/products') + .send({ id: 1, name: 'Pen' }) + .expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + + it('injects options to patchOrCreateWithWhere()', function(done) { + request.post('/products/upsertWithWhere?where[name]=Pen') + .send({ name: 'Pencil' }) + .expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + + it('injects options to replaceById()', function(done) { + Product.create({ id: 1, name: 'Pen' }, function(err, p) { + if (err) return done(err); + request.put('/products/1') + .send({ name: 'Pencil' }) + .expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + }); + + it('injects options to prototype.patchAttributes()', function(done) { + Product.create({ id: 1, name: 'Pen' }, function(err, p) { + if (err) return done(err); + request.patch('/products/1') + .send({ name: 'Pencil' }) + .expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + }); + + it('injects options to updateAll()', function(done) { + request.post('/products/update?where[name]=Pen') + .send({ name: 'Pencil' }) + .expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + }); + + context('when deleting via REST', function() { + beforeEach(observeOptionsBeforeDelete); + + it('injects options to deleteById()', function(done) { + Product.create({ id: 1, name: 'Pen' }, function(err, p) { + if (err) return done(err); + request.delete('/products/1') + .expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + }); + }); + + context('when querying via REST', function() { + beforeEach(observeOptionsOnAccess); + beforeEach(createProductId1); + + it('injects options to find()', function(done) { + request.get('/products').expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + + it('injects options to findById()', function(done) { + request.get('/products/1').expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + + it('injects options to findOne()', function(done) { + request.get('/products/findOne?where[id]=1').expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + + it('injects options to exists()', function(done) { + request.head('/products/1').expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + + it('injects options to count()', function(done) { + request.get('/products/count').expect(200, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + }); + + context('when invoking prototype methods', function() { + beforeEach(observeOptionsOnAccess); + beforeEach(createProductId1); + + it('injects options to sharedCtor', function(done) { + Product.prototype.dummy = function(cb) { cb(); }; + Product.remoteMethod('prototype.dummy', {}); + request.post('/products/1/dummy').expect(204, function(err) { + if (err) return done(err); + expect(actualOptions).to.have.property('remotingContext'); + done(); + }); + }); + }); + + function setupAppAndRequest() { + app = loopback({ localRegistry: true }); + app.dataSource('db', { connector: 'memory' }); + + Product = app.registry.createModel( + 'Product', + { name: String }, + { forceId: false, replaceOnPUT: true }); + + app.model(Product, { dataSource: 'db' }); + + app.use(loopback.rest()); + request = supertest(app); + } + + function observeOptionsBeforeSave() { + Product.observe('before save', function(ctx, next) { + actualOptions = ctx.options; + next(); + }); + } + + function observeOptionsBeforeDelete() { + Product.observe('before delete', function(ctx, next) { + actualOptions = ctx.options; + next(); + }); + } + + function observeOptionsOnAccess() { + Product.observe('access', function(ctx, next) { + actualOptions = ctx.options; + next(); + }); + } + + function createProductId1() { + return Product.create({ id: 1, name: 'Pen' }); + } + function observeOptionsOnAccess() { + Product.observe('access', function(ctx, next) { + actualOptions = ctx.options; + next(); + }); + } + + function createProductId1() { + return Product.create({ id: 1, name: 'Pen' }); + } +}); diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js index 231c2617a..78c2c611d 100644 --- a/test/remote-connector.test.js +++ b/test/remote-connector.test.js @@ -80,7 +80,7 @@ describe('RemoteConnector', function() { var ServerModel = this.ServerModel; - ServerModel.create = function(data, cb) { + ServerModel.create = function(data, options, cb) { calledServerCreate = true; data.id = 1; cb(null, data); diff --git a/test/remoting.integration.js b/test/remoting.integration.js index f21c42031..a8132e68e 100644 --- a/test/remoting.integration.js +++ b/test/remoting.integration.js @@ -272,7 +272,9 @@ function formatMethod(m) { arr.push([ m.name, '(', - m.accepts.map(function(a) { + m.accepts.filter(function(a) { + return !(a.http && typeof a.http === 'function'); + }).map(function(a) { return a.arg + ':' + a.type + (a.model ? ':' + a.model : ''); }).join(','), ')',