loopback/lib/application.js

674 lines
19 KiB
JavaScript

// Copyright IBM Corp. 2013,2018. All Rights Reserved.
// Node module: loopback
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
/*!
* Module dependencies.
*/
'use strict';
const g = require('./globalize');
const DataSource = require('loopback-datasource-juggler').DataSource;
const Registry = require('./registry');
const assert = require('assert');
const fs = require('fs');
const extend = require('util')._extend;
const RemoteObjects = require('strong-remoting');
const classify = require('underscore.string/classify');
const camelize = require('underscore.string/camelize');
const path = require('path');
const util = require('util');
/**
* The `App` object represents a Loopback application.
*
* The App object extends [Express](http://expressjs.com/api.html#express) and
* supports Express middleware. See
* [Express documentation](http://expressjs.com/) for details.
*
* ```js
* var loopback = require('loopback');
* var app = loopback();
*
* app.get('/', function(req, res){
* res.send('hello world');
* });
*
* app.listen(3000);
* ```
*
* @class LoopBackApplication
* @header var app = loopback()
*/
function App() {
// this is a dummy placeholder for jsdox
}
/*!
* Export the app prototype.
*/
const app = module.exports = {};
/**
* Lazily load a set of [remote objects](http://apidocs.strongloop.com/strong-remoting/#remoteobjectsoptions).
*
* **NOTE:** Calling `app.remotes()` more than once returns only a single set of remote objects.
* @returns {RemoteObjects}
*/
app.remotes = function() {
if (this._remotes) {
return this._remotes;
} else {
let options = {};
if (this.get) {
options = this.get('remoting');
}
return (this._remotes = RemoteObjects.create(options));
}
};
/*!
* Remove a route by reference.
*/
app.disuse = function(route) {
if (this.stack) {
for (let i = 0; i < this.stack.length; i++) {
if (this.stack[i].route === route) {
this.stack.splice(i, 1);
}
}
}
};
/**
* Attach a model to the app. The `Model` will be available on the
* `app.models` object.
*
* Example - Attach an existing model:
```js
* var User = loopback.User;
* app.model(User);
*```
* Example - Attach an existing model, alter some aspects of the model:
* ```js
* var User = loopback.User;
* app.model(User, { dataSource: 'db' });
*```
*
* @param {Object} Model The model to attach.
* @options {Object} config The model's configuration.
* @property {String|DataSource} dataSource The `DataSource` to which to attach the model.
* @property {Boolean} [public] Whether the model should be exposed via REST API.
* @property {Object} [relations] Relations to add/update.
* @end
* @returns {ModelConstructor} the model class
*/
app.model = function(Model, config) {
let isPublic = true;
const registry = this.registry;
if (typeof Model === 'string') {
const msg = 'app.model(modelName, settings) is no longer supported. ' +
'Use app.registry.createModel(modelName, definition) and ' +
'app.model(ModelCtor, config) instead.';
throw new Error(msg);
}
if (arguments.length > 1) {
config = config || {};
configureModel(Model, config, this);
isPublic = config.public !== false;
} else {
assert(Model.prototype instanceof Model.registry.getModel('Model'),
Model.modelName + ' must be a descendant of loopback.Model');
}
const modelName = Model.modelName;
this.models[modelName] =
this.models[classify(modelName)] =
this.models[camelize(modelName)] = Model;
this.models().push(Model);
if (isPublic && Model.sharedClass) {
this.remotes().defineObjectType(Model.modelName, function(data) {
return new Model(data);
});
this.remotes().addClass(Model.sharedClass);
if (Model.settings.trackChanges && Model.Change) {
this.remotes().addClass(Model.Change.sharedClass);
}
clearHandlerCache(this);
this.emit('modelRemoted', Model.sharedClass);
}
const self = this;
Model.on('remoteMethodDisabled', function(model, methodName) {
clearHandlerCache(self);
self.emit('remoteMethodDisabled', model, methodName);
});
Model.on('remoteMethodAdded', function(model) {
clearHandlerCache(self);
self.emit('remoteMethodAdded', model);
});
Model.shared = isPublic;
Model.app = this;
Model.emit('attached', this);
return Model;
};
/**
* Remove all references to a previously registered Model.
*
* The method emits "modelDeleted" event as a counter-part to "modelRemoted"
* event.
*
* @param {String} modelName The name of the model to remove.
*/
app.deleteModelByName = function(modelName) {
const ModelCtor = this.models[modelName];
delete this.models[modelName];
delete this.models[classify(modelName)];
delete this.models[camelize(modelName)];
if (ModelCtor) {
ModelCtor.removeAllListeners();
const ix = this._models.indexOf(ModelCtor);
if (ix > -1) {
this._models.splice(ix, 1);
}
}
const remotes = this.remotes();
remotes.deleteClassByName(modelName);
remotes.deleteTypeByName(modelName);
if (ModelCtor && ModelCtor.dataSource) {
ModelCtor.dataSource.deleteModelByName(modelName);
} else {
this.registry.modelBuilder.deleteModelByName(modelName);
}
clearHandlerCache(this);
this.emit('modelDeleted', ModelCtor || modelName);
};
/**
* Get the models exported by the app. Returns only models defined using `app.model()`
*
* There are two ways to access models:
*
* 1. Call `app.models()` to get a list of all models.
*
* ```js
* var models = app.models();
*
* models.forEach(function(Model) {
* console.log(Model.modelName); // color
* });
* ```
*
* 2. Use `app.models` to access a model by name.
* `app.models` has properties for all defined models.
*
* The following example illustrates accessing the `Product` and `CustomerReceipt` models
* using the `models` object.
*
* ```js
* var loopback = require('loopback');
* var app = loopback();
* app.boot({
* dataSources: {
* db: {connector: 'memory'}
* }
* });
*
* var productModel = app.registry.createModel('product');
* app.model(productModel, {dataSource: 'db'});
* var customerReceiptModel = app.registry.createModel('customer-receipt');
* app.model(customerReceiptModel, {dataSource: 'db'});
*
* // available based on the given name
* var Product = app.models.Product;
*
* // also available as camelCase
* var product = app.models.product;
*
* // multi-word models are avaiable as pascal cased
* var CustomerReceipt = app.models.CustomerReceipt;
*
* // also available as camelCase
* var customerReceipt = app.models.customerReceipt;
* ```
*
* @returns {Array} Array of model classes.
*/
app.models = function() {
return this._models || (this._models = []);
};
/**
* Define a DataSource.
*
* @param {String} name The data source name
* @param {Object} config The data source config
*/
app.dataSource = function(name, config) {
try {
const ds = dataSourcesFromConfig(name, config, this.connectors, this.registry);
this.dataSources[name] =
this.dataSources[classify(name)] =
this.dataSources[camelize(name)] = ds;
ds.app = this;
return ds;
} catch (err) {
if (err.message) {
err.message = g.f('Cannot create data source %s: %s',
JSON.stringify(name), err.message);
}
throw err;
}
};
/**
* Register a connector.
*
* When a new data-source is being added via `app.dataSource`, the connector
* name is looked up in the registered connectors first.
*
* Connectors are required to be explicitly registered only for applications
* using browserify, because browserify does not support dynamic require,
* which is used by LoopBack to automatically load the connector module.
*
* @param {String} name Name of the connector, e.g. 'mysql'.
* @param {Object} connector Connector object as returned
* by `require('loopback-connector-{name}')`.
*/
app.connector = function(name, connector) {
this.connectors[name] =
this.connectors[classify(name)] =
this.connectors[camelize(name)] = connector;
};
/**
* Get all remote objects.
* @returns {Object} [Remote objects](http://apidocs.strongloop.com/strong-remoting/#remoteobjectsoptions).
*/
app.remoteObjects = function() {
const result = {};
this.remotes().classes().forEach(function(sharedClass) {
result[sharedClass.name] = sharedClass.ctor;
});
return result;
};
/*!
* Get a handler of the specified type from the handler cache.
* @triggers `mounted` events on shared class constructors (models)
*/
app.handler = function(type, options) {
const handlers = this._handlers || (this._handlers = {});
if (handlers[type]) {
return handlers[type];
}
const remotes = this.remotes();
const handler = this._handlers[type] = remotes.handler(type, options);
remotes.classes().forEach(function(sharedClass) {
sharedClass.ctor.emit('mounted', app, sharedClass, remotes);
});
return handler;
};
/**
* An object to store dataSource instances.
*/
app.dataSources = app.datasources = {};
/**
* Enable app wide authentication.
*/
app.enableAuth = function(options) {
const AUTH_MODELS = ['User', 'AccessToken', 'ACL', 'Role', 'RoleMapping'];
const remotes = this.remotes();
const app = this;
if (options && options.dataSource) {
const appModels = app.registry.modelBuilder.models;
AUTH_MODELS.forEach(function(m) {
const Model = app.registry.findModel(m);
if (!Model) {
throw new Error(
g.f('Authentication requires model %s to be defined.', m),
);
}
if (Model.dataSource || Model.app) return;
// Find descendants of Model that are attached,
// for example "Customer" extending "User" model
for (const name in appModels) {
const candidate = appModels[name];
const isSubclass = candidate.prototype instanceof Model;
const isAttached = !!candidate.dataSource || !!candidate.app;
if (isSubclass && isAttached) return;
}
app.model(Model, {
dataSource: options.dataSource,
public: m === 'User',
});
});
}
remotes.authorization = function(ctx, next) {
const method = ctx.method;
const req = ctx.req;
const Model = method.ctor;
const modelInstance = ctx.instance;
const modelId = modelInstance && modelInstance.id ||
// replacement for deprecated req.param()
(req.params && req.params.id !== undefined ? req.params.id :
req.body && req.body.id !== undefined ? req.body.id :
req.query && req.query.id !== undefined ? req.query.id :
undefined);
const modelName = Model.modelName;
const modelSettings = Model.settings || {};
let errStatusCode = modelSettings.aclErrorStatus || app.get('aclErrorStatus') || 401;
if (!req.accessToken) {
errStatusCode = 401;
}
if (Model.checkAccess) {
Model.checkAccess(
req.accessToken,
modelId,
method,
ctx,
function(err, allowed) {
if (err) {
console.log(err);
next(err);
} else if (allowed) {
next();
} else {
const messages = {
403: {
message: g.f('Access Denied'),
code: 'ACCESS_DENIED',
},
404: {
message: (g.f('could not find %s with id %s', modelName, modelId)),
code: 'MODEL_NOT_FOUND',
},
401: {
message: g.f('Authorization Required'),
code: 'AUTHORIZATION_REQUIRED',
},
};
const e = new Error(messages[errStatusCode].message || messages[403].message);
e.statusCode = errStatusCode;
e.code = messages[errStatusCode].code || messages[403].code;
next(e);
}
},
);
} else {
next();
}
};
this._verifyAuthModelRelations();
this.isAuthEnabled = true;
};
app._verifyAuthModelRelations = function() {
// Allow unit-tests (but also LoopBack users) to disable the warnings
if (this.get('_verifyAuthModelRelations') === false) return;
const AccessToken = this.registry.findModel('AccessToken');
const User = this.registry.findModel('User');
this.models().forEach(Model => {
if (Model === AccessToken || Model.prototype instanceof AccessToken) {
scheduleVerification(Model, verifyAccessTokenRelations);
}
if (Model === User || Model.prototype instanceof User) {
scheduleVerification(Model, verifyUserRelations);
}
});
function scheduleVerification(Model, verifyFn) {
if (Model.dataSource) {
verifyFn(Model);
} else {
Model.on('attached', () => verifyFn(Model));
}
}
function verifyAccessTokenRelations(Model) {
const belongsToUser = Model.relations && Model.relations.user;
if (belongsToUser) return;
const relationsConfig = Model.settings.relations || {};
const userName = (relationsConfig.user || {}).model;
if (userName) {
console.warn(
'The model %j configures "belongsTo User-like models" relation ' +
'with target model %j. However, the model %j is not attached to ' +
'the application and therefore cannot be used by this relation. ' +
'This typically happens when the application has a custom ' +
'custom User subclass, but does not fix AccessToken relations ' +
'to use this new model.\n' +
'Learn more at http://ibm.biz/setup-loopback-auth',
Model.modelName, userName, userName,
);
return;
}
console.warn(
'The model %j does not have "belongsTo User-like model" relation ' +
'configured.\n' +
'Learn more at http://ibm.biz/setup-loopback-auth',
Model.modelName,
);
}
function verifyUserRelations(Model) {
const hasManyTokens = Model.relations && Model.relations.accessTokens;
if (hasManyTokens) {
// display a temp warning message for users using multiple users config
if (hasManyTokens.polymorphic) {
console.warn(
'The app configuration follows the multiple user models setup ' +
'as described in http://ibm.biz/setup-loopback-auth',
'The built-in role resolver $owner is not currently compatible ' +
'with this configuration and should not be used in production.',
);
}
return;
}
const relationsConfig = Model.settings.relations || {};
const accessTokenName = (relationsConfig.accessTokens || {}).model;
if (accessTokenName) {
console.warn(
'The model %j configures "hasMany AccessToken-like models" relation ' +
'with target model %j. However, the model %j is not attached to ' +
'the application and therefore cannot be used by this relation. ' +
'This typically happens when the application has a custom ' +
'AccessToken subclass, but does not fix User relations to use this ' +
'new model.\n' +
'Learn more at http://ibm.biz/setup-loopback-auth',
Model.modelName, accessTokenName, accessTokenName,
);
return;
}
console.warn(
'The model %j does not have "hasMany AccessToken-like models" relation ' +
'configured.\n' +
'Learn more at http://ibm.biz/setup-loopback-auth',
Model.modelName,
);
}
};
app.boot = function(options) {
throw new Error(
g.f('{{`app.boot`}} was removed, use the new module {{loopback-boot}} instead'),
);
};
function dataSourcesFromConfig(name, config, connectorRegistry, registry) {
let connectorPath;
assert(typeof config === 'object',
'can not create data source without config object');
if (typeof config.connector === 'string') {
const connectorName = config.connector;
if (connectorRegistry[connectorName]) {
config.connector = connectorRegistry[connectorName];
} else {
connectorPath = path.join(__dirname, 'connectors', connectorName + '.js');
if (fs.existsSync(connectorPath)) {
config.connector = require(connectorPath);
}
}
if (config.connector && typeof config.connector === 'object' && !config.connector.name)
config.connector.name = connectorName;
}
return registry.createDataSource(name, config);
}
function configureModel(ModelCtor, config, app) {
assert(ModelCtor.prototype instanceof ModelCtor.registry.getModel('Model'),
ModelCtor.modelName + ' must be a descendant of loopback.Model');
let dataSource = config.dataSource;
if (dataSource) {
if (typeof dataSource === 'string') {
dataSource = app.dataSources[dataSource];
}
assert(
dataSource instanceof DataSource,
ModelCtor.modelName + ' is referencing a dataSource that does not exist: "' +
config.dataSource + '"',
);
}
config = extend({}, config);
config.dataSource = dataSource;
app.registry.configureModel(ModelCtor, config);
}
function clearHandlerCache(app) {
app._handlers = undefined;
}
/**
* Listen for connections and update the configured port.
*
* When there are no parameters or there is only one callback parameter,
* the server will listen on `app.get('host')` and `app.get('port')`.
*
* For example, to listen on host/port configured in app config:
* ```js
* app.listen();
* ```
*
* Otherwise all arguments are forwarded to `http.Server.listen`.
*
* For example, to listen on the specified port and all hosts, and ignore app config.
* ```js
* app.listen(80);
* ```
*
* The function also installs a `listening` callback that calls
* `app.set('port')` with the value returned by `server.address().port`.
* This way the port param contains always the real port number, even when
* listen was called with port number 0.
*
* @param {Function} [cb] If specified, the callback is added as a listener
* for the server's "listening" event.
* @returns {http.Server} A node `http.Server` with this application configured
* as the request handler.
*/
app.listen = function(cb) {
const self = this;
const server = require('http').createServer(this);
server.on('listening', function() {
self.set('port', this.address().port);
let listeningOnAll = false;
let host = self.get('host');
if (!host) {
listeningOnAll = true;
host = this.address().address;
self.set('host', host);
} else if (host === '0.0.0.0' || host === '::') {
listeningOnAll = true;
}
if (!self.get('url')) {
if (listeningOnAll) {
// We are replacing it with localhost to build a URL
// that can be copied and pasted into the browser.
host = 'localhost';
}
const url = 'http://' + host + ':' + self.get('port') + '/';
self.set('url', url);
}
});
const useAppConfig =
arguments.length === 0 ||
(arguments.length == 1 && typeof arguments[0] == 'function');
if (useAppConfig) {
let port = this.get('port');
// NOTE(bajtos) port:undefined no longer works on node@6,
// we must pass port:0 explicitly
if (port === undefined) port = 0;
server.listen(port, this.get('host'), cb);
} else {
server.listen.apply(server, arguments);
}
return server;
};