UNPKG

strong-remotingnext

Version:
656 lines (577 loc) 19.2 kB
// Copyright IBM Corp. 2013,2016. All Rights Reserved. // Node module: strong-remoting // This file is licensed under the Artistic License 2.0. // License text available at https://opensource.org/licenses/Artistic-2.0 /*! * Expose `SharedMethod`. */ module.exports = SharedMethod; var debug = require('debug')('strong-remoting:shared-method'); var util = require('util'); var traverse = require('traverse'); var assert = require('assert'); /** * Create a new `SharedMethod` (remote method) with the given `fn`. * See also [Remote methods](http://docs.strongloop.com/display/LB/Remote+methods). * * @property {String} name The method name. * @property {String[]} aliases An array of method aliases. * @property {Boolean} isStatic Whether the method is static; from `options.isStatic`. * Default is `true`. * @property {Array|Object} accepts See `options.accepts`. * @property {Array|Object} returns See `options.returns`. * @property {Array|Object} errors See `options.errors`. * @property {String} description Text description of the method. * @property {String} notes Additional notes, used by API documentation generators like * Swagger. * @property {String} http * @property {String} rest * @property {String} shared * @property {Boolean} [documented] Default: true. Set to `false` to exclude the method * from Swagger metadata. * * @param {Function} fn The `Function` to be invoked when the method is invoked. * @param {String} name The name of the `SharedMethod`. * @param {SharedClass} sharedClass The `SharedClass` to which the method will be attached. * * @options {Object} options See below. * @property {Array|Object} [accepts] Defines either a single argument as an object or an * ordered set of arguments as an array. * @property {String} [accepts.arg] The name of the argument. * @property {String} [accepts.description] Text description of the argument, used by API * documentation generators like Swagger. * @property {String} [accepts.http] HTTP mapping for the argument. See argument mapping in * [the docs](http://docs.strongloop.com/x/-Yw6#Remotemethods-HTTPmappingofinputarguments). * @property {String} [accepts.http.source] The HTTP source for the argument. May be one * of the following: * * - `req` - the Express `Request` object. * - `res` - the Express `Response` object. * - `body` - the `req.body` value. * - `form` - `req.body[argumentName]`. * - `query` - `req.query[argumentName]`. * - `path` - `req.params[argumentName]`. * - `header` - `req.headers[argumentName]`. * - `context` - the current `HttpContext`. * @property {Object} [accepts.rest] The REST mapping / settings for the argument. * @property {String} [accepts.type] Argument datatype; must be a * [Loopback type](http://docs.strongloop.com/display/LB/LoopBack+types). * @property {Array} [aliases] A list of aliases for the method. * @property {Array|Object} [errors] Object or `Array` containing error definitions. * @property {Array} [http] HTTP-only options. * @property {Number} [http.errorStatus] Default error status code. * @property {String} [http.path] HTTP path (relative to the model) at which the method is * exposed. * @property {Number} [http.status] Default status code when the callback is called * _without_ an error. * @property {String} [http.verb] HTTP method (verb) at which the method is available. * One of: get, post (default), put, del, or all * @property {Boolean} [isStatic] Whether the method is a static method or a prototype * method. * @property {Array|Object} [returns] Specifies the remote method's callback arguments; * either a single argument as an object or an ordered set of arguments as an array. * The `err` argument is assumed; do not specify. NOTE: Can have the same properties as * `accepts`, except for `http.target`. * * Additionally, one of the callback arguments can have `type: 'file'` and * `root:true`, in which case this argument is sent in the raw form as * a response body. Allowed values: `String`, `Buffer` or `ReadableStream` * @property {Boolean} [shared] Whether the method is shared. Default is `true`. * @property {Number} [status] The default status code. * @end * * @class */ function SharedMethod(fn, name, sc, options) { if (typeof options === 'boolean') { options = { isStatic: options }; } this.fn = fn; fn = fn || {}; this.name = name; assert(typeof name === 'string', 'The method name must be a string'); options = options || {}; this.aliases = options.aliases || []; var isStatic = this.isStatic = options.isStatic || false; this.accepts = options.accepts || fn.accepts || []; this.returns = options.returns || fn.returns || []; this.errors = options.errors || fn.errors || []; this.description = options.description || fn.description; this.accessType = options.accessType || fn.accessType; this.notes = options.notes || fn.notes; this.documented = options.documented !== false && fn.documented !== false; this.http = options.http || fn.http || {}; this.rest = options.rest || fn.rest || {}; this.shared = options.shared; this.cache = options.cache; if (this.shared === undefined) { this.shared = true; } if (fn.shared === false) { this.shared = false; } this.sharedClass = sc; if (sc) { this.ctor = sc.ctor; this.sharedCtor = sc.sharedCtor; } if (name === 'sharedCtor') { this.isSharedCtor = true; } if (this.accepts && !Array.isArray(this.accepts)) { this.accepts = [this.accepts]; } this.accepts.forEach(normalizeArgumentDescriptor); if (this.returns && !Array.isArray(this.returns)) { this.returns = [this.returns]; } this.returns.forEach(normalizeArgumentDescriptor); var firstReturns = this.returns[0]; var streamTypes = ['ReadableStream', 'WriteableStream', 'DuplexStream']; if (firstReturns && firstReturns.type && streamTypes.indexOf(firstReturns.type) > -1) { this.streams = {returns: firstReturns}; } if (this.errors && !Array.isArray(this.errors)) { this.errors = [this.errors]; } this.stringName = (sc ? sc.name : '') + (isStatic ? '.' : '.prototype.') + name; } function normalizeArgumentDescriptor(desc) { if (desc.type === 'array') desc.type = ['any']; } /** * Create a new `SharedMethod` with the given `fn`. The function should include * all the method options. * * @param {Function} fn * @param {Function} name * @param {SharedClass} SharedClass * @param {Boolean} isStatic */ SharedMethod.fromFunction = function(fn, name, sharedClass, isStatic) { return new SharedMethod(fn, name, sharedClass, { isStatic: isStatic, accepts: fn.accepts, returns: fn.returns, errors: fn.errors, description: fn.description, cache: fn.cache, notes: fn.notes, http: fn.http, rest: fn.rest }); }; SharedMethod.prototype.getReturnArgDescByName = function(name) { var returns = this.returns; var desc; for (var i = 0; i < returns.length; i++) { desc = returns[i]; if (desc && ((desc.arg || desc.name) === name)) { return desc; } } }; /** * Execute the remote method using the given arg data. * * @param {Object} scope `this` parameter for the invocation * @param {Object} args containing named argument data * @param {Object=} remotingOptions remote-objects options * @param {Function} cb callback `fn(err, result)` containing named result data */ SharedMethod.prototype.invoke = function(scope, args, remotingOptions, ctx, cb) { var self = this; var cache = this.cache || {}; var Model = this.sharedClass.ctor; var accepts = this.accepts; var returns = this.returns; var errors = this.errors; var method = this.getFunction(); var sharedMethod = this; var formattedArgs = []; if (typeof ctx === 'function') { cb = ctx; ctx = undefined; } if (cb === undefined && typeof remotingOptions === 'function') { cb = remotingOptions; remotingOptions = {}; } // map the given arg data in order they are expected in if (accepts) { for (var i = 0; i < accepts.length; i++) { var desc = accepts[i]; var name = desc.name || desc.arg; var uarg = SharedMethod.convertArg(desc, args[name]); try { uarg = coerceAccepts(uarg, desc, name); } catch (e) { debug('- %s - ' + e.message, sharedMethod.name); return cb(e); } // Add the argument even if it's undefined to stick with the accepts formattedArgs.push(uarg); } } // define the callback function callback(err) { if (err) { return cb(err); } // args without err var rawArgs = [].slice.call(arguments, 1); var result = SharedMethod.toResult(returns, rawArgs, ctx); debug('- %s - result %j', sharedMethod.name, result); cb(null, result); } debug('- %s - invoke with', this.name, formattedArgs); // invoke try { var retval; if (cache.expire && Model.loadRemoteResultFromCache && Model.saveToCache) { retval = Model.loadRemoteResultFromCache(this, formattedArgs, ctx) .catch(function() { return method.apply(scope, formattedArgs).then(function(results) { Model.saveToCache(ctx.cacheKey, results, cache.expire); return Promise.resolve(results); }); }); } else { // add in the required callback formattedArgs.push(callback); retval = method.apply(scope, formattedArgs); } if (retval && typeof retval.then === 'function') { return retval.then( function(args) { if (returns.length === 1) args = [args]; var result = SharedMethod.toResult(returns, args); debug('- %s - promise result %j', sharedMethod.name, result); cb(null, result); }, cb // error handler ); } return retval; } catch (err) { debug('error caught during the invocation of %s', this.name); return cb(err); } }; function badArgumentError(msg) { var err = new Error(msg); err.statusCode = 400; return err; } function escapeRegex(d) { // see http://stackoverflow.com/a/6969486/69868 return d.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); } /** * Coerce an 'accepts' value into its final type. * If using HTTP, some coercion is already done in http-context. * * This should only do very simple coercion. * * @param {*} uarg Argument value. * @param {Object} desc Argument description. * @return {*} Coerced argument. */ function coerceAccepts(uarg, desc) { var name = desc.name || desc.arg; var targetType = convertToBasicRemotingType(desc.type); var targetTypeIsArray = Array.isArray(targetType) && targetType.length === 1; // If coercing an array to an erray, // then coerce all members of the array too if (targetTypeIsArray && Array.isArray(uarg)) { return uarg.map(function(arg, ix) { // when coercing array items, use only name and type, // ignore all other root settings like "required" return coerceAccepts(arg, { name: name + '[' + ix + ']', type: targetType[0] }); }); } var actualType = SharedMethod.getType(uarg); // convert values to the correct type // TODO(bajtos) Move conversions to HttpContext (and friends) // SharedMethod should only check that argument values match argument types. var conversionNeeded = targetType !== 'any' && actualType !== 'undefined' && actualType !== targetType; if (conversionNeeded) { // JSON.parse can throw, so catch this error. try { uarg = convertValueToTargetType(name, uarg, targetType); actualType = SharedMethod.getType(uarg); } catch (e) { var message = util.format('invalid value for argument \'%s\' of type ' + '\'%s\': %s. Received type was %s. Error: %s', name, targetType, uarg, typeof uarg, e.message); throw new badArgumentError(message); } } var typeMismatch = targetType !== 'any' && actualType !== 'undefined' && targetType !== actualType && // In JavaScript, an array is an object too (typeof [] === 'object'). // However, SharedMethod.getType([]) returns 'array' instead of 'object'. // We must explicitly allow assignment of an array value to an argument // of type 'object'. !(targetType === 'object' && actualType === 'array'); if (typeMismatch) { var message = util.format('Invalid value for argument \'%s\' of type ' + '\'%s\': %s. Received type was converted to %s.', name, targetType, uarg, typeof uarg); throw new badArgumentError(message); } // Verify that a required argument has a value // FIXME(bajtos) "null" should be treated as no value too if (actualType === 'undefined') { if (desc.required) { throw new badArgumentError(name + ' is a required arg'); } else { return undefined; } } if (actualType === 'number' && Number.isNaN(uarg)) { throw new badArgumentError(name + ' must be a number'); } return uarg; } /** * Returns an appropriate type based on a type specifier from remoting * metadata. * @param {Object} type A type specifier from remoting metadata, * e.g. "[Number]" or "MyModel" from `accepts[0].type`. * @returns {String} A type name compatible with the values returned by * `SharedMethod.getType()`, e.g. "string" or "array". */ function convertToBasicRemotingType(type) { if (Array.isArray(type)) { return type.map(convertToBasicRemotingType); } if (typeof type === 'object') { type = type.modelName || type.name; } type = String(type).toLowerCase(); switch (type) { case 'string': case 'number': case 'date': case 'boolean': case 'buffer': case 'object': case 'file': case 'any': return type; case 'array': return ['any'].map(convertToBasicRemotingType); default: // custom types like MyModel return 'object'; } } function convertValueToTargetType(argName, value, targetType) { switch (targetType) { case 'string': return String(value).valueOf(); case 'date': return new Date(value); case 'number': return Number(value).valueOf(); case 'boolean': return Boolean(value).valueOf(); // Other types such as 'object', 'array', // ModelClass, ['string'], or [ModelClass] default: switch (typeof value) { case 'string': return JSON.parse(value); case 'object': return value; default: throw new badArgumentError(argName + ' must be ' + targetType); } } } /** * Returns an appropriate type based on `val`. * @param {*} val The value to determine the type for * @returns {String} The type name */ SharedMethod.getType = function(val) { var type = typeof val; switch (type) { case 'undefined': case 'boolean': case 'number': case 'function': case 'string': return type; case 'object': // null if (val === null) { return 'null'; } // buffer if (Buffer.isBuffer(val)) { return 'buffer'; } // array if (Array.isArray(val)) { return 'array'; } // date if (val instanceof Date) { return 'date'; } // object return 'object'; } }; /** * Returns a reformatted Object valid for consumption as remoting function * arguments */ SharedMethod.convertArg = function(accept, raw) { if (accept.http && (accept.http.source === 'req' || accept.http.source === 'res' || accept.http.source === 'context' )) { return raw; } if (raw === null || typeof raw !== 'object') { return raw; } if (typeof raw === 'object' && raw.constructor !== Object && raw.constructor !== Array) { // raw is not plain return raw; } var data = traverse(raw).forEach(function(x) { if (x === null || typeof x !== 'object') { return x; } var result = x; if (x.$type === 'base64' || x.$type === 'date') { switch (x.$type) { case 'base64': result = new Buffer(x.$data, 'base64'); break; case 'date': result = new Date(x.$data); break; } this.update(result); } return result; }); return data; }; /** * Returns a reformatted Object valid for consumption as JSON from an Array of * results from a remoting function, based on `returns`. */ SharedMethod.toResult = function(returns, raw, ctx) { var result = {}; if (!returns.length) { return; } returns = returns.filter(function(item, index) { if (index >= raw.length) { return false; } if (ctx && ctx.setReturnArgByName(item.name || item.arg, raw[index])) { return false; } if (item.root) { var isFile = convertToBasicRemotingType(item.type) === 'file'; result = isFile ? raw[index] : convert(raw[index]); return false; } return true; }); returns.forEach(function(item, index) { var name = item.name || item.arg; if (convertToBasicRemotingType(item.type) === 'file') { console.warn('%s: discarded non-root return argument %s of type "file"', this.stringName, name); return; } var value = convert(raw[index]); result[name] = value; }); return result; function convert(val) { switch (SharedMethod.getType(val)) { case 'date': return { $type: 'date', $data: val.toString() }; case 'buffer': return { $type: 'base64', $data: val.toString('base64') }; } return val; } }; /** * Get the function the `SharedMethod` will `invoke()`. */ SharedMethod.prototype.getFunction = function() { var fn; if (!this.ctor) return this.fn; if (this.isStatic) { fn = this.ctor[this.name]; } else { fn = this.ctor.prototype[this.name]; } return fn || this.fn; }; /** * Determine if this shared method invokes the given "suspect" function. * * @example * ```js * sharedMethod.isDelegateFor(myClass.myMethod); // pass a function * sharedMethod.isDelegateFor(myClass.prototype.myInstMethod); * sharedMethod.isDelegateFor('myMethod', true); // check for a static method by name * sharedMethod.isDelegateFor('myInstMethod', false); // instance method by name * ``` * * @param {String|Function} suspect The name of the suspected function * or a `Function`. * @returns Boolean True if the shared method invokes the given function; false otherwise. */ SharedMethod.prototype.isDelegateFor = function(suspect, isStatic) { var type = typeof suspect; isStatic = isStatic || false; if (suspect) { switch (type) { case 'function': return this.getFunction() === suspect; case 'string': if (this.isStatic !== isStatic) return false; return this.name === suspect || this.aliases.indexOf(suspect) !== -1; } } return false; }; /** * Add an alias * * @param {String} alias Alias method name. */ SharedMethod.prototype.addAlias = function(alias) { if (this.aliases.indexOf(alias) === -1) { this.aliases.push(alias); } };