diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..4ec275ddd --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,39 @@ + + +### Bug or feature request + + + +- [ ] Bug +- [ ] Feature request + +### Description of feature (or steps to reproduce if bug) + + + +### Link to sample repo to reproduce issue (if bug) + + + +### Expected result + + + +### Actual result (if bug) + + + +### Additional information (Node.js version, LoopBack version, etc) + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..58c8d7b2d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +### Description + + +#### Related issues + + + +- None + +### Checklist + +- [ ] New tests are added to cover all changes +- [ ] Code conforms with the [style + guide](http://loopback.io/doc/en/contrib/style-guide.html) diff --git a/.gitignore b/.gitignore index a30033bdf..ba976d833 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ node_modules dist *xunit.xml +.nyc_output/ diff --git a/.jscsrc b/.jscsrc index 8deeea1cf..b2569dc0c 100644 --- a/.jscsrc +++ b/.jscsrc @@ -10,14 +10,11 @@ ], "disallowMultipleVarDecl": "exceptUndefined", "disallowSpacesInsideObjectBrackets": null, + "jsDoc": false, + "requireDotNotation": false, "maximumLineLength": { "value": 150, "allowComments": true, "allowRegex": true - }, - "validateJSDoc": { - "checkParamNames": false, - "checkRedundantParams": false, - "requireParamTypes": true } } diff --git a/.nycrc b/.nycrc new file mode 100644 index 000000000..b64607eee --- /dev/null +++ b/.nycrc @@ -0,0 +1,7 @@ +{ + "exclude": [ + "Gruntfile.js", + "test/**/*.js" + ], + "cache": true +} diff --git a/.strong-pm/env.json b/.strong-pm/env.json deleted file mode 100644 index e69de29bb..000000000 diff --git a/.travis.yml b/.travis.yml index 168369451..bdf39070f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,21 @@ sudo: false language: node_js node_js: - - "0.10" - - "0.12" - - "iojs" + - "4" + - "6" + - "8" +after_success: npm run coverage + +# see https://www.npmjs.com/package/phantomjs-prebuilt#continuous-integration +cache: + directories: + - travis_phantomjs +before_install: + # Upgrade PhantomJS to v2.1.1. + - "export PHANTOMJS_VERSION=2.1.1" + - "export PATH=$PWD/travis_phantomjs/phantomjs-$PHANTOMJS_VERSION-linux-x86_64/bin:$PATH" + - "if [ $(phantomjs --version) != $PHANTOMJS_VERSION ]; then rm -rf $PWD/travis_phantomjs; mkdir -p $PWD/travis_phantomjs; fi" + - "if [ $(phantomjs --version) != $PHANTOMJS_VERSION ]; then wget https://github.com/Medium/phantomjs/releases/download/v$PHANTOMJS_VERSION/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 -O $PWD/travis_phantomjs/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2; fi" + - "if [ $(phantomjs --version) != $PHANTOMJS_VERSION ]; then tar -xvf $PWD/travis_phantomjs/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 -C $PWD/travis_phantomjs; fi" + - "phantomjs --version" diff --git a/CHANGES.md b/CHANGES.md index ea87f5de4..54c931e59 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,432 @@ +2019-06-04, Version 2.42.0 +========================== + + * fix: disallow queries in username and email fields (Hage Yaapa) + + * update README for 2.x EOL (Diana Lau) + + +2019-01-11, Version 2.41.2 +========================== + + * Fix crash when modifying an unknown user (Matheus Horstmann) + + * test: fix User test for custom token generator (Miroslav Bajtoš) + + * test: improve assertion error messages (Miroslav Bajtoš) + + +2018-11-26, Version 2.41.1 +========================== + + * Fix: treat empty access token string as undefined (andrey-abramow) + + * Fix context propagation broken by async@2.x (Miroslav Bajtoš) + + +2018-10-09, Version 2.41.0 +========================== + + * Update LB2 LTS version (Diana Lau) + + +2018-08-08, Version 2.40.0 +========================== + + * fix: accessToken create default acl (virkt25) + + +2018-02-12, Version 2.39.2 +========================== + + * Babelify juggler for Karma tests (Miroslav Bajtoš) + + * Fix Karma config to babelify node_modules too (Miroslav Bajtoš) + + +2018-01-31, Version 2.39.1 +========================== + + * update juggler dep (Taranveer Virk) + + * fix(id): replace with != null (Samuel Reed) + + * fix(AccessContext): Tighten userid/appid checks (Samuel Reed) + + +2017-10-23, Version 2.39.0 +========================== + + * Drop support for Node.js versions 0.10 and 0.12 (Miroslav Bajtoš) + + * test: fix too strict test assertion (Miroslav Bajtoš) + + * Add unit test for empty password (loay) + + * Update translated strings Q2 2017 (Allen Boone) + + +2017-04-17, Version 2.38.3 +========================== + + * use lower version of karma-browserify (Diana Lau) + + * update karma-browserify to 5.x (Diana Lau) + + * update translation msg (Diana Lau) + + * Fix user-literal rewrite for anonymous requests (Aaron Buchanan) + + * Forward options in prepareForTokenInvalidation (Miroslav Bajtoš) + + +2017-03-17, Version 2.38.2 +========================== + + * Fix file patch (Raymond Feng) + + * Add nyc coverage, report data to coveralls.io (Miroslav Bajtoš) + + +2017-03-13, Version 2.38.1 +========================== + + * Fix User.verify to convert uid to string (phairow) + + * Configure Travis CI to cache phantomjs binaries (Miroslav Bajtoš) + + * Improve "filter" arg description (Raymond Camden) + + * Fix creation of verification links (Miroslav Bajtoš) + + * Include link to docs in logoutSessions warning (Miroslav Bajtoš) + + * Fix detection of logoutSessionsOnSensitiveChanges (Miroslav Bajtoš) + + * Preserve sessions on User.save() making no changes (Miroslav Bajtoš) + + * Remove unused dependencies (Miroslav Bajtoš) + + * Fix logout to handle no or missing accessToken (Ritchie Martori) + + * Use English when running Mocha tests (Miroslav Bajtoš) + + * Role model: resolves related models by name (Benjamin Kroeger) + + * Fix User methods to use correct Primary Key (Aris Kemper) + + +2017-01-20, Version 2.38.0 +========================== + + * Add app setting logoutSessionsOnSensitiveChanges (Miroslav Bajtoš) + + * Fix User.resetPassword to call createAccessToken() (João Ribeiro) + + +2017-01-16, Version 2.37.1 +========================== + + * Preserve current session when invalidating tokens (Miroslav Bajtoš) + + * Clean up access-token-invalidation tests (Miroslav Bajtoš) + + +2017-01-09, Version 2.37.0 +========================== + + * Emit resetPasswordRequest event with options (Sergey Reus) + + * Fix false emailVerified on user model update (박대선) + + * Add new flag injectOptionsFromRemoteContext (Miroslav Bajtoš) + + * Contextify DAO and relation methods (Miroslav Bajtoš) + + * Implement new http arg mapping optionsFromRequest (Miroslav Bajtoš) + + * Fix package.json CI downstreamIgnoreList nesting (David Cheung) + + +2016-12-21, Version 2.36.2 +========================== + + * Add option disabling periodic change rectification (kobaska) + + * Release LTS LB2 (Simon Ho) + + * Invalidate AccessTokens on password change (Miroslav Bajtoš) + + * Fix registration of operation hooks in User model (Miroslav Bajtoš) + + * Remove "options.template" from Email payload (Miroslav Bajtoš) + + * Opt-out downstream builds that are unstable (David Cheung) + + * Allow password reset request for users in realms (Bram Borggreve) + + * Add "returnOnlyRoleNames" option to Role.getRoles (Eric) + + * Fix context within listByPrincipalType role method (codyolsen) + + * Add templateFn option to User#verify() (Adrien Kiren) + + * Add options to bulkUpdate (Kogulan Baskaran) + + * Require verification after email change (Loay) + + * adding check of string for case insensitive emails (Dhaval Trivedi) + + * Fix PR template to not link all PRs to #49 (#2887) (Miroslav Bajtoš) + + +2016-10-24, Version 2.36.0 +========================== + + * Need index on principalId for performance. (#2883) (#2884) (Simon Ho) + + * Remove redundant items in PR template (#2877) (#2878) (Simon Ho) + + * Refactor PR template based on feedback (#2865) (#2874) (Simon Ho) + + * Add pull request template (#2843) (#2862) (Simon Ho) + + * Fix description of updateAll response (Miroslav Bajtoš) + + +2016-10-13, Version 2.35.0 +========================== + + * Reword ticking checkbox note in issue template (#2855) (Simon Ho) + + * Add how to tick checkbox in issue template (#2851) (#2853) (Simon Ho) + + * Use GitHub issue templates (#2810) (#2852) (Simon Ho) + + * Allow tokens with eternal TTL (value -1) (Miroslav Bajtoš) + + * Update ja and nl translation files (Candy) + + * Fix support for remote hooks returning a Promise (Tim van der Staaij) + + * Validate non-email property partial update (Loay) + + * Update translation files - round#2 (Candy) + + * Update tests to use registry for model creation (gunjpan) + + * Call new disable remote method from model class. (Richard Pringle) + + * Temporarily disable Karma tests on Windows CI (Miroslav Bajtoš) + + * Add translation files for 2.x (Candy) + + * Allow resetPassword if email is verified (Loay) + + * Add docs for KeyValue model (Simon Ho) + + * Invalidate sessions after email change (Loay) + + * Upgrade loopback-testing to the latest ^1.4 (Miroslav Bajtoš) + + +2016-09-13, Version 2.34.1 +========================== + + * Fix double-slash in confirmation URL (Miroslav Bajtoš) + + +2016-09-12, Version 2.34.0 +========================== + + + +2016-09-09, Version 2.33.0 +========================== + + * Fix data argument for upsertWithWhere (Amir Jafarian) + + * Expose upsertWithWhere (Sonali Samantaray) + + * Fix remoting metadata for "data" arguments (Miroslav Bajtoš) + + * Rework email validation to use isemail (Miroslav Bajtoš) + + +2016-09-05, Version 2.32.0 +========================== + + * test/user: don't attach User model twice (Miroslav Bajtoš) + + * app.enableAuth: correctly detect attached models (Miroslav Bajtoš) + + * Apply g.f to literal strings (Candy) + + * Make the app instance available to connectors (Subramanian Krishnan) + + * Reorder PATCH Vs PUT endpoints (Amir Jafarian) + + * streamline use if `self` (Benjamin Kroeger) + + * resolve related models from correct registry (Benjamin Kroeger) + + * KeyValueModel: add API for listing keys (Miroslav Bajtoš) + + +2016-08-17, Version 2.31.0 +========================== + + * Fix token middleware crash (Carl Fürstenberg) + + * Support 'alias' in mail transport config. (Samuel Reed) + + +2016-08-16, Version 2.30.0 +========================== + + * Revert globalization of Swagger descriptions (Miroslav Bajtoš) + + * Expose `Replace*` methods (Amir Jafarian) + + * Add bcrypt validation (Loay) + + * Cache remoting descriptions to speed up tests (Miroslav Bajtoš) + + * Revert globalization of assert() messages (Miroslav Bajtoš) + + * Fix token middleware to not trigger CLS init (Miroslav Bajtoš) + + * common: add KeyValueModel (Miroslav Bajtoš) + + * Globalize current-context deprecation messages (Miroslav Bajtoš) + + * Deprecate current-context API (Miroslav Bajtoš) + + * test: increase timeout to prevent CI failures (Miroslav Bajtoš) + + * Backport of #2407 (Candy) + + * test: fix timeout in rest.middleware.test (Miroslav Bajtoš) + + * test: fix "socket hang up" error in app.test (Miroslav Bajtoš) + + * test: increate timeout in Role test (Miroslav Bajtoš) + + * test: make status test more robust (Miroslav Bajtoš) + + * test: fix broken Role tests (Miroslav Bajtoš) + + * Update dependencies to their latest versions (Miroslav Bajtoš) + + * Increase timeout (jannyHou) + + * Backport of #2565 (Miroslav Bajtoš) + + * Avoid calling deprecated methds (Amir Jafarian) + + * test: use local registry in test fixtures (Miroslav Bajtoš) + + * Fix test case error (Loay) + + * Backport/Fix security issue 580 (Loay) + + +2016-07-12, Version 2.29.1 +========================== + + * Fix description for User.prototype.hasPassword (Jue Hou) + + * Fix verificationToken bug #2440 (Loay) + + * add missing unit tests for #2108 (Benjamin Kroeger) + + +2016-06-07, Version 2.29.0 +========================== + + * test: increase timeouts on CI (Miroslav Bajtoš) + + * jscsrc: remove jsDoc rule (Miroslav Bajtoš) + + * Deprecate getters for express 3.x middleware (Miroslav Bajtoš) + + * Remove env.json and strong-pm dir (Ritchie Martori) + + * Fix JSCS unsupported rule error (Jason) + + * Resolver support return promise (juehou) + + * Update user.js (Rik) + + * Backport separate error checking and done logic (Simon Ho) + + * Clean up by removing unnecessary comments (Supasate Choochaisri) + + * Add feature to not allow duplicate role name (Supasate Choochaisri) + + * update/insert copyright notices (Ryan Graham) + + * relicense as MIT only (Ryan Graham) + + * Upgrade phantomjs to 2.x (Miroslav Bajtoš) + + * app: send port:0 instead of port:undefined (Miroslav Bajtoš) + + * travis: drop node@5, add node@6 (Miroslav Bajtoš) + + * Disable DEBUG output for eslint on Jenkins CI (Miroslav Bajtoš) + + * test/rest.middleware: use local registry (Miroslav Bajtoš) + + * Fix role.isOwner to support app-local registry (Miroslav Bajtoš) + + * test/user: use local registry (Miroslav Bajtoš) + + +2016-05-02, Version 2.28.0 +========================== + + * Add new feature to emit a `remoteMethodDisabled` event when disabling a remote method. (Supasate Choochaisri) + + * Fix typo in Model.nestRemoting (Tim Needham) + + * Allow built-in token middleware to run repeatedly (Benjamin Kröger) + + * Improve error message on connector init error (Miroslav Bajtoš) + + * application: correct spelling of "cannont" (Sam Roberts) + + +2016-02-19, Version 2.27.0 +========================== + + * Remove sl-blip from dependency (Candy) + + * Fix race condition in replication tests (Miroslav Bajtoš) + + * test: remove errant console.log from test (Ryan Graham) + + * Promisify Model Change (Jue Hou) + + * Fix race condition in error handler test (Miroslav Bajtoš) + + * Travis: drop iojs, add v4.x and v5.x (Miroslav Bajtoš) + + * Correct JSDoc findOrCreate() callback in PersistedModel (Miroslav Bajtoš) + + * Hide verificationToken (Miroslav Bajtoš) + + * test: use ephemeral port for e2e server (Ryan Graham) + + * test: fail on error instead of crash (Ryan Graham) + + * ensure app is booted before integration tests (Ryan Graham) + + * Checkpoint speedup (Amir Jafarian) + + * Pull in API doc fix from PR into master #1910 (crandmck) + + 2015-12-22, Version 2.26.2 ========================== @@ -989,8 +1418,6 @@ 2014-07-15, Version 2.0.0-beta6 =============================== - * 2.0.0-beta6 (Miroslav Bajtoš) - * lib/application: publish Change models to REST API (Miroslav Bajtoš) * models/change: fix typo (Miroslav Bajtoš) @@ -1001,8 +1428,6 @@ 2014-07-03, Version 2.0.0-beta5 =============================== - * 2.0.0-beta5 (Miroslav Bajtoš) - * app: update `url` on `listening` event (Miroslav Bajtoš) * Fix "ReferenceError: loopback is not defined" in registry.memory(). (Guilherme Cirne) @@ -1021,8 +1446,6 @@ 2014-06-26, Version 2.0.0-beta4 =============================== - * 2.0.0-beta4 (Miroslav Bajtoš) - * package: upgrade juggler to 2.0.0-beta2 (Miroslav Bajtoš) * Fix loopback in PhantomJS, fix karma tests (Miroslav Bajtoš) @@ -1149,8 +1572,6 @@ 2014-05-28, Version 2.0.0-beta3 =============================== - * 2.0.0-beta3 (Miroslav Bajtoš) - * package.json: fix malformed json (Miroslav Bajtoš) * 2.0.0-beta2 (Ritchie Martori) diff --git a/Gruntfile.js b/Gruntfile.js index a7a755c81..4ff9df7f6 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + /*global module:false*/ module.exports = function(grunt) { @@ -39,9 +44,6 @@ module.exports = function(grunt) { common: { src: ['common/**/*.js'] }, - browser: { - src: ['browser/**/*.js'] - }, server: { src: ['server/**/*.js'] }, @@ -54,7 +56,6 @@ module.exports = function(grunt) { lib: ['lib/**/*.js'], common: ['common/**/*.js'], server: ['server/**/*.js'], - browser: ['browser/**/*.js'], test: ['test/**/*.js'] }, watch: { @@ -87,7 +88,8 @@ module.exports = function(grunt) { src: 'test/*.js', options: { reporter: 'dot', - } + require: require.resolve('./test/helpers/use-english.js'), + }, }, 'unit-xml': { src: 'test/*.js', @@ -217,7 +219,14 @@ module.exports = function(grunt) { grunt.registerTask('e2e-server', function() { var done = this.async(); var app = require('./test/fixtures/e2e/app'); - app.listen(3000, done); + app.listen(0, function() { + process.env.PORT = this.address().port; + done(); + }); + }); + + grunt.registerTask('skip-karma-on-windows', function() { + console.log('*** SKIPPING PHANTOM-JS BASED TESTS ON WINDOWS ***'); }); grunt.registerTask('e2e', ['e2e-server', 'karma:e2e']); @@ -229,7 +238,9 @@ module.exports = function(grunt) { 'jscs', 'jshint', process.env.JENKINS_HOME ? 'mochaTest:unit-xml' : 'mochaTest:unit', - 'karma:unit-once']); + process.env.JENKINS_HOME && /^win/.test(process.platform) ? + 'skip-karma-on-windows' : 'karma:unit-once', + ]); // alias for sl-ci-run and `npm test` grunt.registerTask('mocha-and-karma', ['test']); diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..a95641b20 --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2013,2016. All Rights Reserved. +Node module: loopback +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 29d781523..000000000 --- a/LICENSE.md +++ /dev/null @@ -1,9 +0,0 @@ -Copyright (c) 2013-2015 StrongLoop, Inc and other contributors. - -loopback uses a dual license model. - -You may use this library under the terms of the [MIT License][], -or under the terms of the [StrongLoop Subscription Agreement][]. - -[MIT License]: http://opensource.org/licenses/MIT -[StrongLoop Subscription Agreement]: http://strongloop.com/license diff --git a/README.md b/README.md index 3ff4a2c3f..54ff6c343 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/strongloop/loopback?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +**LoopBack 2.x has reached end of life in April 2019. It is no longer supported.** + LoopBack is a highly-extensible, open-source Node.js framework that enables you to: * Create dynamic end-to-end REST APIs with little or no coding. @@ -73,6 +75,20 @@ StrongLoop provides a number of example applications that illustrate various key See [loopback-example](https://github.com/strongloop/loopback-example) for details. +## Module Long Term Support Policy + +LoopBack 2.x has reached end of life in April 2019. It is no longer supported. + +This module adopts the [Module Long Term Support (LTS)](http://github.com/CloudNativeJS/ModuleLTS) policy, with the following End Of Life (EOL) dates: + +| Version | Status | Published | EOL | +| ---------- | --------------- | --------- | -------------------- | +| LoopBack 4 | Current | Oct 2018 | Apr 2021 _(minimum)_ | +| LoopBack 3 | Maintenance LTS | Dec 2016 | Dec 2020 | +| LoopBack 2 | End-of-Life | Jul 2014 | Apr 2019 | + +Learn more about our LTS plan in [docs](https://loopback.io/doc/en/contrib/Long-term-support.html). + ## Resources * [Documentation](http://docs.strongloop.com/display/LB/LoopBack). diff --git a/browser/current-context.js b/browser/current-context.js deleted file mode 100644 index cdf1d8a28..000000000 --- a/browser/current-context.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = function(loopback) { - loopback.getCurrentContext = function() { - return null; - }; - - loopback.runInContext = - loopback.createContext = function() { - throw new Error('Current context is not supported in the browser.'); - }; -}; diff --git a/common/models/access-token.js b/common/models/access-token.js index 27cf5206d..38aad7856 100644 --- a/common/models/access-token.js +++ b/common/models/access-token.js @@ -1,7 +1,14 @@ +// Copyright IBM Corp. 2014,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 Dependencies. */ +var g = require('strong-globalize')(); + var loopback = require('../../lib/loopback'); var assert = require('assert'); var uid = require('uid2'); @@ -96,7 +103,7 @@ module.exports = function(AccessToken) { var id = tokenIdForRequest(req, options); - if (id) { + if (id != null) { this.findById(id, function(err, token) { if (err) { cb(err); @@ -107,7 +114,7 @@ module.exports = function(AccessToken) { } else if (isValid) { cb(null, token); } else { - var e = new Error('Invalid Access Token'); + var e = new Error(g.f('Invalid Access Token')); e.status = e.statusCode = 401; e.code = 'INVALID_TOKEN'; cb(e); @@ -142,11 +149,19 @@ module.exports = function(AccessToken) { assert(this.ttl, 'token.ttl must exist'); assert(this.ttl >= -1, 'token.ttl must be >= -1'); + var AccessToken = this.constructor; + var userRelation = AccessToken.relations.user; // may not be set up + var User = userRelation && userRelation.modelTo; + var now = Date.now(); var created = this.created.getTime(); var elapsedSeconds = (now - created) / 1000; var secondsToLive = this.ttl; - var isValid = elapsedSeconds < secondsToLive; + var eternalTokensAllowed = !!(User && User.settings.allowEternalTokens); + var isEternalToken = secondsToLive === -1; + var isValid = isEternalToken ? + eternalTokensAllowed : + elapsedSeconds < secondsToLive; if (isValid) { cb(null, isValid); @@ -194,6 +209,11 @@ module.exports = function(AccessToken) { if (typeof id === 'string') { // Add support for oAuth 2.0 bearer token // http://tools.ietf.org/html/rfc6750 + + // To prevent Error: Model::findById requires the id argument + // with loopback-datasource-juggler 2.56.0+ + if (id === '') continue; + if (id.indexOf('Bearer ') === 0) { id = id.substring(7); // Decode from base64 diff --git a/common/models/access-token.json b/common/models/access-token.json index a5f360c46..b7ee32f5a 100644 --- a/common/models/access-token.json +++ b/common/models/access-token.json @@ -27,12 +27,6 @@ "principalType": "ROLE", "principalId": "$everyone", "permission": "DENY" - }, - { - "principalType": "ROLE", - "principalId": "$everyone", - "property": "create", - "permission": "ALLOW" } ] } diff --git a/common/models/acl.js b/common/models/acl.js index 2a7306b17..96cd79297 100644 --- a/common/models/acl.js +++ b/common/models/acl.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + /*! Schema ACL options @@ -31,6 +36,8 @@ */ +var g = require('strong-globalize')(); + var loopback = require('../../lib/loopback'); var async = require('async'); var assert = require('assert'); @@ -263,7 +270,7 @@ module.exports = function(ACL) { * @return {Object[]} An array of ACLs */ ACL.getStaticACLs = function getStaticACLs(model, property) { - var modelClass = loopback.findModel(model); + var modelClass = this.registry.findModel(model); var staticACLs = []; if (modelClass && modelClass.settings.acls) { modelClass.settings.acls.forEach(function(acl) { @@ -355,7 +362,7 @@ module.exports = function(ACL) { acls = acls.concat(dynACLs); resolved = self.resolvePermission(acls, req); if (resolved && resolved.permission === ACL.DEFAULT) { - var modelClass = loopback.findModel(model); + var modelClass = self.registry.findModel(model); resolved.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW; } if (callback) callback(null, resolved); @@ -387,7 +394,9 @@ module.exports = function(ACL) { */ ACL.checkAccessForContext = function(context, callback) { - var registry = this.registry; + var self = this; + self.resolveRelatedModels(); + var roleModel = self.roleModel; if (!(context instanceof AccessContext)) { context = new AccessContext(context); @@ -410,11 +419,9 @@ module.exports = function(ACL) { var req = new AccessRequest(modelName, property, accessType, ACL.DEFAULT, methodNames); var effectiveACLs = []; - var staticACLs = this.getStaticACLs(model.modelName, property); + var staticACLs = self.getStaticACLs(model.modelName, property); - var self = this; - var roleModel = registry.getModelByType(Role); - this.find({where: {model: model.modelName, property: propertyQuery, + self.find({where: {model: model.modelName, property: propertyQuery, accessType: accessTypeQuery}}, function(err, acls) { if (err) { if (callback) callback(err); @@ -500,10 +507,10 @@ module.exports = function(ACL) { ACL.resolveRelatedModels = function() { if (!this.roleModel) { var reg = this.registry; - this.roleModel = reg.getModelByType(loopback.Role); - this.roleMappingModel = reg.getModelByType(loopback.RoleMapping); - this.userModel = reg.getModelByType(loopback.User); - this.applicationModel = reg.getModelByType(loopback.Application); + this.roleModel = reg.getModelByType('Role'); + this.roleMappingModel = reg.getModelByType('RoleMapping'); + this.userModel = reg.getModelByType('User'); + this.applicationModel = reg.getModelByType('Application'); } }; @@ -530,7 +537,7 @@ module.exports = function(ACL) { break; default: process.nextTick(function() { - var err = new Error('Invalid principal type: ' + type); + var err = new Error(g.f('Invalid principal type: %s', type)); err.statusCode = 400; cb(err); }); diff --git a/common/models/application.js b/common/models/application.js index 617798c15..3286410a5 100644 --- a/common/models/application.js +++ b/common/models/application.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 assert = require('assert'); var utils = require('../../lib/utils'); diff --git a/common/models/change.js b/common/models/change.js index be353a039..cf47082dc 100644 --- a/common/models/change.js +++ b/common/models/change.js @@ -1,9 +1,17 @@ +// Copyright IBM Corp. 2014,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 Dependencies. */ +var g = require('strong-globalize')(); + var PersistedModel = require('../../lib/loopback').PersistedModel; var loopback = require('../../lib/loopback'); +var utils = require('../../lib/utils'); var crypto = require('crypto'); var CJSON = {stringify: require('canonical-json')}; var async = require('async'); @@ -77,6 +85,8 @@ module.exports = function(Change) { var Change = this; var errors = []; + callback = callback || utils.createPromiseCallback(); + var tasks = modelIds.map(function(id) { return function(cb) { Change.findOrCreateChange(modelName, id, function(err, change) { @@ -104,13 +114,14 @@ module.exports = function(Change) { }) .join('\n'); - var msg = 'Cannot rectify ' + modelName + ' changes:\n' + desc; + var msg = g.f('Cannot rectify %s changes:\n%s', modelName, desc); err = new Error(msg); err.details = { errors: errors }; return callback(err); } callback(); }); + return callback.promise; }; /** @@ -137,7 +148,8 @@ module.exports = function(Change) { */ Change.findOrCreateChange = function(modelName, modelId, callback) { - assert(loopback.findModel(modelName), modelName + ' does not exist'); + assert(this.registry.findModel(modelName), modelName + ' does not exist'); + callback = callback || utils.createPromiseCallback(); var id = this.idForModel(modelName, modelId); var Change = this; @@ -155,6 +167,7 @@ module.exports = function(Change) { Change.updateOrCreate(ch, callback); } }); + return callback.promise; }; /** @@ -171,9 +184,7 @@ module.exports = function(Change) { change.debug('rectify change'); - cb = cb || function(err) { - if (err) throw new Error(err); - }; + cb = cb || utils.createPromiseCallback(); change.currentRevision(function(err, rev) { if (err) return cb(err); @@ -194,6 +205,7 @@ module.exports = function(Change) { } ); }); + return cb.promise; function doRectify(checkpoint, rev) { if (rev) { @@ -248,6 +260,7 @@ module.exports = function(Change) { */ Change.prototype.currentRevision = function(cb) { + cb = cb || utils.createPromiseCallback(); var model = this.getModelCtor(); var id = this.getModelId(); model.findById(id, function(err, inst) { @@ -258,6 +271,7 @@ module.exports = function(Change) { cb(null, null); } }); + return cb.promise; }; /** @@ -390,8 +404,11 @@ module.exports = function(Change) { */ Change.diff = function(modelName, since, remoteChanges, callback) { + callback = callback || utils.createPromiseCallback(); + if (!Array.isArray(remoteChanges) || remoteChanges.length === 0) { - return callback(null, {deltas: [], conflicts: []}); + callback(null, {deltas: [], conflicts: []}); + return callback.promise; } var remoteChangeIndex = {}; var modelIds = []; @@ -455,6 +472,7 @@ module.exports = function(Change) { conflicts: conflicts }); }); + return callback.promise; }; /** diff --git a/common/models/checkpoint.js b/common/models/checkpoint.js index 304f83148..b48cb1866 100644 --- a/common/models/checkpoint.js +++ b/common/models/checkpoint.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 Dependencies. */ @@ -27,43 +32,45 @@ module.exports = function(Checkpoint) { * Get the current checkpoint id * @callback {Function} callback * @param {Error} err - * @param {Number} checkpointId The current checkpoint id + * @param {Number} checkpoint The current checkpoint seq */ - Checkpoint.current = function(cb) { var Checkpoint = this; - this.find({ - limit: 1, - order: 'seq DESC' - }, function(err, checkpoints) { - if (err) return cb(err); - var checkpoint = checkpoints[0]; - if (checkpoint) { - cb(null, checkpoint.seq); - } else { - Checkpoint.create({ seq: 1 }, function(err, checkpoint) { - if (err) return cb(err); - cb(null, checkpoint.seq); - }); - } + Checkpoint._getSingleton(function(err, cp) { + cb(err, cp.seq); }); }; - Checkpoint.observe('before save', function(ctx, next) { - if (!ctx.instance) { - // Example: Checkpoint.updateAll() and Checkpoint.updateOrCreate() - return next(new Error('Checkpoint does not support partial updates.')); - } + Checkpoint._getSingleton = function(cb) { + var query = {limit: 1}; // match all instances, return only one + var initialData = {seq: 1}; + this.findOrCreate(query, initialData, cb); + }; - var model = ctx.instance; - if (!model.getId() && model.seq === undefined) { - model.constructor.current(function(err, seq) { - if (err) return next(err); - model.seq = seq + 1; - next(); + /** + * Increase the current checkpoint if it already exists otherwise initialize it + * @callback {Function} callback + * @param {Error} err + * @param {Object} checkpoint The current checkpoint + */ + Checkpoint.bumpLastSeq = function(cb) { + var Checkpoint = this; + Checkpoint._getSingleton(function(err, cp) { + if (err) return cb(err); + var originalSeq = cp.seq; + cp.seq++; + // Update the checkpoint but only if it was not changed under our hands + Checkpoint.updateAll({id: cp.id, seq: originalSeq}, {seq: cp.seq}, function(err, info) { + if (err) return cb(err); + // possible outcomes + // 1) seq was updated to seq+1 - exactly what we wanted! + // 2) somebody else already updated seq to seq+1 and our call was a no-op. + // That should be ok, checkpoints are time based, so we reuse the one created just now + // 3) seq was bumped more than once, so we will be using a value that is behind the latest seq. + // @bajtos is not entirely sure if this is ok, but since it wasn't handled by the current implementation either, + // he thinks we can keep it this way. + cb(null, cp); }); - } else { - next(); - } - }); + }); + }; }; diff --git a/common/models/email.js b/common/models/email.js index f73628027..8ead34aa6 100644 --- a/common/models/email.js +++ b/common/models/email.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + /** * Email model. Extends LoopBack base [Model](#model-new-model). * @property {String} to Email addressee. Required. @@ -10,6 +15,8 @@ * @inherits {Model} */ +var g = require('strong-globalize')(); + module.exports = function(Email) { /** @@ -39,13 +46,13 @@ module.exports = function(Email) { */ Email.send = function() { - throw new Error('You must connect the Email Model to a Mail connector'); + throw new Error(g.f('You must connect the {{Email}} Model to a {{Mail}} connector')); }; /** * A shortcut for Email.send(this). */ Email.prototype.send = function() { - throw new Error('You must connect the Email Model to a Mail connector'); + throw new Error(g.f('You must connect the {{Email}} Model to a {{Mail}} connector')); }; }; diff --git a/common/models/key-value-model.js b/common/models/key-value-model.js new file mode 100644 index 000000000..2c0aeed78 --- /dev/null +++ b/common/models/key-value-model.js @@ -0,0 +1,227 @@ +var g = require('strong-globalize')(); + +/** + * Data model for key-value databases. + * + * @class KeyValueModel + * @inherits {Model} + */ + +module.exports = function(KeyValueModel) { + /** + * Return the value associated with a given key. + * + * @param {String} key Key to use when searching the database. + * @options {Object} options + * @callback {Function} callback + * @param {Error} err Error object. + * @param {Any} result Value associated with the given key. + * @promise + * + * @header KeyValueModel.get(key, cb) + */ + KeyValueModel.get = function(key, options, callback) { + throwNotAttached(this.modelName, 'get'); + }; + + /** + * Persist a value and associate it with the given key. + * + * @param {String} key Key to associate with the given value. + * @param {Any} value Value to persist. + * @options {Number|Object} options Optional settings for the key-value + * pair. If a Number is provided, it is set as the TTL (time to live) in ms + * (milliseconds) for the key-value pair. + * @property {Number} ttl TTL for the key-value pair in ms. + * @callback {Function} callback + * @param {Error} err Error object. + * @promise + * + * @header KeyValueModel.set(key, value, cb) + */ + KeyValueModel.set = function(key, value, options, callback) { + throwNotAttached(this.modelName, 'set'); + }; + + /** + * Set the TTL (time to live) in ms (milliseconds) for a given key. TTL is the + * remaining time before a key-value pair is discarded from the database. + * + * @param {String} key Key to use when searching the database. + * @param {Number} ttl TTL in ms to set for the key. + * @options {Object} options + * @callback {Function} callback + * @param {Error} err Error object. + * @promise + * + * @header KeyValueModel.expire(key, ttl, cb) + */ + KeyValueModel.expire = function(key, ttl, options, callback) { + throwNotAttached(this.modelName, 'expire'); + }; + + /** + * Return the TTL (time to live) for a given key. TTL is the remaining time + * before a key-value pair is discarded from the database. + * + * @param {String} key Key to use when searching the database. + * @options {Object} options + * @callback {Function} callback + * @param {Error} error + * @param {Number} ttl Expiration time for the key-value pair. `undefined` if + * TTL was not initially set. + * @promise + * + * @header KeyValueModel.ttl(key, cb) + */ + KeyValueModel.ttl = function(key, options, callback) { + throwNotAttached(this.modelName, 'ttl'); + }; + + /** + * Return all keys in the database. + * + * **WARNING**: This method is not suitable for large data sets as all + * key-values pairs are loaded into memory at once. For large data sets, + * use `iterateKeys()` instead. + * + * @param {Object} filter An optional filter object with the following + * @param {String} filter.match Glob string used to filter returned + * keys (i.e. `userid.*`). All connectors are required to support `*` and + * `?`, but may also support additional special characters specific to the + * database. + * @param {Object} options + * @callback {Function} callback + * @promise + * + * @header KeyValueModel.keys(filter, cb) + */ + KeyValueModel.keys = function(filter, options, callback) { + throwNotAttached(this.modelName, 'keys'); + }; + + /** + * Asynchronously iterate all keys in the database. Similar to `.keys()` but + * instead allows for iteration over large data sets without having to load + * everything into memory at once. + * + * Callback example: + * ```js + * // Given a model named `Color` with two keys `red` and `blue` + * var iterator = Color.iterateKeys(); + * it.next(function(err, key) { + * // key contains `red` + * it.next(function(err, key) { + * // key contains `blue` + * }); + * }); + * ``` + * + * Promise example: + * ```js + * // Given a model named `Color` with two keys `red` and `blue` + * var iterator = Color.iterateKeys(); + * Promise.resolve().then(function() { + * return it.next(); + * }) + * .then(function(key) { + * // key contains `red` + * return it.next(); + * }); + * .then(function(key) { + * // key contains `blue` + * }); + * ``` + * + * @param {Object} filter An optional filter object with the following + * @param {String} filter.match Glob string to use to filter returned + * keys (i.e. `userid.*`). All connectors are required to support `*` and + * `?`. They may also support additional special characters that are + * specific to the backing database. + * @param {Object} options + * @returns {AsyncIterator} An Object implementing `next(cb) -> Promise` + * function that can be used to iterate all keys. + * + * @header KeyValueModel.iterateKeys(filter) + */ + KeyValueModel.iterateKeys = function(filter, options) { + throwNotAttached(this.modelName, 'iterateKeys'); + }; + + /*! + * Set up remoting metadata for this model. + * + * **Notes**: + * - The method is called automatically by `Model.extend` and/or + * `app.registry.createModel` + * - In general, base models use call this to ensure remote methods are + * inherited correctly, see bug at + * https://github.com/strongloop/loopback/issues/2350 + */ + 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' }, + }); + + this.remoteMethod('keys', { + accepts: { + arg: 'filter', type: 'object', required: false, + http: { source: 'query' }, + }, + returns: { arg: 'keys', type: ['string'], root: true }, + http: { path: '/keys', verb: 'get' }, + }); + }; +}; + +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 000000000..72884e879 --- /dev/null +++ b/common/models/key-value-model.json @@ -0,0 +1,4 @@ +{ + "name": "KeyValueModel", + "base": "Model" +} diff --git a/common/models/role-mapping.js b/common/models/role-mapping.js index ee728483c..53af71f9a 100644 --- a/common/models/role-mapping.js +++ b/common/models/role-mapping.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 loopback = require('../../lib/loopback'); /** diff --git a/common/models/role-mapping.json b/common/models/role-mapping.json index 592f29064..732e24af3 100644 --- a/common/models/role-mapping.json +++ b/common/models/role-mapping.json @@ -11,7 +11,10 @@ "type": "string", "description": "The principal type, such as user, application, or role" }, - "principalId": "string" + "principalId": { + "type": "string", + "index": true + } }, "relations": { "role": { diff --git a/common/models/role.js b/common/models/role.js index a176fec17..a09f7563b 100644 --- a/common/models/role.js +++ b/common/models/role.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 loopback = require('../../lib/loopback'); var debug = require('debug')('loopback:security:role'); var assert = require('assert'); @@ -31,9 +36,9 @@ module.exports = function(Role) { Role.resolveRelatedModels = function() { if (!this.userModel) { var reg = this.registry; - this.roleMappingModel = reg.getModelByType(loopback.RoleMapping); - this.userModel = reg.getModelByType(loopback.User); - this.applicationModel = reg.getModelByType(loopback.Application); + this.roleMappingModel = reg.getModelByType('RoleMapping'); + this.userModel = reg.getModelByType('User'); + this.applicationModel = reg.getModelByType('Application'); } }; @@ -75,26 +80,27 @@ module.exports = function(Role) { }; var model = relsToModels[rel]; - listByPrincipalType(model, relsToTypes[rel], query, callback); + listByPrincipalType(this, model, relsToTypes[rel], query, callback); }; }); /** * Fetch all models assigned to this role * @private + * @param {object} Context role context * @param {*} model model type to fetch * @param {String} [principalType] principalType used in the rolemapping for model * @param {object} [query] query object passed to model find call * @param {Function} [callback] callback function called with `(err, models)` arguments. */ - function listByPrincipalType(model, principalType, query, callback) { + function listByPrincipalType(context, model, principalType, query, callback) { if (callback === undefined) { callback = query; query = {}; } roleModel.roleMappingModel.find({ - where: {roleId: this.id, principalType: principalType} + where: {roleId: context.id, principalType: principalType}, }, function(err, mappings) { var ids; if (err) { @@ -123,8 +129,9 @@ module.exports = function(Role) { /** * Add custom handler for roles. * @param {String} role Name of role. - * @param {Function} resolver Function that determines if a principal is in the specified role. - * Signature must be `function(role, context, callback)` + * @param {Function} resolver Function that determines + * if a principal is in the specified role. + * Should provide a callback or return a promise. */ Role.registerResolver = function(role, resolver) { if (!Role.resolvers) { @@ -147,12 +154,10 @@ module.exports = function(Role) { }); function isUserClass(modelClass) { - if (modelClass) { - return modelClass === loopback.User || - modelClass.prototype instanceof loopback.User; - } else { - return false; - } + if (!modelClass) return false; + var User = modelClass.modelBuilder.models.User; + if (!User) return false; + return modelClass == User || modelClass.prototype instanceof User; } /*! @@ -292,7 +297,14 @@ module.exports = function(Role) { var resolver = Role.resolvers[role]; if (resolver) { debug('Custom resolver found for role %s', role); - resolver(role, context, callback); + + var promise = resolver(role, context, callback); + if (promise && typeof promise.then === 'function') { + promise.then( + function(result) { callback(null, result); }, + callback + ); + } return; } @@ -371,7 +383,12 @@ module.exports = function(Role) { * @param {Error} err Error object. * @param {String[]} roles An array of role IDs */ - Role.getRoles = function(context, callback) { + Role.getRoles = function(context, options, callback) { + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + if (!(context instanceof AccessContext)) { context = new AccessContext(context); } @@ -421,15 +438,24 @@ module.exports = function(Role) { if (principalType && principalId) { // Please find() treat undefined matches all values inRoleTasks.push(function(done) { - roleMappingModel.find({where: {principalType: principalType, - principalId: principalId}}, function(err, mappings) { + var filter = {where: {principalType: principalType, principalId: principalId}}; + if (options.returnOnlyRoleNames === true) { + filter.include = ['role']; + } + roleMappingModel.find(filter, function(err, mappings) { debug('Role mappings found: %s %j', err, mappings); if (err) { if (done) done(err); return; } mappings.forEach(function(m) { - addRole(m.roleId); + var role; + if (options.returnOnlyRoleNames === true) { + role = m.toJSON().role.name; + } else { + role = m.roleId; + } + addRole(role); }); if (done) done(); }); @@ -442,4 +468,6 @@ module.exports = function(Role) { if (callback) callback(err, roles); }); }; + + Role.validatesUniquenessOf('name', { message: 'already exists' }); }; diff --git a/common/models/scope.js b/common/models/scope.js index 3c713b535..478124d2a 100644 --- a/common/models/scope.js +++ b/common/models/scope.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 assert = require('assert'); var loopback = require('../../lib/loopback'); diff --git a/common/models/user.js b/common/models/user.js index b91b3ca18..73bbda792 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -1,13 +1,22 @@ +// Copyright IBM Corp. 2014,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 Dependencies. */ +var g = require('strong-globalize')(); + +var isEmail = require('isemail'); var loopback = require('../../lib/loopback'); var utils = require('../../lib/utils'); var path = require('path'); +var qs = require('querystring'); var SALT_WORK_FACTOR = 10; var crypto = require('crypto'); - +var MAX_PASSWORD_LENGTH = 72; var bcrypt; try { // Try the native module first @@ -199,23 +208,44 @@ module.exports = function(User) { var query = self.normalizeCredentials(credentials, realmRequired, realmDelimiter); - if (realmRequired && !query.realm) { - var err1 = new Error('realm is required'); - err1.statusCode = 400; - err1.code = 'REALM_REQUIRED'; - fn(err1); - return fn.promise; + if (realmRequired) { + if (!query.realm) { + var err1 = new Error(g.f('{{realm}} is required')); + err1.statusCode = 400; + err1.code = 'REALM_REQUIRED'; + fn(err1); + return fn.promise; + } else if (typeof query.realm !== 'string') { + var err5 = new Error(g.f('Invalid realm')); + err5.statusCode = 400; + err5.code = 'INVALID_REALM'; + fn(err5); + return fn.promise; + } } if (!query.email && !query.username) { - var err2 = new Error('username or email is required'); + var err2 = new Error(g.f('{{username}} or {{email}} is required')); err2.statusCode = 400; err2.code = 'USERNAME_EMAIL_REQUIRED'; fn(err2); return fn.promise; } + if (query.username && typeof query.username !== 'string') { + var err3 = new Error(g.f('Invalid username')); + err3.statusCode = 400; + err3.code = 'INVALID_USERNAME'; + fn(err3); + return fn.promise; + } else if (query.email && typeof query.email !== 'string') { + var err4 = new Error(g.f('Invalid email')); + err4.statusCode = 400; + err4.code = 'INVALID_EMAIL'; + fn(err4); + return fn.promise; + } self.findOne({where: query}, function(err, user) { - var defaultError = new Error('login failed'); + var defaultError = new Error(g.f('login failed')); defaultError.statusCode = 401; defaultError.code = 'LOGIN_FAILED'; @@ -245,7 +275,7 @@ module.exports = function(User) { if (self.settings.emailVerificationRequired && !user.emailVerified) { // Fail to log in if email verification is not done yet debug('User email has not been verified'); - err = new Error('login failed as the email has not been verified'); + err = new Error(g.f('login failed as the email has not been verified')); err.statusCode = 401; err.code = 'LOGIN_FAILED_EMAIL_NOT_VERIFIED'; fn(err); @@ -285,23 +315,49 @@ module.exports = function(User) { User.logout = function(tokenId, fn) { fn = fn || utils.createPromiseCallback(); - this.relations.accessTokens.modelTo.findById(tokenId, function(err, accessToken) { + + if (!tokenId) { + var err = new Error(g.f('{{accessToken}} is required to logout')); + err.status = 401; + process.nextTick(function() { fn(err); }); + return fn.promise; + } + + this.relations.accessTokens.modelTo.destroyById(tokenId, function(err, info) { if (err) { fn(err); - } else if (accessToken) { - accessToken.destroy(fn); + } else if ('count' in info && info.count === 0) { + err = new Error(g.f('Could not find {{accessToken}}')); + err.status = 401; + fn(err); } else { - fn(new Error('could not find accessToken')); + fn(); } }); return fn.promise; }; + User.observe('before delete', function(ctx, next) { + var AccessToken = ctx.Model.relations.accessTokens.modelTo; + var pkName = ctx.Model.definition.idName() || 'id'; + ctx.Model.find({ where: ctx.where, fields: [pkName] }, function(err, list) { + if (err) return next(err); + + var ids = list.map(function(u) { return u[pkName]; }); + ctx.where = {}; + ctx.where[pkName] = { inq: ids }; + + AccessToken.destroyAll({ userId: { inq: ids }}, next); + }); + }); + /** * Compare the given `password` with the users hashed password. * * @param {String} password The plain text password - * @returns {Boolean} + * @callback {Function} callback Callback function + * @param {Error} err Error object + * @param {Boolean} isMatch Returns true if the given `password` matches record */ User.prototype.hasPassword = function(plain, fn) { @@ -341,6 +397,10 @@ module.exports = function(User) { * @property {String} text Text of email. * @property {String} template Name of template that displays verification * page, for example, `'verify.ejs'. + * @property {Function} templateFn A function generating the email HTML body + * from `verify()` options object and generated attributes like `options.verifyHref`. + * It must accept the option object and a callback function with `(err, html)` + * as parameters * @property {String} redirect Page to which user will be redirected after * they verify their email, for example `'/'` for root URI. * @property {Function} generateVerificationToken A function to be used to @@ -356,6 +416,7 @@ module.exports = function(User) { var user = this; var userModel = this.constructor; var registry = userModel.registry; + var pkName = userModel.definition.idName() || 'id'; assert(typeof options === 'object', 'options required when calling user.verify()'); assert(options.type, 'You must supply a verification type (options.type)'); assert(options.type === 'email', 'Unsupported verification type'); @@ -377,18 +438,24 @@ module.exports = function(User) { (options.protocol === 'https' && options.port == '443') ) ? '' : ':' + options.port; + var urlPath = joinUrlPath( + options.restApiRoot, + userModel.http.path, + userModel.sharedClass.find('confirm', true).http.path + ); + options.verifyHref = options.verifyHref || options.protocol + '://' + options.host + displayPort + - options.restApiRoot + - userModel.http.path + - userModel.sharedClass.find('confirm', true).http.path + - '?uid=' + - options.user.id + - '&redirect=' + - options.redirect; + urlPath + + '?' + qs.stringify({ + uid: '' + options.user[pkName], + redirect: options.redirect, + }); + + options.templateFn = options.templateFn || createVerificationEmailBody; // Email model var Email = options.mailer || this.constructor.email || registry.getModelByType(loopback.Email); @@ -413,30 +480,50 @@ module.exports = function(User) { function sendEmail(user) { options.verifyHref += '&token=' + user.verificationToken; - options.text = options.text || 'Please verify your email by opening this link in a web browser:\n\t{href}'; + options.text = options.text || g.f('Please verify your email by opening ' + + 'this link in a web browser:\n\t%s', options.verifyHref); - options.text = options.text.replace('{href}', options.verifyHref); + options.text = options.text.replace(/\{href\}/g, options.verifyHref); options.to = options.to || user.email; - options.subject = options.subject || 'Thanks for Registering'; + options.subject = options.subject || g.f('Thanks for Registering'); options.headers = options.headers || {}; - var template = loopback.template(options.template); - options.html = template(options); - - Email.send(options, function(err, email) { + options.templateFn(options, function(err, html) { if (err) { fn(err); } else { - fn(null, {email: email, token: user.verificationToken, uid: user.id}); + setHtmlContentAndSend(html); } }); + + function setHtmlContentAndSend(html) { + options.html = html; + + // Remove options.template to prevent rejection by certain + // nodemailer transport plugins. + delete options.template; + + Email.send(options, function(err, email) { + if (err) { + fn(err); + } else { + fn(null, {email: email, token: user.verificationToken, uid: user[pkName]}); + } + }); + } } return fn.promise; }; + function createVerificationEmailBody(options, cb) { + var template = loopback.template(options.template); + var body = template(options); + cb(null, body); + } + /** * A default verification token generator which accepts the user the token is * being generated for and a callback function to indicate completion. @@ -469,7 +556,7 @@ module.exports = function(User) { fn(err); } else { if (user && user.verificationToken === token) { - user.verificationToken = undefined; + user.verificationToken = null; user.emailVerified = true; user.save(function(err) { if (err) { @@ -480,11 +567,11 @@ module.exports = function(User) { }); } else { if (user) { - err = new Error('Invalid token: ' + token); + err = new Error(g.f('Invalid token: %s', token)); err.statusCode = 400; err.code = 'INVALID_TOKEN'; } else { - err = new Error('User not found: ' + uid); + err = new Error(g.f('User not found: %s', uid)); err.statusCode = 404; err.code = 'USER_NOT_FOUND'; } @@ -496,11 +583,12 @@ module.exports = function(User) { }; /** - * Create a short lived acess token for temporary login. Allows users + * Create a short lived access token for temporary login. Allows users * to change passwords if forgotten. * * @options {Object} options - * @prop {String} email The user's email address + * @property {String} email The user's email address + * @property {String} realm The user's realm (optional) * @callback {Function} callback * @param {Error} err */ @@ -509,29 +597,48 @@ module.exports = function(User) { cb = cb || utils.createPromiseCallback(); var UserModel = this; var ttl = UserModel.settings.resetPasswordTokenTTL || DEFAULT_RESET_PW_TTL; - options = options || {}; if (typeof options.email !== 'string') { - var err = new Error('Email is required'); + var err = new Error(g.f('Email is required')); err.statusCode = 400; err.code = 'EMAIL_REQUIRED'; cb(err); return cb.promise; } - UserModel.findOne({ where: {email: options.email} }, function(err, user) { + try { + if (options.password) { + UserModel.validatePassword(options.password); + } + } catch (err) { + return cb(err); + } + var where = { + email: options.email + }; + if (options.realm) { + where.realm = options.realm; + } + UserModel.findOne({ where: where }, function(err, user) { if (err) { return cb(err); } if (!user) { - err = new Error('Email not found'); + err = new Error(g.f('Email not found')); err.statusCode = 404; err.code = 'EMAIL_NOT_FOUND'; return cb(err); } // create a short lived access token for temp login to change password // TODO(ritch) - eventually this should only allow password change - user.accessTokens.create({ttl: ttl}, function(err, accessToken) { + if (UserModel.settings.emailVerificationRequired && !user.emailVerified) { + err = new Error(g.f('Email has not been verified')); + err.statusCode = 401; + err.code = 'RESET_FAILED_EMAIL_NOT_VERIFIED'; + return cb(err); + } + + user.createAccessToken(ttl, function(err, accessToken) { if (err) { return cb(err); } @@ -539,7 +646,8 @@ module.exports = function(User) { UserModel.emit('resetPasswordRequest', { email: options.email, accessToken: accessToken, - user: user + user: user, + options: options, }); }); }); @@ -557,14 +665,45 @@ module.exports = function(User) { }; User.validatePassword = function(plain) { - if (typeof plain === 'string' && plain) { + var err; + if (plain && typeof plain === 'string' && plain.length <= MAX_PASSWORD_LENGTH) { return true; } - var err = new Error('Invalid password: ' + plain); + if (plain.length > MAX_PASSWORD_LENGTH) { + err = new Error(g.f('Password too long: %s', plain)); + err.code = 'PASSWORD_TOO_LONG'; + } else { + err = new Error(g.f('Invalid password: %s', plain)); + err.code = 'INVALID_PASSWORD'; + } err.statusCode = 422; throw err; }; + User._invalidateAccessTokensOfUsers = function(userIds, options, cb) { + if (typeof options === 'function' && cb === undefined) { + cb = options; + options = {}; + } + + if (!Array.isArray(userIds) || !userIds.length) + return process.nextTick(cb); + + var accessTokenRelation = this.relations.accessTokens; + if (!accessTokenRelation) + return process.nextTick(cb); + + var AccessToken = accessTokenRelation.modelTo; + + var query = {userId: {inq: userIds}}; + var tokenPK = AccessToken.definition.idName() || 'id'; + if (options.accessToken && tokenPK in options.accessToken) { + query[tokenPK] = {neq: options.accessToken[tokenPK]}; + } + + AccessToken.deleteAll(query, options, cb); + }; + /*! * Setup an extended user model. */ @@ -599,14 +738,6 @@ module.exports = function(User) { } }; - // Access token to normalize email credentials - UserModel.observe('access', function normalizeEmailCase(ctx, next) { - if (!ctx.Model.settings.caseSensitiveEmail && ctx.query.where && ctx.query.where.email) { - ctx.query.where.email = ctx.query.where.email.toLowerCase(); - } - next(); - }); - // Make sure emailVerified is not set by creation UserModel.beforeRemote('create', function(ctx, user, next) { var body = ctx.req.body; @@ -622,17 +753,18 @@ module.exports = function(User) { description: 'Login a user with username/email and password.', accepts: [ {arg: 'credentials', type: 'object', required: true, http: {source: 'body'}}, - {arg: 'include', type: ['string'], http: {source: 'query' }, + {arg: 'include', type: ['string'], http: {source: 'query'}, description: 'Related objects to include in the response. ' + - 'See the description of return value for more details.'} + 'See the description of return value for more details.' }, ], returns: { arg: 'accessToken', type: 'object', root: true, description: - 'The response body contains properties of the AccessToken created on login.\n' + + g.f('The response body contains properties of the {{AccessToken}} created on login.\n' + 'Depending on the value of `include` parameter, the body may contain ' + 'additional properties:\n\n' + - ' - `user` - `{User}` - Data of the currently logged in user. (`include=user`)\n\n' + ' - `user` - `U+007BUserU+007D` - Data of the currently logged in user. ' + + '{{(`include=user`)}}\n\n'), }, http: {verb: 'post'} } @@ -643,15 +775,14 @@ module.exports = function(User) { { description: 'Logout a user with access token.', accepts: [ - {arg: 'access_token', type: 'string', required: true, http: function(ctx) { - var req = ctx && ctx.req; - var accessToken = req && req.accessToken; - var tokenID = accessToken && accessToken.id; - - return tokenID; - }, description: 'Do not supply this argument, it is automatically extracted ' + - 'from request headers.' - } + {arg: 'access_token', type: 'string', http: function(ctx) { + var req = ctx && ctx.req; + var accessToken = req && req.accessToken; + var tokenID = accessToken ? accessToken.id : undefined; + return tokenID; + }, description: 'Do not supply this argument, it is automatically extracted ' + + 'from request headers.', + }, ], http: {verb: 'all'} } @@ -684,7 +815,7 @@ module.exports = function(User) { UserModel.afterRemote('confirm', function(ctx, inst, next) { if (ctx.args.redirect !== undefined) { if (!ctx.res) { - return next(new Error('The transport does not support HTTP redirects.')); + return next(new Error(g.f('The transport does not support HTTP redirects.'))); } ctx.res.location(ctx.args.redirect); ctx.res.status(302); @@ -699,10 +830,9 @@ module.exports = function(User) { assert(loopback.AccessToken, 'AccessToken model must be defined before User model'); UserModel.accessToken = loopback.AccessToken; - // email validation regex - var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; - - UserModel.validatesFormatOf('email', {with: re, message: 'Must provide a valid email'}); + UserModel.validate('email', emailValidator, { + message: g.f('Must provide a valid email') + }); // FIXME: We need to add support for uniqueness of composite keys in juggler if (!(UserModel.settings.realmRequired || UserModel.settings.realmDelimiter)) { @@ -710,6 +840,34 @@ module.exports = function(User) { UserModel.validatesUniquenessOf('username', {message: 'User already exists'}); } + UserModel.once('attached', function() { + if (UserModel.app.get('logoutSessionsOnSensitiveChanges') !== undefined) + return; + + g.warn([ + '', + 'The user model %j is attached to an application that does not specify', + 'whether other sessions should be invalidated when a password or', + 'an email has changed. Session invalidation is important for security', + 'reasons as it allows users to recover from various account breach', + 'situations.', + '', + 'We recommend turning this feature on by setting', + '"{{logoutSessionsOnSensitiveChanges}}" to {{true}} in', + '{{server/config.json}} (unless you have implemented your own solution', + 'for token invalidation).', + '', + 'We also recommend enabling "{{injectOptionsFromRemoteContext}}" in', + '%s\'s settings (typically via common/models/*.json file).', + 'This setting is required for the invalidation algorithm to keep ', + 'the current session valid.', + '', + 'Learn more in our documentation at', + 'https://loopback.io/doc/en/lb2/AccessToken-invalidation.html', + '', + ].join('\n'), UserModel.modelName, UserModel.modelName); + }); + return UserModel; }; @@ -719,4 +877,112 @@ module.exports = function(User) { User.setup(); + // --- OPERATION HOOKS --- + // + // Important: Operation hooks are inherited by subclassed models, + // therefore they must be registered outside of setup() function + + // Access token to normalize email credentials + User.observe('access', function normalizeEmailCase(ctx, next) { + if (!ctx.Model.settings.caseSensitiveEmail && ctx.query.where && + ctx.query.where.email && typeof(ctx.query.where.email) === 'string') { + ctx.query.where.email = ctx.query.where.email.toLowerCase(); + } + next(); + }); + + User.observe('before save', function prepareForTokenInvalidation(ctx, next) { + var invalidationEnabled = ctx.Model.app && + ctx.Model.app.get('logoutSessionsOnSensitiveChanges'); + if (!invalidationEnabled) return next(); + + if (ctx.isNewInstance) return next(); + if (!ctx.where && !ctx.instance) return next(); + + var pkName = ctx.Model.definition.idName() || 'id'; + + var where = ctx.where; + if (!where) { + where = {}; + where[pkName] = ctx.instance[pkName]; + } + + ctx.Model.find({where: where}, ctx.options, function(err, userInstances) { + if (err) return next(err); + ctx.hookState.originalUserData = userInstances.map(function(u) { + var user = {}; + user[pkName] = u[pkName]; + user.email = u.email; + user.password = u.password; + return user; + }); + var emailChanged; + if (ctx.instance) { + // Check if map does not return an empty array + // Fix server crashes when try to PUT a non existent id + if (ctx.hookState.originalUserData.length > 0) { + emailChanged = ctx.instance.email !== ctx.hookState.originalUserData[0].email; + } else { + emailChanged = true; + } + + if (emailChanged && ctx.Model.settings.emailVerificationRequired) { + ctx.instance.emailVerified = false; + } + } else if (ctx.data.email) { + emailChanged = ctx.hookState.originalUserData.some(function(data) { + return data.email != ctx.data.email; + }); + if (emailChanged && ctx.Model.settings.emailVerificationRequired) { + ctx.data.emailVerified = false; + } + } + + next(); + }); + }); + + User.observe('after save', function invalidateOtherTokens(ctx, next) { + var invalidationEnabled = ctx.Model.app && + ctx.Model.app.get('logoutSessionsOnSensitiveChanges'); + if (!invalidationEnabled) return next(); + + if (!ctx.instance && !ctx.data) return next(); + if (!ctx.hookState.originalUserData) return next(); + + var pkName = ctx.Model.definition.idName() || 'id'; + var newEmail = (ctx.instance || ctx.data).email; + var newPassword = (ctx.instance || ctx.data).password; + + if (!newEmail && !newPassword) return next(); + + var userIdsToExpire = ctx.hookState.originalUserData.filter(function(u) { + return (newEmail && u.email !== newEmail) || + (newPassword && u.password !== newPassword); + }).map(function(u) { + return u[pkName]; + }); + ctx.Model._invalidateAccessTokensOfUsers(userIdsToExpire, ctx.options, next); + }); }; + +function emailValidator(err, done) { + var value = this.email; + if (value == null) + return; + if (typeof value !== 'string') + return err('string'); + if (value === '') return; + if (!isEmail(value)) + return err('email'); +} + +function joinUrlPath(args) { + var result = arguments[0]; + for (var ix = 1; ix < arguments.length; ix++) { + var next = arguments[ix]; + result += result[result.length - 1] === '/' && next[0] === '/' ? + next.slice(1) : next; + } + return result; +} diff --git a/common/models/user.json b/common/models/user.json index d70a89d3f..a3f3973fc 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -32,7 +32,7 @@ "options": { "caseSensitiveEmail": true }, - "hidden": ["password"], + "hidden": ["password", "verificationToken"], "acls": [ { "principalType": "ROLE", @@ -75,6 +75,12 @@ "permission": "ALLOW", "property": "updateAttributes" }, + { + "principalType": "ROLE", + "principalId": "$owner", + "permission": "ALLOW", + "property": "replaceById" + }, { "principalType": "ROLE", "principalId": "$everyone", diff --git a/docs.json b/docs.json index c99680b27..c951ec91f 100644 --- a/docs.json +++ b/docs.json @@ -5,7 +5,7 @@ "lib/server-app.js", "lib/loopback.js", "lib/registry.js", - "server/current-context.js", + "lib/current-context.js", "lib/access-context.js", { "title": "Base models", "depth": 2 }, "lib/model.js", @@ -24,6 +24,7 @@ "common/models/application.js", "common/models/change.js", "common/models/email.js", + "common/models/key-value-model.js", "common/models/role.js", "common/models/role-mapping.js", "common/models/scope.js", diff --git a/example/client-server/client.js b/example/client-server/client.js index 436e266b8..63cd4e2e9 100644 --- a/example/client-server/client.js +++ b/example/client-server/client.js @@ -1,3 +1,10 @@ +// Copyright IBM Corp. 2014,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 g = require('strong-globalize')(); + var loopback = require('../../'); var client = loopback(); var CartItem = require('./models').CartItem; @@ -11,10 +18,10 @@ CartItem.attachTo(remote); // call the remote method CartItem.sum(1, function(err, total) { - console.log('result:', err || total); + g.log('result:%s', err || total); }); // call a built in remote method CartItem.find(function(err, items) { - console.log(items); + g.log(items); }); diff --git a/example/client-server/models.js b/example/client-server/models.js index 34d5c8bac..ac1b14320 100644 --- a/example/client-server/models.js +++ b/example/client-server/models.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 loopback = require('../../'); var CartItem = exports.CartItem = loopback.PersistedModel.extend('CartItem', { diff --git a/example/client-server/server.js b/example/client-server/server.js index 7e466a563..52c738d3a 100644 --- a/example/client-server/server.js +++ b/example/client-server/server.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 loopback = require('../../'); var server = module.exports = loopback(); var CartItem = require('./models').CartItem; diff --git a/example/colors/app.js b/example/colors/app.js index e182f926b..44d75c493 100644 --- a/example/colors/app.js +++ b/example/colors/app.js @@ -1,3 +1,10 @@ +// 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 g = require('strong-globalize')(); + var loopback = require('../../'); var app = loopback(); @@ -7,9 +14,9 @@ var schema = { name: String }; -var Color = app.model('color', schema); - -app.dataSource('db', {adapter: 'memory'}).attach(Color); +app.dataSource('db', { connector: 'memory' }); +var Color = app.registry.createModel('color', schema); +app.model(Color, { dataSource: 'db' }); Color.create({name: 'red'}); Color.create({name: 'green'}); @@ -17,4 +24,4 @@ Color.create({name: 'blue'}); app.listen(3000); -console.log('a list of colors is available at http://localhost:3000/colors'); +g.log('a list of colors is available at {{http://localhost:3000/colors}}'); diff --git a/example/context/app.js b/example/context/app.js index 12cedc078..8cf43e7e7 100644 --- a/example/context/app.js +++ b/example/context/app.js @@ -1,3 +1,10 @@ +// Copyright IBM Corp. 2014,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 g = require('strong-globalize')(); + var loopback = require('../../'); var app = loopback(); @@ -17,7 +24,7 @@ var Color = loopback.createModel('color', { 'name': String }); Color.beforeRemote('**', function (ctx, unused, next) { // Inside LoopBack code, you can read the property from the context var ns = loopback.getCurrentContext(); - console.log('Request to host', ns && ns.get('host')); + g.log('Request to host %s', ns && ns.get('host')); next(); }); @@ -25,5 +32,5 @@ app.dataSource('db', { connector: 'memory' }); app.model(Color, { dataSource: 'db' }); app.listen(3000, function() { - console.log('A list of colors is available at http://localhost:3000/colors'); + g.log('A list of colors is available at {{http://localhost:3000/colors}}'); }); diff --git a/example/mobile-models/app.js b/example/mobile-models/app.js index e7e3c582b..fe5f4765d 100644 --- a/example/mobile-models/app.js +++ b/example/mobile-models/app.js @@ -1,3 +1,10 @@ +// 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 g = require('strong-globalize')(); + var models = require('../../lib/models'); var loopback = require('../../'); @@ -32,14 +39,15 @@ var data = {pushSettings: [ ]} Application.create(data, function(err, data) { - console.log('Created: ', data.toObject()); + g.log('Created: %s', data.toObject()); }); -Application.register('rfeng', 'MyApp', {description: 'My first mobile application'}, function (err, result) { - console.log(result.toObject()); +Application.register('rfeng', 'MyApp', { description: g.f('My first mobile application') }, + function(err, result) { + console.log(result.toObject()); - result.resetKeys(function (err, result) { - console.log(result.toObject()); - }); + result.resetKeys(function(err, result) { + console.log(result.toObject()); + }); }); diff --git a/example/replication/app.js b/example/replication/app.js index ab6e69870..5ab3741d8 100644 --- a/example/replication/app.js +++ b/example/replication/app.js @@ -1,8 +1,15 @@ +// Copyright IBM Corp. 2014,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 loopback = require('../../'); var app = loopback(); -var db = app.dataSource('db', {connector: loopback.Memory}); -var Color = app.model('color', {dataSource: 'db', options: {trackChanges: true}}); -var Color2 = app.model('color2', {dataSource: 'db', options: {trackChanges: true}}); +var db = app.dataSource('db', { connector: 'memory' }); +var Color = app.registry.createModel('color', {}, { trackChanges: true }); +app.model(Color, { dataSource: 'db' }); +var Color2 = app.registry.createModel('color2', {}, { trackChanges: true }); +app.model(Color2, { dataSource: 'db' }); var target = Color2; var source = Color; var SPEED = process.env.SPEED || 100; diff --git a/example/simple-data-source/app.js b/example/simple-data-source/app.js index 3964df77b..3074d7b9e 100644 --- a/example/simple-data-source/app.js +++ b/example/simple-data-source/app.js @@ -1,3 +1,10 @@ +// 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 g = require('strong-globalize')(); + var loopback = require('../../'); var app = loopback(); @@ -20,4 +27,4 @@ Color.all(function () { app.listen(3000); -console.log('a list of colors is available at http://localhost:3000/colors'); +g.log('a list of colors is available at {{http://localhost:3000/colors}}'); diff --git a/index.js b/index.js index 1512239a7..0f02a1c1e 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,11 @@ +// 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 SG = require('strong-globalize'); +SG.SetRootDir(__dirname, { autonomousMsgLoading: 'all' }); + /** * loopback ~ public api */ diff --git a/intl/de/messages.json b/intl/de/messages.json new file mode 100644 index 000000000..31d7d99c3 --- /dev/null +++ b/intl/de/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "Kein Änderungssatz gefunden für {0} mit ID {1}", + "04bd8af876f001ceaf443aad6a9002f9": "Für die Authentifizierung muss Modell {0} definiert sein.", + "0731d0109e46c21a4e34af3346ed4856": "Dieses Verhalten kann sich in der nächsten Hauptversion ändern.", + "095afbf2f1f0e5be678f5dac5c54e717": "Zugriff verweigert", + "0caffe1d763c8cca6a61814abe33b776": "E-Mail ist erforderlich", + "10e01c895dc0b2fecc385f9f462f1ca6": "eine Liste mit Farben ist verfügbar unter {{http://localhost:3000/colors}}", + "1b2a6076dccbe91a56f1672eb3b8598c": "Der Antworthauptteil enthält Eigenschaften des bei der Anmeldung erstellten {{AccessToken}}.\nAbhängig vom Wert des Parameters 'include' kann der Hauptteil zusätzliche Eigenschaften enthalten:\n\n - user - U+007BUserU+007D - Daten des derzeit angemeldeten Benutzers. {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t BETREFF:{0}", + "1e85f822b547a75d7d385048030e4ecb": "Erstellt: {0}", + "275f22ab95671f095640ca99194b7635": "\t VON:{0}", + "2d3071e3b18681c80a090dc0efbdb349": "{0} mit ID {1} konnte nicht gefunden werden", + "2d78192c43fd2ec52ec18f3918894f9a": "Middleware {0} ist veraltet. Siehe {1} für weitere Details.", + "308e1d484516a33df788f873e65faaff": "Modell '{0}' bietet veraltetes 'DataModel' an. Verwenden Sie stattdessen 'PersistedModel'.", + "316e5b82c203cf3de31a449ee07d0650": "Erwartet wurde boolescher Wert, {0} empfangen", + "320c482401afa1207c04343ab162e803": "Ungültiger Prinzipaltyp: {0}", + "3438fab56cc7ab92dfd88f0497e523e0": "Die relations-Eigenschaft der Konfiguration '{0}' muss ein Objekt sein", + "35e5252c62d80f8c54a5290d30f4c7d0": "Bestätigen Sie Ihre E-Mail-Adresse, indem Sie diesen Link in einem Web-Browser öffnen:\n\t{0}", + "3aae63bb7e8e046641767571c1591441": "Anmeldung fehlgeschlagen, da die E-Mail-Adresse nicht bestätigt wurde", + "3aecb24fa8bdd3f79d168761ca8a6729": "Unbekannte {{middleware}}-Phase {0}", + "3caaa84fc103d6d5612173ae6d43b245": "Ungültiges Token: {0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "Vielen Dank für die Registrierung", + "3d63008ccfb2af1db2142e8cc2716ace": "Warnung: Keine E-Mail-Transportmethode für das Senden von E-Mails angegeben. Richten Sie eine Transportmethode für das Senden von E-Mails ein.", + "4203ab415ec66a78d3164345439ba76e": "{0}.{1}() kann nicht aufgerufen werden. Die Methode {2} wurde nicht konfiguriert. {{PersistedModel}} wurde nicht ordnungsgemäß an eine {{DataSource}} angehängt!", + "44a6c8b1ded4ed653d19ddeaaf89a606": "E-Mail nicht gefunden", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "E-Mail senden:", + "4b494de07f524703ac0879addbd64b13": "E-Mail-Adresse wurde nicht bestätigt", + "4cac5f051ae431321673e04045d37772": "Modell '{0}' bietet das unbekannte Modell '{1}' an. 'PersistedModel' wird als Basis verwendet.", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "Kann Datenquelle {0} nicht erstellen: {1}", + "5858e63efaa0e4ad86b61c0459ea32fa": "Sie müssen das {{Email}}-Modell mit einem {{Mail}}-Konnektor verbinden", + "5e81ad3847a290dc650b47618b9cbc7e": "Anmeldung fehlgeschlagen", + "5fa3afb425819ebde958043e598cb664": "Modell mit {{id}} {0} konnte nicht gefunden werden", + "61e5deebaf44d68f4e6a508f30cc31a3": "Beziehung '{0} ist für Modell {1} nicht vorhanden", + "62e8b0a733417978bab22c8dacf5d7e6": "Massenaktualisierungen können nicht angewendet werden, der Konnektor meldet die Anzahl aktualisierter Datensätze nicht richtig.", + "63a091ced88001ab6acb58f61ec041c5": "\t TEXT:{0}", + "6bc376432cd9972cf991aad3de371e78": "Fehlende Daten für Änderung: {0}", + "705c2d456a3e204c4af56e671ec3225c": "{{accessToken}} konnte nicht gefunden werden", + "734a7bebb65e10899935126ba63dd51f": "Die options-Eigenschaft der Konfiguration '{0}' muss ein Objekt sein", + "779467f467862836e19f494a37d6ab77": "Die acls-Eigenschaft der Konfiguration '{0}' muss eine Reihe von Objekten sein", + "7d5e7ed0efaedf3f55f380caae0df8b8": "Meine erste mobile Anwendung", + "7e0fca41d098607e1c9aa353c67e0fa1": "Ungültiges Zugriffstoken", + "7e287fc885d9fdcf42da3a12f38572c1": "Berechtigung erforderlich", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "{{accessToken}} muss sich abmelden", + "80a32e80cbed65eba2103201a7c94710": "Modell nicht gefunden: {0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "Eigenschaft '{0}' kann für {1} nicht rekonfiguriert werden", + "855ecd4a64885ba272d782435f72a4d4": "\"{0}\" unbekannt, ID \"{1}\".", + "860d1a0b8bd340411fb32baa72867989": "Die Transportmethode unterstützt keine HTTP-Umleitungen.", + "895b1f941d026870b3cc8e6af087c197": "{{username}} oder {{email}} ist erforderlich", + "8a17c5ef611e2e7535792316e66b8fca": "Kennwort zu lang: {0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "Anforderung an Host {0}", + "8ae418c605b6a45f2651be9b1677c180": "Ungültige Remote-Methode: '{0}'", + "8bab6720ecc58ec6412358c858a53484": "Massenaktualisierung fehlgeschlagen, der Konnektor hat eine unerwartete Anzahl an Datensätzen geändert: {0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML:{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "Der Konfiguration von {0} fehlt die {{`dataSource`}}-Eigenschaft.\nVerwenden Sie 'null' oder 'false', um Modelle zu kennzeichnen, die mit keiner Datenquelle verbunden sind.", + "a40684f5a9f546115258b76938d1de37": "Eine Liste mit Farben ist verfügbar unter {{http://localhost:3000/colors}}", + "a50d10fc6e0959b220e085454c40381e": "Benutzer nicht gefunden: {0}", + "a80038252430df2754884bf3c845c4cf": "Den Remote-Anbindungs-Metadaten für \"{0}.{1}\" fehlt das Flag \"isStatic\"; die Methode ist als Instanzdefinitionsmethode registriert.", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "\"{0}\" unbekannt, {{key}} \"{1}\".", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}} ist erforderlich", + "c2b5d51f007178170ca3952d59640ca4": "{0} Änderungen können nicht behoben werden:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "Eine gültige E-Mail-Adresse muss angegeben werden", + "cd0412f2f33a4a2a316acc834f3f21a6": "muss {{id}} oder {{data}} angeben", + "d5552322de5605c58b62f47ad26d2716": "{{`app.boot`}} wurde entfernt, verwenden Sie stattdessen das neue Modul {{loopback-boot}}", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0} ist veraltet. Siehe {1} für weitere Details.", + "dc568bee32deb0f6eaf63e73b20e8ceb": "Nicht-Objekt-Einstellung \"{0}\" von \"methods\" wird ignoriert.", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "\"{0}\" unbekannt, {{id}} \"{1}\".", + "e92aa25b6b864e3454b65a7c422bd114": "Massenaktualisierung fehlgeschlagen, der Konnektor hat eine unerwartete Anzahl an Datensätzen gelöscht : {0}", + "ea63d226b6968e328bdf6876010786b5": "Massenaktualisierungen können nicht angewendet werden, der Konnektor meldet die Anzahl gelöschter Datensätze nicht richtig.", + "ead044e2b4bce74b4357f8a03fb78ec4": "{0}.{1}() kann nicht aufgerufen werden. Die Methode {2} wurde nicht konfiguriert. {{KeyValueModel}} wurde nicht ordnungsgemäß an eine {{DataSource}} angehängt!", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t AN:{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t TRANSPORTMETHODE:{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "Ergebnis:{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "Konflikt", + "f58cdc481540cd1f69a4aa4da2e37981": "Ungültiges Kennwort: {0}" +} + diff --git a/intl/en/messages.json b/intl/en/messages.json new file mode 100644 index 000000000..4e6decd4b --- /dev/null +++ b/intl/en/messages.json @@ -0,0 +1,75 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "No change record found for {0} with id {1}", + "04bd8af876f001ceaf443aad6a9002f9": "Authentication requires model {0} to be defined.", + "0731d0109e46c21a4e34af3346ed4856": "This behaviour may change in the next major version.", + "095afbf2f1f0e5be678f5dac5c54e717": "Access Denied", + "0caffe1d763c8cca6a61814abe33b776": "Email is required", + "10e01c895dc0b2fecc385f9f462f1ca6": "a list of colors is available at {{http://localhost:3000/colors}}", + "1b2a6076dccbe91a56f1672eb3b8598c": "The response body contains properties of the {{AccessToken}} created on login.\nDepending on the value of `include` parameter, the body may contain additional properties:\n\n - `user` - `U+007BUserU+007D` - Data of the currently logged in user. {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t SUBJECT:{0}", + "1e85f822b547a75d7d385048030e4ecb": "Created: {0}", + "275f22ab95671f095640ca99194b7635": "\t FROM:{0}", + "2d3071e3b18681c80a090dc0efbdb349": "could not find {0} with id {1}", + "2d78192c43fd2ec52ec18f3918894f9a": "{0} middleware is deprecated. See {1} for more details.", + "308e1d484516a33df788f873e65faaff": "Model `{0}` is extending deprecated `DataModel. Use `PersistedModel` instead.", + "316e5b82c203cf3de31a449ee07d0650": "Expected boolean, got {0}", + "320c482401afa1207c04343ab162e803": "Invalid principal type: {0}", + "3438fab56cc7ab92dfd88f0497e523e0": "The relations property of `{0}` configuration must be an object", + "35e5252c62d80f8c54a5290d30f4c7d0": "Please verify your email by opening this link in a web browser:\n\t{0}", + "3aae63bb7e8e046641767571c1591441": "login failed as the email has not been verified", + "3aecb24fa8bdd3f79d168761ca8a6729": "Unknown {{middleware}} phase {0}", + "3caaa84fc103d6d5612173ae6d43b245": "Invalid token: {0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "Thanks for Registering", + "3d63008ccfb2af1db2142e8cc2716ace": "Warning: No email transport specified for sending email. Setup a transport to send mail messages.", + "4203ab415ec66a78d3164345439ba76e": "Cannot call {0}.{1}(). The {2} method has not been setup. The {{PersistedModel}} has not been correctly attached to a {{DataSource}}!", + "44a6c8b1ded4ed653d19ddeaaf89a606": "Email not found", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "Sending Mail:", + "4b494de07f524703ac0879addbd64b13": "Email has not been verified", + "4cac5f051ae431321673e04045d37772": "Model `{0}` is extending an unknown model `{1}`. Using `PersistedModel` as the base.", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "Cannot create data source {0}: {1}", + "5858e63efaa0e4ad86b61c0459ea32fa": "You must connect the {{Email}} Model to a {{Mail}} connector", + "5e81ad3847a290dc650b47618b9cbc7e": "login failed", + "5fa3afb425819ebde958043e598cb664": "could not find a model with {{id}} {0}", + "61e5deebaf44d68f4e6a508f30cc31a3": "Relation `{0}` does not exist for model `{1}`", + "62e8b0a733417978bab22c8dacf5d7e6": "Cannot apply bulk updates, the connector does not correctly report the number of updated records.", + "63a091ced88001ab6acb58f61ec041c5": "\t TEXT:{0}", + "6bc376432cd9972cf991aad3de371e78": "Missing data for change: {0}", + "705c2d456a3e204c4af56e671ec3225c": "Could not find {{accessToken}}", + "734a7bebb65e10899935126ba63dd51f": "The options property of `{0}` configuration must be an object", + "779467f467862836e19f494a37d6ab77": "The acls property of `{0}` configuration must be an array of objects", + "7d5e7ed0efaedf3f55f380caae0df8b8": "My first mobile application", + "7e0fca41d098607e1c9aa353c67e0fa1": "Invalid Access Token", + "7e287fc885d9fdcf42da3a12f38572c1": "Authorization Required", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "{{accessToken}} is required to logout", + "80a32e80cbed65eba2103201a7c94710": "Model not found: {0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "Property `{0}` cannot be reconfigured for `{1}`", + "855ecd4a64885ba272d782435f72a4d4": "Unknown \"{0}\" id \"{1}\".", + "860d1a0b8bd340411fb32baa72867989": "The transport does not support HTTP redirects.", + "895b1f941d026870b3cc8e6af087c197": "{{username}} or {{email}} is required", + "8a17c5ef611e2e7535792316e66b8fca": "Password too long: {0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "Request to host {0}", + "8ae418c605b6a45f2651be9b1677c180": "Invalid remote method: `{0}`", + "8bab6720ecc58ec6412358c858a53484": "Bulk update failed, the connector has modified unexpected number of records: {0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML:{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "The configuration of `{0}` is missing {{`dataSource`}} property.\nUse `null` or `false` to mark models not attached to any data source.", + "a40684f5a9f546115258b76938d1de37": "A list of colors is available at {{http://localhost:3000/colors}}", + "a50d10fc6e0959b220e085454c40381e": "User not found: {0}", + "a80038252430df2754884bf3c845c4cf": "Remoting metadata for \"{0}.{1}\" is missing \"isStatic\" flag, the method is registered as an instance method.", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "Unknown \"{0}\" {{key}} \"{1}\".", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}} is required", + "c2b5d51f007178170ca3952d59640ca4": "Cannot rectify {0} changes:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "Must provide a valid email", + "cd0412f2f33a4a2a316acc834f3f21a6": "must specify an {{id}} or {{data}}", + "d5552322de5605c58b62f47ad26d2716": "{{`app.boot`}} was removed, use the new module {{loopback-boot}} instead", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0} is deprecated. See {1} for more details.", + "dc568bee32deb0f6eaf63e73b20e8ceb": "Ignoring non-object \"methods\" setting of \"{0}\".", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "Unknown \"{0}\" {{id}} \"{1}\".", + "e92aa25b6b864e3454b65a7c422bd114": "Bulk update failed, the connector has deleted unexpected number of records: {0}", + "ea63d226b6968e328bdf6876010786b5": "Cannot apply bulk updates, the connector does not correctly report the number of deleted records.", + "ead044e2b4bce74b4357f8a03fb78ec4": "Cannot call {0}.{1}(). The {2} method has not been setup. The {{KeyValueModel}} has not been correctly attached to a {{DataSource}}!", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t TO:{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t TRANSPORT:{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "result:{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "Conflict", + "f58cdc481540cd1f69a4aa4da2e37981": "Invalid password: {0}" +} diff --git a/intl/es/messages.json b/intl/es/messages.json new file mode 100644 index 000000000..7abc0eca4 --- /dev/null +++ b/intl/es/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "No se ha encontrado ningún registro de cambio para {0} con el id {1}", + "04bd8af876f001ceaf443aad6a9002f9": "La autenticación requiere la definición del modelo {0}.", + "0731d0109e46c21a4e34af3346ed4856": "Este comportamiento puede cambiar en la próxima versión principal.", + "095afbf2f1f0e5be678f5dac5c54e717": "Acceso denegado", + "0caffe1d763c8cca6a61814abe33b776": "Es necesario el correo electrónico", + "10e01c895dc0b2fecc385f9f462f1ca6": "una lista de colores está disponible en {{http://localhost:3000/colors}}", + "1b2a6076dccbe91a56f1672eb3b8598c": "El cuerpo de respuesta contiene propiedades de la {{AccessToken}} creada durante el inicio de la sesión.\nDependiendo del valor del parámetro `include`, el cuerpo puede contener propiedades adicionales:\n\n - `user` - `U+007BUserU+007D` - Datos del usuario conectado actualmente. {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t ASUNTO:{0}", + "1e85f822b547a75d7d385048030e4ecb": "Creado: {0}", + "275f22ab95671f095640ca99194b7635": "\t DESDE:{0}", + "2d3071e3b18681c80a090dc0efbdb349": "no se ha encontrado {0} con el ID {1}", + "2d78192c43fd2ec52ec18f3918894f9a": "El middleware {0} está en desuso. Consulte {1} para obtener detalles.", + "308e1d484516a33df788f873e65faaff": "El modelo `{0}` está ampliando `DataModel` en desuso. Utilice `PersistedModel` en su lugar.", + "316e5b82c203cf3de31a449ee07d0650": "Se esperaba un booleano, se ha obtenido {0}", + "320c482401afa1207c04343ab162e803": "Tipo de principal no válido: {0}", + "3438fab56cc7ab92dfd88f0497e523e0": "La configuración de la propiedad relations de `{0}` debe ser un objeto", + "35e5252c62d80f8c54a5290d30f4c7d0": "Verifique su correo electrónico abriendo este enlace en un navegador:\n\t {0}", + "3aae63bb7e8e046641767571c1591441": "el inicio de sesión ha fallado porque el correo electrónico no ha sido verificado", + "3aecb24fa8bdd3f79d168761ca8a6729": "Fase de {{middleware}} desconocida {0}", + "3caaa84fc103d6d5612173ae6d43b245": "La señal no es válida: {0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "Gracias por registrarse", + "3d63008ccfb2af1db2142e8cc2716ace": "Aviso: No se ha especificado ningún transporte de correo electrónico para enviar correo electrónico. Configure un transporte para enviar mensajes de correo.", + "4203ab415ec66a78d3164345439ba76e": "No se puede llamar a {0}.{1}(). El método {2} no se ha configurado. {{PersistedModel}} no se ha conectado correctamente a un {{DataSource}}.", + "44a6c8b1ded4ed653d19ddeaaf89a606": "Correo electrónico no encontrado", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "Enviando correo:", + "4b494de07f524703ac0879addbd64b13": "El correo electrónico no se ha verificado", + "4cac5f051ae431321673e04045d37772": "El modelo `{0}` está ampliando un modelo desconocido `{1}`. Se utiliza `PersistedModel` como base.", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "No se puede crear el origen de datos {0}: {1}", + "5858e63efaa0e4ad86b61c0459ea32fa": "Debe conectar el modelo de {{Email}} a un conector de {{Mail}}", + "5e81ad3847a290dc650b47618b9cbc7e": "el inicio de sesión ha fallado", + "5fa3afb425819ebde958043e598cb664": "no se ha encontrado un modelo con {{id}} {0}", + "61e5deebaf44d68f4e6a508f30cc31a3": "La relación `{0}` no existe para el modelo `{1}`", + "62e8b0a733417978bab22c8dacf5d7e6": "No pueden aplicarse actualizaciones masivas, el conector no notifica correctamente el número de registros actualizados.", + "63a091ced88001ab6acb58f61ec041c5": "\t TEXTO:{0}", + "6bc376432cd9972cf991aad3de371e78": "Faltan datos para el cambio: {0}", + "705c2d456a3e204c4af56e671ec3225c": "No se ha encontrado {{accessToken}}", + "734a7bebb65e10899935126ba63dd51f": "La configuración de la propiedad de options de `{0}` debe ser un objeto", + "779467f467862836e19f494a37d6ab77": "La configuración de la propiedad acls de `{0}` debe ser una matriz de objetos", + "7d5e7ed0efaedf3f55f380caae0df8b8": "Mi primera aplicación móvil", + "7e0fca41d098607e1c9aa353c67e0fa1": "Señal de acceso no válida", + "7e287fc885d9fdcf42da3a12f38572c1": "Autorización necesaria", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "Es necesario {{accessToken}} para cerrar la sesión", + "80a32e80cbed65eba2103201a7c94710": "No se ha encontrado el modelo: {0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "La propiedad `{0}` no puede reconfigurarse para `{1}`", + "855ecd4a64885ba272d782435f72a4d4": "Id de \"{0}\" desconocido \"{1}\".", + "860d1a0b8bd340411fb32baa72867989": "El transporte no admite redirecciones HTTP.", + "895b1f941d026870b3cc8e6af087c197": "{{username}} o {{email}} es obligatorio", + "8a17c5ef611e2e7535792316e66b8fca": "Contraseña demasiado larga: {0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "Solicitud al host {0}", + "8ae418c605b6a45f2651be9b1677c180": "Método remoto no válido: `{0}`", + "8bab6720ecc58ec6412358c858a53484": "La actualización masiva ha fallado, el conector ha modificado un número de registros inesperado: {0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML:{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "En la configuración de `{0}` falta la propiedad {{`dataSource`}}.\nUtilice `null` o `false` para marcar los modelos no conectados a ningún origen de datos.", + "a40684f5a9f546115258b76938d1de37": "Una lista de colores está disponible en {{http://localhost:3000/colors}}", + "a50d10fc6e0959b220e085454c40381e": "No se ha encontrado el usuario: {0}", + "a80038252430df2754884bf3c845c4cf": "En los metadatos de interacción remota para \"{0}.{1}\" falta el indicador \"isStatic\", el método está registrado como método de instancia.", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "{{key}} de \"{0}\" desconocido \"{1}\".", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}} es obligatorio", + "c2b5d51f007178170ca3952d59640ca4": "No se pueden rectificar los cambios de {0}:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "Debe proporcionar un correo electrónico válido", + "cd0412f2f33a4a2a316acc834f3f21a6": "debe especificar un {{id}} o {{data}}", + "d5552322de5605c58b62f47ad26d2716": "{{`app.boot`}} se ha eliminado, utilice el nuevo módulo {{loopback-boot}} en su lugar", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0} está en desuso. Consulte {1} para obtener detalles.", + "dc568bee32deb0f6eaf63e73b20e8ceb": "Se ignora el valor \"methods\" no de objeto de \"{0}\".", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "{{id}} de \"{0}\" desconocido \"{1}\".", + "e92aa25b6b864e3454b65a7c422bd114": "La actualización masiva ha fallado, el conector ha suprimido un número de registros inesperado: {0}", + "ea63d226b6968e328bdf6876010786b5": "No pueden aplicarse actualizaciones masivas, el conector no notifica correctamente el número de registros suprimidos.", + "ead044e2b4bce74b4357f8a03fb78ec4": "No se puede llamar a {0}.{1}(). El método {2} no se ha configurado. {{KeyValueModel}} no se ha conectado correctamente a un {{DataSource}}.", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t A:{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t TRANSPORTE:{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "resultado:{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "Conflicto", + "f58cdc481540cd1f69a4aa4da2e37981": "Contraseña no válida: {0}" +} + diff --git a/intl/fr/messages.json b/intl/fr/messages.json new file mode 100644 index 000000000..cd5537d46 --- /dev/null +++ b/intl/fr/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "Aucun enregistrement de changement trouvé pour {0} avec l'id {1}", + "04bd8af876f001ceaf443aad6a9002f9": "L'authentification exige que le modèle {0} soit défini.", + "0731d0109e46c21a4e34af3346ed4856": "Ce comportement peut changer dans la version principale suivante.", + "095afbf2f1f0e5be678f5dac5c54e717": "Accès refusé", + "0caffe1d763c8cca6a61814abe33b776": "L'adresse électronique est obligatoire", + "10e01c895dc0b2fecc385f9f462f1ca6": "une liste de couleurs est disponible sur {{http://localhost:3000/colors}}", + "1b2a6076dccbe91a56f1672eb3b8598c": "Le corps de réponse contient les propriétés de {{AccessToken}} créées lors de la connexion.\nEn fonction de la valeur du paramètre `include`, le corps peut contenir des propriétés supplémentaires :\n\n - `user` - `U+007BUserU+007D` - Données de l'utilisateur connecté. {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t SUJET :{0}", + "1e85f822b547a75d7d385048030e4ecb": "Création de : {0}", + "275f22ab95671f095640ca99194b7635": "\t DE :{0}", + "2d3071e3b18681c80a090dc0efbdb349": "impossible de trouver {0} avec l'id {1}", + "2d78192c43fd2ec52ec18f3918894f9a": "Le middleware {0} est obsolète. Pour plus de détails, voir {1}.", + "308e1d484516a33df788f873e65faaff": "Le modèle `{0}` étend le `DataModel obsolète. Utilisez à la place `PersistedModel`.", + "316e5b82c203cf3de31a449ee07d0650": "Valeur booléenne attendue, {0} obtenu", + "320c482401afa1207c04343ab162e803": "Type de principal non valide : {0}", + "3438fab56cc7ab92dfd88f0497e523e0": "La propriété relations de la configuration `{0}` doit être un objet", + "35e5252c62d80f8c54a5290d30f4c7d0": "Vérifiez votre courrier électronique en ouvrant ce lien dans un navigateur Web :\n\t{0}", + "3aae63bb7e8e046641767571c1591441": "la connexion a échoué car l'adresse électronique n'a pas été vérifiée", + "3aecb24fa8bdd3f79d168761ca8a6729": "Phase {{middleware}} inconnue {0}", + "3caaa84fc103d6d5612173ae6d43b245": "Jeton non valide : {0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "Merci pour votre inscription", + "3d63008ccfb2af1db2142e8cc2716ace": "Avertissement : Aucun transport de courrier électronique n'est spécifié pour l'envoi d'un message électronique. Configurez un transport pour envoyer des messages électroniques.", + "4203ab415ec66a78d3164345439ba76e": "Impossible d'appeler {0}.{1}(). La méthode {2} n'a pas été configurée. {{PersistedModel}} n'a pas été associé correctement à {{DataSource}} !", + "44a6c8b1ded4ed653d19ddeaaf89a606": "Adresse électronique introuvable", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "Envoi d'un message électronique :", + "4b494de07f524703ac0879addbd64b13": "Le courrier électronique n'a pas été vérifié", + "4cac5f051ae431321673e04045d37772": "Le modèle `{0}` étend un modèle inconnu `{1}`. Utilisation de `PersistedModel` comme base.", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "Impossible de créer la source de données {0} : {1}", + "5858e63efaa0e4ad86b61c0459ea32fa": "Vous devez connecter le modèle {{Email}} à un connecteur {{Mail}}", + "5e81ad3847a290dc650b47618b9cbc7e": "échec de la connexion", + "5fa3afb425819ebde958043e598cb664": "impossible de trouver un modèle avec {{id}} {0}", + "61e5deebaf44d68f4e6a508f30cc31a3": "La relation `{0}` n'existe pas pour le modèle `{1}`", + "62e8b0a733417978bab22c8dacf5d7e6": "Impossible d'appliquer des mises à jour en bloc ; le connecteur ne signale pas correctement le nombre d'enregistrements mis à jour.", + "63a091ced88001ab6acb58f61ec041c5": "\t TEXTE :{0}", + "6bc376432cd9972cf991aad3de371e78": "Données manquantes pour le changement : {0}", + "705c2d456a3e204c4af56e671ec3225c": "{{accessToken}} introuvable", + "734a7bebb65e10899935126ba63dd51f": "La propriété options de la configuration `{0}` doit être un objet", + "779467f467862836e19f494a37d6ab77": "La propriété acls de la configuration `{0}` doit être un tableau d'objets", + "7d5e7ed0efaedf3f55f380caae0df8b8": "Ma première application mobile", + "7e0fca41d098607e1c9aa353c67e0fa1": "Jeton d'accès non valide", + "7e287fc885d9fdcf42da3a12f38572c1": "Autorisation requise", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "{{accessToken}} est nécessaire pour la déconnexion", + "80a32e80cbed65eba2103201a7c94710": "Modèle introuvable : {0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "La propriété `{0}` ne peut pas être reconfigurée pour `{1}`", + "855ecd4a64885ba272d782435f72a4d4": "ID \"{0}\" inconnu \"{1}\".", + "860d1a0b8bd340411fb32baa72867989": "Le transport ne prend pas en charge les réacheminements HTTP.", + "895b1f941d026870b3cc8e6af087c197": "{{username}} ou {{email}} est obligatoire", + "8a17c5ef611e2e7535792316e66b8fca": "Mot de passe trop long : {0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "Demande à l'hôte {0}", + "8ae418c605b6a45f2651be9b1677c180": "Méthode distante non valide : `{0}`", + "8bab6720ecc58ec6412358c858a53484": "La mise à jour en bloc a échoué ; le connecteur a modifié un nombre inattendu d'enregistrements : {0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML :{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "La propriété {{`dataSource`}} est manquante dans la configuration de `{0}`.\nUtilisez `null` ou `false` pour marquer les modèles non associés à une source de données.", + "a40684f5a9f546115258b76938d1de37": "Une liste de couleurs est disponible sur {{http://localhost:3000/colors}}", + "a50d10fc6e0959b220e085454c40381e": "Utilisateur introuvable : {0}", + "a80038252430df2754884bf3c845c4cf": "Métadonnées remoting pour \"{0}.{1}\" ne comporte pas l'indicateur \"isStatic\" ; la méthode est enregistrée en tant que méthode instance.", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "\"{0}\" {{key}} \"{1}\" inconnu.", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}} est obligatoire", + "c2b5d51f007178170ca3952d59640ca4": "Impossible de rectifier les modifications {0} :\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "Obligation de fournir une adresse électronique valide", + "cd0412f2f33a4a2a316acc834f3f21a6": "obligation de spécifier {{id}} ou {{data}}", + "d5552322de5605c58b62f47ad26d2716": "{{`app.boot`}} a été supprimé ; utilisez à la place le nouveau module {{loopback-boot}}", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0} est obsolète. Pour plus de détails, voir {1}.", + "dc568bee32deb0f6eaf63e73b20e8ceb": "Le paramètre \"methods\" non objet de \"{0}\" est ignoré.", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "\"{0}\" {{id}} \"{1}\" inconnu.", + "e92aa25b6b864e3454b65a7c422bd114": "La mise à jour en bloc a échoué ; le connecteur a supprimé un nombre inattendu d'enregistrements : {0}", + "ea63d226b6968e328bdf6876010786b5": "Impossible d'appliquer des mises à jour en bloc ; le connecteur ne signale pas correctement le nombre d'enregistrements supprimés.", + "ead044e2b4bce74b4357f8a03fb78ec4": "Impossible d'appeler {0}.{1}(). La méthode {2} n'a pas été configurée. {{KeyValueModel}} n'a pas été associé correctement à {{DataSource}} !", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t A :{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t TRANSPORT :{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "résultat :{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "Conflit", + "f58cdc481540cd1f69a4aa4da2e37981": "Mot de passe non valide : {0}" +} + diff --git a/intl/it/messages.json b/intl/it/messages.json new file mode 100644 index 000000000..f4b23f5cc --- /dev/null +++ b/intl/it/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "Nessun record di modifica trovato per {0} con id {1}", + "04bd8af876f001ceaf443aad6a9002f9": "L'autenticazione richiede che sia definito il modello {0}.", + "0731d0109e46c21a4e34af3346ed4856": "Questo funzionamento può essere modificato nella versione principale successiva.", + "095afbf2f1f0e5be678f5dac5c54e717": "Accesso negato", + "0caffe1d763c8cca6a61814abe33b776": "L'email è obbligatoria", + "10e01c895dc0b2fecc385f9f462f1ca6": "un elenco dei colori è disponibile all'indirizzo {{http://localhost:3000/colors}}", + "1b2a6076dccbe91a56f1672eb3b8598c": "Il corpo della risposta contiene proprietà del {{AccessToken}} creato all'accesso.\nIn base al valore del parametro `include`, il corpo può contenere ulteriori proprietà:\n\n - `user` - `U+007BUserU+007D` - Dati dell'utente attualmente collegato.. {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t OGGETTO:{0}", + "1e85f822b547a75d7d385048030e4ecb": "Creato: {0}", + "275f22ab95671f095640ca99194b7635": "\t DA:{0}", + "2d3071e3b18681c80a090dc0efbdb349": "impossibile trovare {0} con id {1}", + "2d78192c43fd2ec52ec18f3918894f9a": "{0} middleware is deprecated. Consultare {1} per ulteriori dettagli.", + "308e1d484516a33df788f873e65faaff": "Il modello `{0}` estende il modello `DataModel obsoleto. Utilizzare `PersistedModel`.", + "316e5b82c203cf3de31a449ee07d0650": "Previsto valore booleano, ricevuto {0}", + "320c482401afa1207c04343ab162e803": "Tipo principal non valido: {0}", + "3438fab56cc7ab92dfd88f0497e523e0": "La proprietà relations della configurazione `{0}` deve essere un oggetto", + "35e5252c62d80f8c54a5290d30f4c7d0": "Verificare la e-mail aprendo questo link in un browser web:\n\t{0}", + "3aae63bb7e8e046641767571c1591441": "login non riuscito perché l'email non è stata verificata", + "3aecb24fa8bdd3f79d168761ca8a6729": "Fase {{middleware}} sconosciuta {0}", + "3caaa84fc103d6d5612173ae6d43b245": "Token non valido: {0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "Grazie per essersi registrati", + "3d63008ccfb2af1db2142e8cc2716ace": "Avvertenza: nessun trasporto email specificato per l'invio della email. Configurare un trasporto per inviare messaggi email.", + "4203ab415ec66a78d3164345439ba76e": "Impossibile richiamare {0}.{1}(). Il metodo {2} non è stato configurato. {{PersistedModel}} non è stato correttamente collegato ad una {{DataSource}}!", + "44a6c8b1ded4ed653d19ddeaaf89a606": "Email non trovata", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "Invio email:", + "4b494de07f524703ac0879addbd64b13": "La e-mail non è stata verificata", + "4cac5f051ae431321673e04045d37772": "Il modello `{0}` estende un modello sconosciuto `{1}`. Viene utilizzato `PersistedModel` come base.", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "Impossibile creare l'origine dati {0}: {1}", + "5858e63efaa0e4ad86b61c0459ea32fa": "È necessario collegare il modello {{Email}} ad un connettore {{Mail}}", + "5e81ad3847a290dc650b47618b9cbc7e": "login non riuscito", + "5fa3afb425819ebde958043e598cb664": "impossibile trovare un modello con {{id}} {0}", + "61e5deebaf44d68f4e6a508f30cc31a3": "La relazione `{0}` non esiste per il modello `{1}`", + "62e8b0a733417978bab22c8dacf5d7e6": "Impossibile applicare gli aggiornamenti in massa, il connettore non indica correttamente il numero di record aggiornati.", + "63a091ced88001ab6acb58f61ec041c5": "\t TESTO:{0}", + "6bc376432cd9972cf991aad3de371e78": "Dati mancanti per la modifica: {0}", + "705c2d456a3e204c4af56e671ec3225c": "Could not find {{accessToken}}", + "734a7bebb65e10899935126ba63dd51f": "La proprietà options della configurazione `{0}` deve essere un oggetto", + "779467f467862836e19f494a37d6ab77": "La proprietà acls della configurazione `{0}` deve essere un array di oggetti", + "7d5e7ed0efaedf3f55f380caae0df8b8": "Prima applicazione mobile personale", + "7e0fca41d098607e1c9aa353c67e0fa1": "Token di accesso non valido", + "7e287fc885d9fdcf42da3a12f38572c1": "Autorizzazione richiesta", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "{{accessToken}} is required to logout", + "80a32e80cbed65eba2103201a7c94710": "Modello non trovato: {0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "Impossibile riconfigurare la proprietà `{0}` per `{1}`", + "855ecd4a64885ba272d782435f72a4d4": "ID sconosciuto \"{0}\" \"{1}\".", + "860d1a0b8bd340411fb32baa72867989": "Il trasporto non supporta i reindirizzamenti HTTP.", + "895b1f941d026870b3cc8e6af087c197": "Sono richiesti {{username}} o {{email}}", + "8a17c5ef611e2e7535792316e66b8fca": "Password troppo lunga: {0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "Richiesta all'host {0}", + "8ae418c605b6a45f2651be9b1677c180": "Metodo remoto non valido: `{0}`", + "8bab6720ecc58ec6412358c858a53484": "Aggiornamento in massa non riuscito, il connettore ha modificato un numero non previsto di record: {0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML:{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "La configurazione di `{0}` non contiene la proprietà {{`dataSource`}}.\nUtilizzare `null` o `false` per contrassegnare i modelli non collegati ad alcuna origine dati.", + "a40684f5a9f546115258b76938d1de37": "Un elenco dei colori è disponibile all'indirizzo {{http://localhost:3000/colors}}", + "a50d10fc6e0959b220e085454c40381e": "Utente non trovato: {0}", + "a80038252430df2754884bf3c845c4cf": "Metadati della comunicazione in remoto per \"{0}.{1}\" non presenta l'indicatore \"isStatic\", il metodo è registrato come metodo dell'istanza.", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "{{key}} \"{0}\" sconosciuto \"{1}\".", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}} è obbligatorio", + "c2b5d51f007178170ca3952d59640ca4": "Impossibile correggere {0} modifiche:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "È necessario fornire una email valida", + "cd0412f2f33a4a2a316acc834f3f21a6": "è necessario specificare {{id}} o {{data}}", + "d5552322de5605c58b62f47ad26d2716": "{{`app.boot`}} è stato rimosso, utilizzare il nuovo modulo {{loopback-boot}}", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0} is deprecated. Consultare {1} per ulteriori dettagli.", + "dc568bee32deb0f6eaf63e73b20e8ceb": "L'impostazione \"methods\" non oggetto di \"{0}\" viene ignorata.", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "{{id}} \"{0}\" sconosciuto \"{1}\".", + "e92aa25b6b864e3454b65a7c422bd114": "Aggiornamento in massa non riuscito, il connettore ha eliminato un numero non previsto di record: {0}", + "ea63d226b6968e328bdf6876010786b5": "Impossibile applicare gli aggiornamenti in massa, il connettore non indica correttamente il numero di record eliminati.", + "ead044e2b4bce74b4357f8a03fb78ec4": "Impossibile richiamare {0}.{1}(). Il metodo {2} non è stato configurato. {{KeyValueModel}} non è stato correttamente collegato ad una {{DataSource}}!", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t A:{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t TRASPORTO:{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "risultato:{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "Conflitto", + "f58cdc481540cd1f69a4aa4da2e37981": "Password non valida: {0}" +} + diff --git a/intl/ja/messages.json b/intl/ja/messages.json new file mode 100644 index 000000000..7f793f7be --- /dev/null +++ b/intl/ja/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "ID {1} の {0} の変更レコードが見つかりませんでした", + "04bd8af876f001ceaf443aad6a9002f9": "認証では、モデル {0} を定義する必要があります。", + "0731d0109e46c21a4e34af3346ed4856": "この動作は次のメジャー・バージョンで変更される可能性があります。", + "095afbf2f1f0e5be678f5dac5c54e717": "アクセス拒否", + "0caffe1d763c8cca6a61814abe33b776": "E メールは必須です", + "10e01c895dc0b2fecc385f9f462f1ca6": "カラー・リストは {{http://localhost:3000/colors}} で利用できます", + "1b2a6076dccbe91a56f1672eb3b8598c": "応答本文には、ログイン時に作成された {{AccessToken}} のプロパティーが含まれます。\n`include` パラメーターの値によっては、本文に追加のプロパティーが含まれる場合があります:\n\n - `user` - `U+007BUserU+007D` - 現在ログインしているユーザーのデータ。 {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t 件名:{0}", + "1e85f822b547a75d7d385048030e4ecb": "作成済み: {0}", + "275f22ab95671f095640ca99194b7635": "\t 送信元:{0}", + "2d3071e3b18681c80a090dc0efbdb349": "ID {1} の {0} が見つかりませんでした", + "2d78192c43fd2ec52ec18f3918894f9a": "{0} ミドルウェアは非推奨です。詳しくは、{1} を参照してください。", + "308e1d484516a33df788f873e65faaff": "モデル `{0}` は非推奨の `DataModel を拡張しています。 代わりに `PersistedModel` を使用してください。", + "316e5b82c203cf3de31a449ee07d0650": "ブール値が必要ですが、{0} が取得されました", + "320c482401afa1207c04343ab162e803": "無効なプリンシパル・タイプ: {0}", + "3438fab56cc7ab92dfd88f0497e523e0": "`{0}` 構成の関係プロパティーはオブジェクトでなければなりません", + "35e5252c62d80f8c54a5290d30f4c7d0": "Web ブラウザーで次のリンクを開いて、E メールを検証してください: \n\t{0}", + "3aae63bb7e8e046641767571c1591441": "E メールが検証されていないため、ログインに失敗しました", + "3aecb24fa8bdd3f79d168761ca8a6729": "不明な {{middleware}} フェーズ {0}", + "3caaa84fc103d6d5612173ae6d43b245": "無効なトークン: {0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "ご登録いただき、ありがとうございます。", + "3d63008ccfb2af1db2142e8cc2716ace": "警告: E メール送信用の E メール・トランスポートが指定されていません。 メール・メッセージを送信するためのトランスポートをセットアップしてください。", + "4203ab415ec66a78d3164345439ba76e": "{0}.{1}() を呼び出せません。 {2} メソッドがセットアップされていません。 {{PersistedModel}} は {{DataSource}} に正しく付加されていません。", + "44a6c8b1ded4ed653d19ddeaaf89a606": "E メールが見つかりません", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "メールの送信:", + "4b494de07f524703ac0879addbd64b13": "E メールが検証されていません", + "4cac5f051ae431321673e04045d37772": "モデル `{0}` は不明のモデル `{1}` を拡張しています。 ベースとして `PersistedModel` を使用します。", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "データ・ソース {0}: {1} を作成できません", + "5858e63efaa0e4ad86b61c0459ea32fa": "{{Email}} モデルを {{Mail}} コネクターに接続する必要があります", + "5e81ad3847a290dc650b47618b9cbc7e": "ログインに失敗しました", + "5fa3afb425819ebde958043e598cb664": "{{id}} {0} のモデルが見つかりませんでした", + "61e5deebaf44d68f4e6a508f30cc31a3": "モデル `{1}` には関係 `{0}` が存在しません", + "62e8b0a733417978bab22c8dacf5d7e6": "一括更新を適用できません。コネクターは更新されたレコードの数を正しく報告していません。", + "63a091ced88001ab6acb58f61ec041c5": "\t テキスト:{0}", + "6bc376432cd9972cf991aad3de371e78": "変更用のデータがありません: {0}", + "705c2d456a3e204c4af56e671ec3225c": "{{accessToken}} が見つかりませんでした", + "734a7bebb65e10899935126ba63dd51f": "`{0}` 構成のオプション・プロパティーはオブジェクトでなければなりません", + "779467f467862836e19f494a37d6ab77": "`{0}` 構成の ACL プロパティーはオブジェクトの配列でなければなりません", + "7d5e7ed0efaedf3f55f380caae0df8b8": "最初のモバイル・アプリケーション", + "7e0fca41d098607e1c9aa353c67e0fa1": "無効なアクセス・トークン", + "7e287fc885d9fdcf42da3a12f38572c1": "許可が必要です", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "ログアウトするには {{accessToken}} が必要です", + "80a32e80cbed65eba2103201a7c94710": "モデルが見つかりません: {0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "`{1}` のプロパティー `{0}` を再構成できません", + "855ecd4a64885ba272d782435f72a4d4": "\"{0}\" ID \"{1}\" が不明です。", + "860d1a0b8bd340411fb32baa72867989": "トランスポートでは HTTP リダイレクトはサポートされません。", + "895b1f941d026870b3cc8e6af087c197": "{{username}} または {{email}} が必要です", + "8a17c5ef611e2e7535792316e66b8fca": "パスワードが長すぎます: {0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "ホスト {0} への要求", + "8ae418c605b6a45f2651be9b1677c180": "無効なリモート・メソッド: `{0}`", + "8bab6720ecc58ec6412358c858a53484": "一括更新が失敗しました。コネクターは予期しない数のレコードを変更しました: {0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML:{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "`{0}` の構成は {{`dataSource`}} プロパティーがありません。\nどのデータ・ソースにも付加されていないモデルにマークを付けるには `null` または `false` を使用します。", + "a40684f5a9f546115258b76938d1de37": "カラー・リストは {{http://localhost:3000/colors}} で利用できます", + "a50d10fc6e0959b220e085454c40381e": "ユーザーが見つかりません: {0}", + "a80038252430df2754884bf3c845c4cf": "\"{0}.{1}\" のリモート・メタデータに「isStatic」フラグがありません。このメソッドはインスタンス・メソッドとして登録されます。", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "\"{0}\" {{key}} \"{1}\" が不明です。", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}} は必須です", + "c2b5d51f007178170ca3952d59640ca4": "{0} の変更を修正できません:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "有効な E メールを指定する必要があります", + "cd0412f2f33a4a2a316acc834f3f21a6": "{{id}} または {{data}} を指定する必要があります", + "d5552322de5605c58b62f47ad26d2716": "{{`app.boot`}} は削除されました。代わりに新規モジュール {{loopback-boot}} を使用してください", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0} は非推奨です。詳しくは、{1} を参照してください。", + "dc568bee32deb0f6eaf63e73b20e8ceb": "\"{0}\" の非オブジェクト「メソッド」設定を無視します。", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "\"{0}\" {{id}} \"{1}\" が不明です。", + "e92aa25b6b864e3454b65a7c422bd114": "一括更新が失敗しました。コネクターは予期しない数のレコードを削除しました: {0}", + "ea63d226b6968e328bdf6876010786b5": "一括更新を適用できません。コネクターは削除されたレコードの数を正しく報告していません。", + "ead044e2b4bce74b4357f8a03fb78ec4": "{0}.{1}() を呼び出せません。 {2} メソッドがセットアップされていません。 {{KeyValueModel}} は {{DataSource}} に正しく付加されていません。", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t 宛先:{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t トランスポート:{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "結果:{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "競合", + "f58cdc481540cd1f69a4aa4da2e37981": "無効なパスワード: {0}" +} + diff --git a/intl/ko/messages.json b/intl/ko/messages.json new file mode 100644 index 000000000..b027adee0 --- /dev/null +++ b/intl/ko/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "ID가 {1}인 {0}에 대한 변경 레코드를 찾을 수 없음", + "04bd8af876f001ceaf443aad6a9002f9": "인증을 위해 {0} 모델이 정의되어야 함", + "0731d0109e46c21a4e34af3346ed4856": "이 동작은 다음 주요 버전에서 변경될 수 있습니다.", + "095afbf2f1f0e5be678f5dac5c54e717": "액세스 거부", + "0caffe1d763c8cca6a61814abe33b776": "이메일은 필수입니다.", + "10e01c895dc0b2fecc385f9f462f1ca6": "색상 목록은 {{http://localhost:3000/colors}}에 있음", + "1b2a6076dccbe91a56f1672eb3b8598c": "응답 본문에 로그인 시 작성한 {{AccessToken}} 특성이 포함됩니다. \n`include` 매개변수 값에 따라 본문에 추가 특성이 포함될 수 있습니다. \n\n - `user` - `U+007BUserU+007D` - 현재 로그인된 사용자의 데이터. {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t 제목:{0}", + "1e85f822b547a75d7d385048030e4ecb": "작성 날짜: {0}", + "275f22ab95671f095640ca99194b7635": "\t 발신인:{0}", + "2d3071e3b18681c80a090dc0efbdb349": "ID {1}(으)로 {0}을(를) 찾을 수 없음 ", + "2d78192c43fd2ec52ec18f3918894f9a": "{0} 미들웨어는 더 이상 사용되지 않습니다. 자세한 정보는 {1}을(를) 참조하십시오. ", + "308e1d484516a33df788f873e65faaff": "모델 `{0}`은(는) 더 이상 사용되지 않는 `DataModel`의 확장입니다. 대신 `PersistedModel`을 사용하십시오.", + "316e5b82c203cf3de31a449ee07d0650": "예상 부울, 실제 {0}", + "320c482401afa1207c04343ab162e803": "올바르지 않은 프린시펄 유형: {0}", + "3438fab56cc7ab92dfd88f0497e523e0": "`{0}` 구성의 관계 특성은 오브젝트여야 함", + "35e5252c62d80f8c54a5290d30f4c7d0": "웹 브라우저에서 이 링크를 열어 이메일을 확인하십시오.\n\t{0}", + "3aae63bb7e8e046641767571c1591441": "이메일이 확인되지 않아서 로그인에 실패했습니다. ", + "3aecb24fa8bdd3f79d168761ca8a6729": "알 수 없는 {{middleware}} 단계 {0}", + "3caaa84fc103d6d5612173ae6d43b245": "올바르지 않은 토큰: {0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "등록해 주셔서 감사합니다.", + "3d63008ccfb2af1db2142e8cc2716ace": "경고: 이메일 발송을 위해 이메일 전송이 지정되지 않았습니다. 메일 메시지를 보내려면 전송을 설정하십시오. ", + "4203ab415ec66a78d3164345439ba76e": "{0}.{1}()을(를) 호출할 수 없습니다. {2} 메소드가 설정되지 않았습니다. {{PersistedModel}}이(가) {{DataSource}}에 재대로 첨부되지 않았습니다!", + "44a6c8b1ded4ed653d19ddeaaf89a606": "이메일을 찾을 수 없음", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "메일 발송 중:", + "4b494de07f524703ac0879addbd64b13": "이메일이 확인되지 않았습니다.", + "4cac5f051ae431321673e04045d37772": "모델 `{0}`은(는) 알 수 없는 모델 `{1}`의 확장입니다. `PersistedModel`을 기본으로 사용하십시오.", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "데이터 소스 {0}을(를) 작성할 수 없음: {1}", + "5858e63efaa0e4ad86b61c0459ea32fa": "{{Email}} 모델을 {{Mail}} 커넥터에 연결해야 합니다. ", + "5e81ad3847a290dc650b47618b9cbc7e": "로그인 실패", + "5fa3afb425819ebde958043e598cb664": "{{id}} {0}인 모델을 찾을 수 없음", + "61e5deebaf44d68f4e6a508f30cc31a3": "모델 `{1}`에 대해 관계 `{0}`이(가) 없습니다. ", + "62e8b0a733417978bab22c8dacf5d7e6": "벌크 업데이트를 적용할 수 없습니다. 커넥터가 업데이트된 레코드 수를 제대로 보고하지 않습니다. ", + "63a091ced88001ab6acb58f61ec041c5": "\t 텍스트:{0}", + "6bc376432cd9972cf991aad3de371e78": "변경을 위한 데이터 누락: {0}", + "705c2d456a3e204c4af56e671ec3225c": "{{accessToken}}을(를) 찾을 수 없음", + "734a7bebb65e10899935126ba63dd51f": "`{0}` 구성의 옵션 특성은 오브젝트여야 함", + "779467f467862836e19f494a37d6ab77": "`{0}` 구성의 acls 특성은 오브젝트 배열이어야 함", + "7d5e7ed0efaedf3f55f380caae0df8b8": "내 첫 번째 모바일 애플리케이션", + "7e0fca41d098607e1c9aa353c67e0fa1": "올바르지 않은 액세스 토큰", + "7e287fc885d9fdcf42da3a12f38572c1": "권한 필수", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "{{accessToken}}이(가) 로그아웃해야 함", + "80a32e80cbed65eba2103201a7c94710": "모델을 찾을 수 없음: {0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "`{1}`에 대해 `{0}` 특성을 다시 구성할 수 없음", + "855ecd4a64885ba272d782435f72a4d4": "알 수 없는 \"{0}\" ID \"{1}\".", + "860d1a0b8bd340411fb32baa72867989": "전송에서 HTTP 경로 재지원을 지원하지 않습니다.", + "895b1f941d026870b3cc8e6af087c197": "{{username}} 또는 {{email}}은(는) 필수입니다.", + "8a17c5ef611e2e7535792316e66b8fca": "비밀번호가 너무 김: {0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "호스트 {0}에 요청", + "8ae418c605b6a45f2651be9b1677c180": "올바르지 않은 원격 메소드: `{0}`", + "8bab6720ecc58ec6412358c858a53484": "벌크 업데이트에 실패했습니다. 커넥터가 예상치 못한 수의 레코드를 수정했습니다. {0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML:{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "`{0}`의 구성에 {{`dataSource`}} 특성이 누락되었습니다.\n데이터 소스에 첨부되지 않은 모델을 표시하려면 `null` 또는 `false`를 사용하십시오.", + "a40684f5a9f546115258b76938d1de37": "색상 목록은 {{http://localhost:3000/colors}}에 있음", + "a50d10fc6e0959b220e085454c40381e": "사용자를 찾을 수 없음: {0}", + "a80038252430df2754884bf3c845c4cf": "\"{0}.{1}\"에 대한 원격 메타데이터에 \"isStatic\" 플래그가 누락되었습니다. 이 메소드는 인스턴스 메소드로 등록되어 있습니다.", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "알 수 없는 \"{0}\" {{key}} \"{1}\".", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}}은(는) 필수입니다.", + "c2b5d51f007178170ca3952d59640ca4": "{0} 변경사항을 교정할 수 없음:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "올바른 이메일을 제공해야 함", + "cd0412f2f33a4a2a316acc834f3f21a6": "{{id}} 또는 {{data}}을(를) 지정해야 함", + "d5552322de5605c58b62f47ad26d2716": "{{`app.boot`}}이(가) 제거되었습니다. 대신 새 모듈 {{loopback-boot}}을(를) 사용하십시오. ", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0}은(는) 더 이상 사용되지 않습니다. 자세한 정보는 {1}을(를) 참조하십시오. ", + "dc568bee32deb0f6eaf63e73b20e8ceb": "\"{0}\"의 비오브젝트 \"methods\" 설정 무시", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "알 수 없는 \"{0}\" {{id}} \"{1}\".", + "e92aa25b6b864e3454b65a7c422bd114": "벌크 업데이트에 실패했습니다. 커넥터가 예상치 못한 수의 레코드를 삭제했습니다. {0}", + "ea63d226b6968e328bdf6876010786b5": "벌크 업데이트를 적용할 수 없습니다. 커넥터가 삭제된 레코드 수를 제대로 보고하지 않습니다. ", + "ead044e2b4bce74b4357f8a03fb78ec4": "{0}.{1}()을(를) 호출할 수 없습니다. {2} 메소드가 설정되지 않았습니다. {{KeyValueModel}}이(가) {{DataSource}}에 재대로 첨부되지 않았습니다!", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t 수신인:{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t 전송:{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "결과: {0}", + "f1d4ac54357cc0932f385d56814ba7e4": "충돌", + "f58cdc481540cd1f69a4aa4da2e37981": "올바르지 않은 비밀번호: {0}" +} + diff --git a/intl/nl/messages.json b/intl/nl/messages.json new file mode 100644 index 000000000..080ed46e1 --- /dev/null +++ b/intl/nl/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "Geen wijzigingsrecord gevonden voor {0} met ID {1}", + "04bd8af876f001ceaf443aad6a9002f9": "Voor verificatie moet model {0} worden gedefinieerd.", + "0731d0109e46c21a4e34af3346ed4856": "Dit gedrag kan gewijzigd worden in de volgende hoofdversie.", + "095afbf2f1f0e5be678f5dac5c54e717": "Toegang geweigerd", + "0caffe1d763c8cca6a61814abe33b776": "E-mail is vereist", + "10e01c895dc0b2fecc385f9f462f1ca6": "een lijst van kleuren is beschikbaar op {{http://localhost:3000/colors}}", + "1b2a6076dccbe91a56f1672eb3b8598c": "De lopende tekst van het antwoord bevat eigenschappen van het {{AccessToken}} dat is gemaakt bij aanmelding.\nAfhankelijk van de waarde van de parameter 'include' kan de lopende tekst aanvullende eigenschappen bevatten:\n\n - 'user' - 'U+007BUserU+007D' - Gegevens van de aangemelde gebruiker. {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t ONDERWERP: {0}", + "1e85f822b547a75d7d385048030e4ecb": "Gemaakt: {0}", + "275f22ab95671f095640ca99194b7635": "\t VAN: {0}", + "2d3071e3b18681c80a090dc0efbdb349": "kan {0} met ID {1} niet vinden", + "2d78192c43fd2ec52ec18f3918894f9a": "{0}-middleware is gedeprecieerd. Zie {1} voor meer informatie.", + "308e1d484516a33df788f873e65faaff": "Model '{0}' is een uitbreiding van het gedeprecieerde 'DataModel'. Gebruik in plaats daarvan 'PersistedModel'.", + "316e5b82c203cf3de31a449ee07d0650": "Booleaanse waarde verwacht, {0} ontvangen", + "320c482401afa1207c04343ab162e803": "Ongeldig type principal: {0}", + "3438fab56cc7ab92dfd88f0497e523e0": "De relaties-eigenschap van de '{0}'-configuratie moet een object zijn", + "35e5252c62d80f8c54a5290d30f4c7d0": "Controleer uw e-mail door deze link te openen in een webbrowser:\n\t{0}", + "3aae63bb7e8e046641767571c1591441": "Aanmelding mislukt omdat e-mail niet is gecontroleerd", + "3aecb24fa8bdd3f79d168761ca8a6729": "Onbekende {{middleware}}-fase {0}", + "3caaa84fc103d6d5612173ae6d43b245": "Ongeldig token: {0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "Hartelijk dank voor uw registratie", + "3d63008ccfb2af1db2142e8cc2716ace": "Waarschuwing: Geen e-mailtransport opgegeven voor verzending van e-mail. Configureer een transport om e-mailberichten te verzenden.", + "4203ab415ec66a78d3164345439ba76e": "{0} kan niet worden aangeroepen. {1}(). De methode {2} is niet geconfigureerd. De {{PersistedModel}} is niet correct gekoppeld aan een {{DataSource}}!", + "44a6c8b1ded4ed653d19ddeaaf89a606": "E-mail is niet gevonden", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "Mail verzenden:", + "4b494de07f524703ac0879addbd64b13": "E-mail is niet geverifieerd", + "4cac5f051ae431321673e04045d37772": "Model '{0}' is een uitbreiding van onbekend model '{1}'. 'PersistedModel' wordt gebruikt als basis.", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "Gegevensbron {0}: {1} kan n iet worden gemaakt", + "5858e63efaa0e4ad86b61c0459ea32fa": "U moet verbinding maken tussen het model {{Email}} en een {{Mail}}-connector", + "5e81ad3847a290dc650b47618b9cbc7e": "Aanmelden is mislukt", + "5fa3afb425819ebde958043e598cb664": "geen model gevonden met {{id}} {0}", + "61e5deebaf44d68f4e6a508f30cc31a3": "Relatie '{0}' voor model '{1}' bestaat niet", + "62e8b0a733417978bab22c8dacf5d7e6": "Bulkupdates kunnen niet worden toegepast, de connector meldt niet het juiste aantal bijgewerkte records.", + "63a091ced88001ab6acb58f61ec041c5": "\t TEKST: {0}", + "6bc376432cd9972cf991aad3de371e78": "Ontbrekende gegevens voor wijziging: {0}", + "705c2d456a3e204c4af56e671ec3225c": "{{accessToken}} is niet gevonden", + "734a7bebb65e10899935126ba63dd51f": "De opties-eigenschap van de '{0}'-configuratie moet een object zijn", + "779467f467862836e19f494a37d6ab77": "De acls-eigenschap van de '{0}'-configuratie moet een array objecten zijn", + "7d5e7ed0efaedf3f55f380caae0df8b8": "Mijn eerste mobiele toepassing", + "7e0fca41d098607e1c9aa353c67e0fa1": "Ongeldig toegangstoken", + "7e287fc885d9fdcf42da3a12f38572c1": "Verplichte verificatie", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "{{accessToken}} is vereist voor afmelding", + "80a32e80cbed65eba2103201a7c94710": "Model is niet gevonden: {0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "Eigenschap '{0}' mag niet opnieuw worden geconfigureerd voor '{1}'", + "855ecd4a64885ba272d782435f72a4d4": "Onbekend \"{0}\"-ID \"{1}\".", + "860d1a0b8bd340411fb32baa72867989": "Transport biedt geen ondersteuning voor HTTP-omleidingen.", + "895b1f941d026870b3cc8e6af087c197": "{{username}} of {{email}} is verplicht", + "8a17c5ef611e2e7535792316e66b8fca": "Wachtwoord is te lang: {0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "Aanvraag voor host {0}", + "8ae418c605b6a45f2651be9b1677c180": "Ongeldige niet-lokale methode: '{0}'", + "8bab6720ecc58ec6412358c858a53484": "Bulkupdate is mislukt; connector heeft een onverwacht aantal records gewijzigd: {0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML: {0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "De eigenschap {{`dataSource`}} ontbreekt in de configuratie van '{0}'.\nGebruik 'null' of 'false' om modellen te markeren die niet gekoppeld zijn aan een gegevensbron.", + "a40684f5a9f546115258b76938d1de37": "Een lijst van kleuren is beschikbaar op {{http://localhost:3000/colors}}", + "a50d10fc6e0959b220e085454c40381e": "Gebruiker is niet gevonden: {0}", + "a80038252430df2754884bf3c845c4cf": "Vlag \"isStatic\" ontbreekt in remoting (externe) metagegevens voor \"{0}.{1}\"; de methode wordt geregistreerd als instancemethode.", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "Onbekend \"{0}\" {{key}} \"{1}\".", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}} is verplicht", + "c2b5d51f007178170ca3952d59640ca4": "Wijzigingen van {0} kunnen niet worden hersteld:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "U moet een geldig e-mailadres opgeven", + "cd0412f2f33a4a2a316acc834f3f21a6": "U moet een {{id}} of {{data}} opgeven", + "d5552322de5605c58b62f47ad26d2716": "{{`app.boot`}} is verwijderd; gebruik in plaats daarvan de nieuwe module {{loopback-boot}}", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0} is gedeprecieerd. Zie {1} voor meer informatie.", + "dc568bee32deb0f6eaf63e73b20e8ceb": "Niet-object \"methods\"-instelling \"{0}\" wordt genegeerd.", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "Onbekend \"{0}\" {{id}} \"{1}\".", + "e92aa25b6b864e3454b65a7c422bd114": "Bulkupdate is mislukt; connector heeft een onverwacht aantal records gewist: {0}", + "ea63d226b6968e328bdf6876010786b5": "Bulkupdates kunnen niet worden toegepast, de connector meldt niet het juiste aantal gewiste records.", + "ead044e2b4bce74b4357f8a03fb78ec4": "{0} kan niet worden aangeroepen. {1}(). De methode {2} is niet geconfigureerd. De {{KeyValueModel}} is niet correct gekoppeld aan een {{DataSource}}!", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t AAN: {0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t TRANSPORT: {0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "resultaat:{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "Conflict", + "f58cdc481540cd1f69a4aa4da2e37981": "Ongeldige wachtwoord: {0}" +} + diff --git a/intl/pt/messages.json b/intl/pt/messages.json new file mode 100644 index 000000000..d5698b4e6 --- /dev/null +++ b/intl/pt/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "Nenhum registro de mudança localizado para {0} com o ID {1}", + "04bd8af876f001ceaf443aad6a9002f9": "Autenticação requer que modelo {0} seja definido.", + "0731d0109e46c21a4e34af3346ed4856": "Este comportamento pode mudar na próxima versão principal.", + "095afbf2f1f0e5be678f5dac5c54e717": "Acesso Negado", + "0caffe1d763c8cca6a61814abe33b776": "E-mail é necessário", + "10e01c895dc0b2fecc385f9f462f1ca6": "uma lista de cores está disponível em {{http://localhost:3000/colors}}", + "1b2a6076dccbe91a56f1672eb3b8598c": "O corpo de resposta contém propriedades do {{AccessToken}} criado no login.\nDependendo do valor do parâmetro `include`, o corpo poderá conter propriedades adicionais:\n\n - `user` - `U+007BUserU+007D` - Dados do usuário com login efetuado atualmente. {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t ASSUNTO:{0}", + "1e85f822b547a75d7d385048030e4ecb": "Criado: {0}", + "275f22ab95671f095640ca99194b7635": "\t DE:{0}", + "2d3071e3b18681c80a090dc0efbdb349": "não foi possível localizar {0} com ID {1}", + "2d78192c43fd2ec52ec18f3918894f9a": "O middleware {0} foi descontinuado. Consulte {1} para obter mais detalhes.", + "308e1d484516a33df788f873e65faaff": "O modelo `{0}` está estendendo `DataModel` descontinuado. Use `PersistedModel` no lugar.", + "316e5b82c203cf3de31a449ee07d0650": "Booleano esperado, obteve {0}", + "320c482401afa1207c04343ab162e803": "Tipo principal inválido: {0}", + "3438fab56cc7ab92dfd88f0497e523e0": "A propriedade de relações da configuração de `{0}` deve ser um objeto", + "35e5252c62d80f8c54a5290d30f4c7d0": "Verifique seu e-mail abrindo este link em um navegador da web:\n\t{0}", + "3aae63bb7e8e046641767571c1591441": "login com falha pois o e-mail não foi verificado", + "3aecb24fa8bdd3f79d168761ca8a6729": "Fase {0} do {{middleware}} desconhecida", + "3caaa84fc103d6d5612173ae6d43b245": "Token inválido: {0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "Obrigado por se Registrar", + "3d63008ccfb2af1db2142e8cc2716ace": "Aviso: Nenhum transporte de e-mail especificado para enviar e-mail. Configure um transporte para enviar mensagens de e-mail.", + "4203ab415ec66a78d3164345439ba76e": "Não é possível chamar {0}.{1}(). O método {2} não foi configurado. O {{PersistedModel}} não foi conectado corretamente a uma {{DataSource}}!", + "44a6c8b1ded4ed653d19ddeaaf89a606": "E-mail não encontrado", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "Enviando E-mail:", + "4b494de07f524703ac0879addbd64b13": "E-mail não foi verificado", + "4cac5f051ae431321673e04045d37772": "O modelo `{0}` está estendendo um modelo `{1}` desconhecido. Usando `PersistedModel` como a base.", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "Não é possível criar origem de dados {0}: {1}", + "5858e63efaa0e4ad86b61c0459ea32fa": "Deve-se conectar o Modelo de {{Email}} em um conector de {{Mail}}", + "5e81ad3847a290dc650b47618b9cbc7e": "falha de login", + "5fa3afb425819ebde958043e598cb664": "não foi possível localizar um modelo com {{id}} {0}", + "61e5deebaf44d68f4e6a508f30cc31a3": "Relação `{0}` não existe para o modelo `{1}`", + "62e8b0a733417978bab22c8dacf5d7e6": "Não é possível aplicar atualizações em massa, o conector não relata o número de registros de atualização corretamente.", + "63a091ced88001ab6acb58f61ec041c5": "\t TEXTO:{0}", + "6bc376432cd9972cf991aad3de371e78": "Dados ausentes para a mudança: {0}", + "705c2d456a3e204c4af56e671ec3225c": "Não foi possível localizar o {{accessToken}}", + "734a7bebb65e10899935126ba63dd51f": "A propriedade de opções da configuração de `{0}` deve ser um objeto", + "779467f467862836e19f494a37d6ab77": "A propriedade acls da configuração de `{0}` deve ser uma matriz de objetos", + "7d5e7ed0efaedf3f55f380caae0df8b8": "Meu primeiro aplicativo móvel", + "7e0fca41d098607e1c9aa353c67e0fa1": "Token de Acesso Inválido", + "7e287fc885d9fdcf42da3a12f38572c1": "Autorização Necessária", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "{{accessToken}} é necessário para efetuar logout", + "80a32e80cbed65eba2103201a7c94710": "Modelo não localizado: {0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "A propriedade `{0}` não pode ser reconfigurada para `{1}`", + "855ecd4a64885ba272d782435f72a4d4": "ID \"{1}\" de \"{0}\" desconhecido.", + "860d1a0b8bd340411fb32baa72867989": "O transporte não suporta redirecionamentos de HTTP.", + "895b1f941d026870b3cc8e6af087c197": "{{username}} ou {{email}} é necessário", + "8a17c5ef611e2e7535792316e66b8fca": "Senha muito longa: {0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "Solicitação para o host {0}", + "8ae418c605b6a45f2651be9b1677c180": "Método remoto inválido: `{0}`", + "8bab6720ecc58ec6412358c858a53484": "Atualização em massa falhou, o conector modificou um número inesperado de registros: {0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML:{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "A configuração de `{0}` não possui a propriedade {{`dataSource`}}.\nUse `null` ou `false` para marcar modelos não conectados a nenhuma origem de dados.", + "a40684f5a9f546115258b76938d1de37": "Uma lista de cores está disponível em {{http://localhost:3000/colors}}", + "a50d10fc6e0959b220e085454c40381e": "Usuário não localizado: {0}", + "a80038252430df2754884bf3c845c4cf": "Metadados remotos para \"{0}.{1}\" não possui sinalização \" isStatic\", o método foi registrado como um método de instância.", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "\"{0}\" {{key}} \"{1}\" desconhecido.", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}} é obrigatório", + "c2b5d51f007178170ca3952d59640ca4": "Não é possível retificar mudanças de {0}:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "Deve-se fornecer um e-mail válido", + "cd0412f2f33a4a2a316acc834f3f21a6": "deve-se especificar um {{id}} ou {{data}}", + "d5552322de5605c58b62f47ad26d2716": "{{`app.boot`}} foi removido, use o novo módulo {{loopback-boot}} no lugar", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0} foi descontinuado. Consulte {1} para obter mais detalhes.", + "dc568bee32deb0f6eaf63e73b20e8ceb": "Ignorando configuração de \"methods\" de não objeto de \"{0}\".", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "\"{0}\" {{id}} \"{1}\" desconhecido.", + "e92aa25b6b864e3454b65a7c422bd114": "Atualização em massa falhou, o conector excluiu um número inesperado de registros: {0}", + "ea63d226b6968e328bdf6876010786b5": "Não é possível aplicar atualizações em massa, o conector não relata o número de registros excluídos corretamente.", + "ead044e2b4bce74b4357f8a03fb78ec4": "Não é possível chamar {0}.{1}(). O método {2} não foi configurado. O {{KeyValueModel}} não foi conectado corretamente a uma {{DataSource}}!", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t PARA:{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t TRANSPORTE:{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "resultado:{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "Conflito", + "f58cdc481540cd1f69a4aa4da2e37981": "Senha inválida: {0}" +} + diff --git a/intl/tr/messages.json b/intl/tr/messages.json new file mode 100644 index 000000000..e56e50381 --- /dev/null +++ b/intl/tr/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "{0} için {1} tanıtıcılı bir değişiklik kaydı bulunamadı", + "04bd8af876f001ceaf443aad6a9002f9": "Kimlik doğrulaması {0} modelinin tanımlanmasını gerektiriyor.", + "0731d0109e46c21a4e34af3346ed4856": "Bu davranış sonraki ana sürümde değişebilir.", + "095afbf2f1f0e5be678f5dac5c54e717": "Erişim Verilmedi", + "0caffe1d763c8cca6a61814abe33b776": "E-posta zorunludur", + "10e01c895dc0b2fecc385f9f462f1ca6": "renklerin listesine şu adresle erişebilirsiniz: {{http://localhost:3000/colors}}", + "1b2a6076dccbe91a56f1672eb3b8598c": "Yanıt gövdesi, oturum açma sırasında yaratılan {{AccessToken}} belirtecine ilişkin özellikleri içerir.\n`include` parametresinin değerine bağlı olarak, gövde ek özellikler içerebilir:\n\n - `user` - `U+007BUserU+007D` - Oturum açmış olan kullanıcıya ilişkin veriler. {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t KONU:{0}", + "1e85f822b547a75d7d385048030e4ecb": "Yaratıldığı tarih: {0}", + "275f22ab95671f095640ca99194b7635": "\t KİMDEN:{0}", + "2d3071e3b18681c80a090dc0efbdb349": "{1} tanıtıcılı {0} bulunamadı", + "2d78192c43fd2ec52ec18f3918894f9a": "{0} ara katman yazılımı kullanım dışı bırakıldı. Daha fazla ayrıntı için bkz. {1}.", + "308e1d484516a33df788f873e65faaff": "`{0}` modeli kullanım dışı bırakılmış şu modeli genişletiyor: `DataModel. Onun yerine `PersistedModel` modelini kullanın.", + "316e5b82c203cf3de31a449ee07d0650": "Boole beklenirken {0} alındı", + "320c482401afa1207c04343ab162e803": "Geçersiz birincil kullanıcı tipi: {0}", + "3438fab56cc7ab92dfd88f0497e523e0": "`{0}` yapılandırmasının ilişkiler (relations) özelliği bir nesne olmalıdır", + "35e5252c62d80f8c54a5290d30f4c7d0": "Lütfen bu bağlantıyı bir web tarayıcısında açarak e-postanızı doğrulayın:\n\t{0}", + "3aae63bb7e8e046641767571c1591441": "e-posta doğrulanmadığından oturum açma başarısız oldu", + "3aecb24fa8bdd3f79d168761ca8a6729": "Bilinmeyen {{middleware}} aşaması {0}", + "3caaa84fc103d6d5612173ae6d43b245": "Geçersiz belirteç: {0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "Kaydolduğunuz için teşekkürler", + "3d63008ccfb2af1db2142e8cc2716ace": "Uyarı: E-posta göndermek için e-posta aktarımı belirtilmedi. Posta iletileri göndermek için aktarım ayarlayın.", + "4203ab415ec66a78d3164345439ba76e": "Çağrılamıyor: {0}.{1}(). {2} yöntemi ayarlanmamış. {{PersistedModel}}, bir veri kaynağına ({{DataSource}}) doğru olarak eklenmedi!", + "44a6c8b1ded4ed653d19ddeaaf89a606": "E-posta bulunamadı", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "Posta Gönderiliyor:", + "4b494de07f524703ac0879addbd64b13": "E-posta doğrulanmadı", + "4cac5f051ae431321673e04045d37772": "`{0}` modeli, bilinmeyen `{1}` modelini genişletiyor. Temel olarak 'PersistedModel' kullanılıyor.", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "Veri kaynağı {0} yaratılamıyor: {1}", + "5858e63efaa0e4ad86b61c0459ea32fa": "{{Email}} modelini bir {{Mail}} bağlayıcısına bağlamalısınız", + "5e81ad3847a290dc650b47618b9cbc7e": "oturum açma başarısız oldu", + "5fa3afb425819ebde958043e598cb664": "{{id}} {0} tanıtıcılı bir model bulunamadı", + "61e5deebaf44d68f4e6a508f30cc31a3": "`{1}` modeli için `{0}` ilişkisi yok", + "62e8b0a733417978bab22c8dacf5d7e6": "Toplu güncelleme uygulanamaz; bağlayıcı, güncellenen kayıtların sayısını doğru olarak bildirmiyor.", + "63a091ced88001ab6acb58f61ec041c5": "\t METİN:{0}", + "6bc376432cd9972cf991aad3de371e78": "Değişiklik için veri eksik: {0}", + "705c2d456a3e204c4af56e671ec3225c": "{{accessToken}} bulunamadı", + "734a7bebb65e10899935126ba63dd51f": "`{0}` yapılandırmasının seçenekler (options) özelliği bir nesne olmalıdır.", + "779467f467862836e19f494a37d6ab77": "`{0}` yapılandırmasının erişim denetim listeleri (acls) özelliği bir nesne dizisi olmalıdır.", + "7d5e7ed0efaedf3f55f380caae0df8b8": "İlk mobil uygulamam", + "7e0fca41d098607e1c9aa353c67e0fa1": "Geçersiz Erişim Belirteci", + "7e287fc885d9fdcf42da3a12f38572c1": "Yetkilendirme Gerekli", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "Oturumu kapatmak için {{accessToken}} zorunludur", + "80a32e80cbed65eba2103201a7c94710": "Model bulunamadı: {0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "`{0}` özelliği `{1}` için yeniden yapılandırılamıyor", + "855ecd4a64885ba272d782435f72a4d4": "Bilinmeyen \"{0}\" tanıtıcısı \"{1}\".", + "860d1a0b8bd340411fb32baa72867989": "Aktarım HTTP yeniden yönlendirmelerini desteklemiyor.", + "895b1f941d026870b3cc8e6af087c197": "{{username}} ya da {{email}} zorunludur", + "8a17c5ef611e2e7535792316e66b8fca": "Parola çok uzun: {0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "{0} ana makinesine yönelik istek", + "8ae418c605b6a45f2651be9b1677c180": "Uzak yöntem geçersiz: `{0}`", + "8bab6720ecc58ec6412358c858a53484": "Toplu güncelleme başarısız oldu, bağlayıcı beklenmeyen sayıda kaydı değiştirdi: {0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML:{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "`{0}` yapılandırmasında {{`dataSource`}} özelliği eksik.\nHiçbir veri kaynağına eklenmemiş modelleri işaretlemek için `null` ya da `false` kullanın.", + "a40684f5a9f546115258b76938d1de37": "Renklerin listesine şu adresle erişebilirsiniz: {{http://localhost:3000/colors}}", + "a50d10fc6e0959b220e085454c40381e": "Kullanıcı bulunamadı: {0}", + "a80038252430df2754884bf3c845c4cf": "\"{0}.{1}\" ile ilgili uzaktan iletişim meta verisinde \"isStatic\" işareti eksik; yöntem bir eşgörünüm yöntemi olarak kaydedildi.", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "Bilinmeyen \"{0}\" {{key}} \"{1}\".", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}} zorunludur", + "c2b5d51f007178170ca3952d59640ca4": "{0} değişiklik düzeltilemiyor:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "Geçerli bir e-posta belirtilmeli", + "cd0412f2f33a4a2a316acc834f3f21a6": "bir {{id}} ya da {{data}} belirtmelidir", + "d5552322de5605c58b62f47ad26d2716": "{{`app.boot`}} kaldırıldı, onun yerine yeni {{loopback-boot}} modülünü kullanın", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0} kullanım dışı bırakıldı. Daha fazla ayrıntı için bkz. {1}.", + "dc568bee32deb0f6eaf63e73b20e8ceb": "\"{0}\" öğesinin nesne olmayan \"methods\" atarı yoksayılıyor.", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "Bilinmeyen \"{0}\" {{id}} \"{1}\".", + "e92aa25b6b864e3454b65a7c422bd114": "Toplu güncelleme başarısız oldu, bağlayıcı beklenmeyen sayıda kaydı sildi: {0}", + "ea63d226b6968e328bdf6876010786b5": "Toplu güncelleme uygulanamaz; bağlayıcı, silinen kayıtların sayısını doğru olarak bildirmiyor.", + "ead044e2b4bce74b4357f8a03fb78ec4": "Çağrılamıyor: {0}.{1}(). {2} yöntemi ayarlanmamış. {{KeyValueModel}}, bir veri kaynağına ({{DataSource}}) doğru olarak eklenmedi!", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t KİME:{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t AKTARIM:{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "sonuç:{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "Çakışma", + "f58cdc481540cd1f69a4aa4da2e37981": "Geçersiz parola: {0}" +} + diff --git a/intl/zh-Hans/messages.json b/intl/zh-Hans/messages.json new file mode 100644 index 000000000..8cfebffef --- /dev/null +++ b/intl/zh-Hans/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "对于标识为 {1} 的 {0},找不到任何更改记录", + "04bd8af876f001ceaf443aad6a9002f9": "认证需要定义模型 {0}。", + "0731d0109e46c21a4e34af3346ed4856": "在下一个主版本中,此行为可能进行更改。", + "095afbf2f1f0e5be678f5dac5c54e717": "拒绝访问", + "0caffe1d763c8cca6a61814abe33b776": "电子邮件是必需的", + "10e01c895dc0b2fecc385f9f462f1ca6": "颜色列表位于:{{http://localhost:3000/colors}}", + "1b2a6076dccbe91a56f1672eb3b8598c": "响应主体包含在登录时创建的 {{AccessToken}} 的属性。\n根据“include”参数的值,主体可包含其他属性:\n\n - `user` - `U+007BUserU+007D` - 当前已登录用户的数据。 {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t主题:{0}", + "1e85f822b547a75d7d385048030e4ecb": "创建时间:{0}", + "275f22ab95671f095640ca99194b7635": "\t发件人:{0}", + "2d3071e3b18681c80a090dc0efbdb349": "无法找到标识为 {1} 的 {0}", + "2d78192c43fd2ec52ec18f3918894f9a": "不推荐 {0} 中间件。请参阅 {1} 以获取更多详细信息。", + "308e1d484516a33df788f873e65faaff": "模型“{0}”正在扩展不推荐使用的“DataModel”。请改用“PersistedModel”。", + "316e5b82c203cf3de31a449ee07d0650": "期望布尔值,获取 {0}", + "320c482401afa1207c04343ab162e803": "无效的主体类型:{0}", + "3438fab56cc7ab92dfd88f0497e523e0": "“{0}”配置的关系属性必须是对象。", + "35e5252c62d80f8c54a5290d30f4c7d0": "请通过在 Web 浏览器中打开此链接来验证您的电子邮件:\n\t{0}", + "3aae63bb7e8e046641767571c1591441": "因为尚未验证电子邮件,登录失败", + "3aecb24fa8bdd3f79d168761ca8a6729": "未知的 {{middleware}} 阶段 {0}", + "3caaa84fc103d6d5612173ae6d43b245": "无效的令牌:{0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "感谢您注册", + "3d63008ccfb2af1db2142e8cc2716ace": "警告:未指定用于发送电子邮件的电子邮件传输。设置传输以发送电子邮件消息。", + "4203ab415ec66a78d3164345439ba76e": "无法调用 {0}.{1}()。尚未设置 {2} 方法。{{PersistedModel}} 未正确附加到 {{DataSource}}!", + "44a6c8b1ded4ed653d19ddeaaf89a606": "找不到电子邮件", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "正在发送电子邮件:", + "4b494de07f524703ac0879addbd64b13": "尚未验证电子邮件", + "4cac5f051ae431321673e04045d37772": "模型“{0}”正在扩展未知的模型“{1}”。使用“PersistedModel”作为基础。", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "无法创建数据源 {0}:{1}", + "5858e63efaa0e4ad86b61c0459ea32fa": "您必须将 {{Email}} 模型连接到 {{Mail}} 连接器", + "5e81ad3847a290dc650b47618b9cbc7e": "登录失败", + "5fa3afb425819ebde958043e598cb664": "找不到具有 {{id}} {0} 的模型", + "61e5deebaf44d68f4e6a508f30cc31a3": "对于模型“{1}”,关系“{0}”不存在", + "62e8b0a733417978bab22c8dacf5d7e6": "无法应用批量更新,连接器未正确报告更新的记录数。", + "63a091ced88001ab6acb58f61ec041c5": "\t 文本:{0}", + "6bc376432cd9972cf991aad3de371e78": "缺少更改的数据:{0}", + "705c2d456a3e204c4af56e671ec3225c": "无法找到 {{accessToken}}", + "734a7bebb65e10899935126ba63dd51f": "“{0}”配置的选项属性必须是对象。", + "779467f467862836e19f494a37d6ab77": "“{0}”配置的 acls 属性必须是对象数组。", + "7d5e7ed0efaedf3f55f380caae0df8b8": "我的第一个移动应用程序", + "7e0fca41d098607e1c9aa353c67e0fa1": "无效的访问令牌", + "7e287fc885d9fdcf42da3a12f38572c1": "需要授权", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "{{accessToken}} 需要注销", + "80a32e80cbed65eba2103201a7c94710": "找不到模型:{0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "无法针对“{1}”重新配置属性“{0}”。", + "855ecd4a64885ba272d782435f72a4d4": "未知的“{0}”标识“{1}”。", + "860d1a0b8bd340411fb32baa72867989": "传输不支持 HTTP 重定向。", + "895b1f941d026870b3cc8e6af087c197": "{{username}} 或 {{email}} 是必需的", + "8a17c5ef611e2e7535792316e66b8fca": "密码过长:{0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "到主机 {0} 的请求", + "8ae418c605b6a45f2651be9b1677c180": "无效的远程方法:“{0}”", + "8bab6720ecc58ec6412358c858a53484": "批量更新失败,连接器已修改意外数量的记录:{0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML:{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "“{0}”的配置缺少 {{`dataSource`}} 属性。\n使用“null”或“false”来标记未附加到任何数据源的模型。", + "a40684f5a9f546115258b76938d1de37": "颜色列表位于:{{http://localhost:3000/colors}}", + "a50d10fc6e0959b220e085454c40381e": "找不到用户:{0}", + "a80038252430df2754884bf3c845c4cf": "“{0}.{1}”的远程处理元数据缺少“isStatic”标志,方法注册为实例方法。", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "未知的“{0}”{{key}}“{1}”。", + "ba96498b10c179f9cd75f75c8def4f70": "{{realm}} 是必需的", + "c2b5d51f007178170ca3952d59640ca4": "无法纠正 {0} 更改:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "必须提供有效电子邮件", + "cd0412f2f33a4a2a316acc834f3f21a6": "必须指定 {{id}} 或 {{data}}", + "d5552322de5605c58b62f47ad26d2716": "已除去 {{`app.boot`}},请改用新模块 {{loopback-boot}}", + "d9ef6dc3770dd8f80a129e92a79851f3": "不推荐使用 {0}。请参阅 {1} 以获取更多详细信息。", + "dc568bee32deb0f6eaf63e73b20e8ceb": "忽略“{0}”的非对象“方法”设置。", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "未知的“{0}”{{id}}“{1}”。", + "e92aa25b6b864e3454b65a7c422bd114": "批量更新失败,连接器已删除意外数量的记录:{0}", + "ea63d226b6968e328bdf6876010786b5": "无法应用批量更新,连接器未正确报告删除的记录数。", + "ead044e2b4bce74b4357f8a03fb78ec4": "无法调用 {0}.{1}()。尚未设置 {2} 方法。{{KeyValueModel}} 未正确附加到 {{DataSource}}!", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t 收件人:{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t 传输:{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "结果:{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "冲突", + "f58cdc481540cd1f69a4aa4da2e37981": "无效的密码:{0}" +} + diff --git a/intl/zh-Hant/messages.json b/intl/zh-Hant/messages.json new file mode 100644 index 000000000..a1d13ec88 --- /dev/null +++ b/intl/zh-Hant/messages.json @@ -0,0 +1,76 @@ +{ + "03f79fa268fe199de2ce4345515431c1": "對於 id 為 {1} 的 {0},找不到變更記錄", + "04bd8af876f001ceaf443aad6a9002f9": "需要定義模型 {0} 才能鑑別。", + "0731d0109e46c21a4e34af3346ed4856": "下一個主要版本中可能會變更這個行為。", + "095afbf2f1f0e5be678f5dac5c54e717": "拒絕存取", + "0caffe1d763c8cca6a61814abe33b776": "需要電子郵件", + "10e01c895dc0b2fecc385f9f462f1ca6": "{{http://localhost:3000/colors}} 提供顏色清單", + "1b2a6076dccbe91a56f1672eb3b8598c": "回應內文包含登入時建立的 {{AccessToken}} 的內容。\n根據 `include` 參數的值而定,內文還可能包含其他內容:\n\n - `user` - `U+007BUserU+007D` - 目前登入的使用者的資料。 {{(`include=user`)}}\n\n", + "1d7833c3ca2f05fdad8fad7537531c40": "\t 主旨:{0}", + "1e85f822b547a75d7d385048030e4ecb": "已建立:{0}", + "275f22ab95671f095640ca99194b7635": "\t 寄件者:{0}", + "2d3071e3b18681c80a090dc0efbdb349": "找不到 id 為 {1} 的 {0}", + "2d78192c43fd2ec52ec18f3918894f9a": "{0} 中介軟體已淘汰。如需詳細資料,請參閱 {1}。", + "308e1d484516a33df788f873e65faaff": "模型 `{0}` 正在延伸已淘汰的 `DataModel。請改用 `PersistedModel`。", + "316e5b82c203cf3de31a449ee07d0650": "預期為布林,但卻取得 {0}", + "320c482401afa1207c04343ab162e803": "無效的主體類型:{0}", + "3438fab56cc7ab92dfd88f0497e523e0": "`{0}` 配置的 relations 內容必須是物件", + "35e5252c62d80f8c54a5290d30f4c7d0": "請在 Web 瀏覽器中開啟此鏈結來驗證電子郵件:\n\t{0}", + "3aae63bb7e8e046641767571c1591441": "因為尚未驗證電子郵件,所以登入失敗", + "3aecb24fa8bdd3f79d168761ca8a6729": "{{middleware}} 階段 {0} 不明", + "3caaa84fc103d6d5612173ae6d43b245": "無效記號:{0}", + "3d617953470be16d0c2b32f0bcfbb5ee": "感謝您登錄", + "3d63008ccfb2af1db2142e8cc2716ace": "警告:未指定用於傳送電子郵件的電子郵件傳輸。請設定傳輸來傳送郵件訊息。", + "4203ab415ec66a78d3164345439ba76e": "無法呼叫 {0}.{1}()。尚未設定 {2} 方法。{{PersistedModel}} 未正確連接至 {{DataSource}}!", + "44a6c8b1ded4ed653d19ddeaaf89a606": "找不到電子郵件", + "4a4f04a4e480fc5d4ee73b84d9a4b904": "正在傳送郵件:", + "4b494de07f524703ac0879addbd64b13": "尚未驗證電子郵件", + "4cac5f051ae431321673e04045d37772": "模型 `{0}` 正在延伸不明模型 `{1}`。請使用 `PersistedModel` 作為基礎。", + "57b87ae0e65f6ab7a2e3e6cbdfca49a4": "無法建立資料來源 {0}:{1}", + "5858e63efaa0e4ad86b61c0459ea32fa": "您必須將 {{Email}} 模型連接至 {{Mail}} 連接器", + "5e81ad3847a290dc650b47618b9cbc7e": "登入失敗", + "5fa3afb425819ebde958043e598cb664": "找不到 {{id}} 為 {0} 的模型", + "61e5deebaf44d68f4e6a508f30cc31a3": "模型 `{1}` 的關係 `{0}` 不存在", + "62e8b0a733417978bab22c8dacf5d7e6": "無法套用大量更新,連接器未正確報告已更新的記錄數。", + "63a091ced88001ab6acb58f61ec041c5": "\t 文字:{0}", + "6bc376432cd9972cf991aad3de371e78": "遺漏變更的資料:{0}", + "705c2d456a3e204c4af56e671ec3225c": "找不到 {{accessToken}}", + "734a7bebb65e10899935126ba63dd51f": "`{0}` 配置的 options 內容必須是物件", + "779467f467862836e19f494a37d6ab77": "`{0}` 配置的 acls 內容必須是物件陣列", + "7d5e7ed0efaedf3f55f380caae0df8b8": "我的第一個行動式應用程式", + "7e0fca41d098607e1c9aa353c67e0fa1": "存取記號無效", + "7e287fc885d9fdcf42da3a12f38572c1": "需要授權", + "7ea04ea91aac3cb7ce0ddd96b7ff1fa4": "需要 {{accessToken}} 才能登出", + "80a32e80cbed65eba2103201a7c94710": "找不到模型:{0}", + "83cbdc2560ba9f09155ccfc63e08f1a1": "無法為 `{1}` 重新配置內容 `{0}`", + "855ecd4a64885ba272d782435f72a4d4": "\"{0}\" ID \"{1}\" 不明。", + "860d1a0b8bd340411fb32baa72867989": "傳輸不支援 HTTP 重新導向。", + "895b1f941d026870b3cc8e6af087c197": "需要 {{username}} 或 {{email}}", + "8a17c5ef611e2e7535792316e66b8fca": "密碼太長:{0}", + "8a27e0c9ce3ebf0e0c3978efb456e13e": "向主機 {0} 要求", + "8ae418c605b6a45f2651be9b1677c180": "無效的遠端方法:`{0}`", + "8bab6720ecc58ec6412358c858a53484": "大量更新失敗,連接器已修改超乎預期的記錄數:{0}", + "93ba9a1d03da3b7696332d3f155c5bb7": "\t HTML:{0}", + "97795efe0c3eb7f35ce8cf8cfe70682b": "`{0}` 的配置遺漏 {{`dataSource`}} 內容。\n請使用 `null` 或 `false` 來標示未連接至任何資料來源的模型。", + "a40684f5a9f546115258b76938d1de37": "{{http://localhost:3000/colors}} 提供顏色清單", + "a50d10fc6e0959b220e085454c40381e": "找不到使用者:{0}", + "a80038252430df2754884bf3c845c4cf": "\"{0}.{1}\" 的遠端 meta 資料遺漏 \"isStatic\" 旗標,這個方法已登錄為實例方法。", + "b6f740aeb6f2eb9bee9cb049dbfe6a28": "\"{0}\" {{key}} \"{1}\" 不明。", + "ba96498b10c179f9cd75f75c8def4f70": "需要 {{realm}}", + "c2b5d51f007178170ca3952d59640ca4": "無法更正 {0} 個變更:\n{1}", + "c68a93f0a9524fed4ff64372fc90c55f": "必須提供有效的電子郵件", + "cd0412f2f33a4a2a316acc834f3f21a6": "必須指定 {{id}} 或 {{data}}", + "d5552322de5605c58b62f47ad26d2716": "已移除 {{`app.boot`}},請改用新的模組 {{loopback-boot}}", + "d9ef6dc3770dd8f80a129e92a79851f3": "{0} 已淘汰。如需詳細資料,請參閱 {1}。", + "dc568bee32deb0f6eaf63e73b20e8ceb": "忽略 \"{0}\" 的非物件 \"methods\" 設定。", + "e4434de4bb8f5a3cd1d416e4d80d7e0b": "\"{0}\" {{id}} \"{1}\" 不明。", + "e92aa25b6b864e3454b65a7c422bd114": "大量更新失敗,連接器已刪除非預期的記錄數:{0}", + "ea63d226b6968e328bdf6876010786b5": "無法套用大量更新,連接器未正確報告已刪除的記錄數。", + "ead044e2b4bce74b4357f8a03fb78ec4": "無法呼叫 {0}.{1}()。尚未設定 {2} 方法。{{KeyValueModel}} 未正確連接至 {{DataSource}}!", + "ecb06666ef95e5db27a5ac1d6a17923b": "\t 收件者:{0}", + "f0aed00a3d3d0b97d6594e4b70e0c201": "\t 傳輸:{0}", + "f0bd73df8714cefb925e3b8da2f4c5f6": "結果:{0}", + "f1d4ac54357cc0932f385d56814ba7e4": "衝突", + "f58cdc481540cd1f69a4aa4da2e37981": "無效密碼:{0}" +} + diff --git a/lib/access-context.js b/lib/access-context.js index 75ec50165..21aa7fc5b 100644 --- a/lib/access-context.js +++ b/lib/access-context.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 assert = require('assert'); var loopback = require('./loopback'); var debug = require('debug')('loopback:security:access-context'); @@ -56,16 +61,16 @@ function AccessContext(context) { var principalType = context.principalType || Principal.USER; var principalId = context.principalId || undefined; var principalName = context.principalName || undefined; - if (principalId) { + if (principalId != null) { this.addPrincipal(principalType, principalId, principalName); } var token = this.accessToken || {}; - if (token.userId) { + if (token.userId != null) { this.addPrincipal(Principal.USER, token.userId); } - if (token.appId) { + if (token.appId != null) { this.addPrincipal(Principal.APPLICATION, token.appId); } this.remotingContext = context.remotingContext; @@ -146,7 +151,7 @@ AccessContext.prototype.getAppId = function() { * @returns {boolean} */ AccessContext.prototype.isAuthenticated = function() { - return !!(this.getUserId() || this.getAppId()); + return this.getUserId() != null || this.getAppId() != null; }; /*! diff --git a/lib/application.js b/lib/application.js index bd3375539..ea1049606 100644 --- a/lib/application.js +++ b/lib/application.js @@ -1,7 +1,14 @@ +// 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 dependencies. */ +var g = require('strong-globalize')(); + var DataSource = require('loopback-datasource-juggler').DataSource; var Registry = require('./registry'); var assert = require('assert'); @@ -153,6 +160,11 @@ app.model = function(Model, config) { this.emit('modelRemoted', Model.sharedClass); } + var self = this; + Model.on('remoteMethodDisabled', function(model, methodName) { + self.emit('remoteMethodDisabled', model, methodName); + }); + Model.shared = isPublic; Model.app = this; Model.emit('attached', this); @@ -219,11 +231,20 @@ app.models = function() { * @param {Object} config The data source config */ app.dataSource = function(name, config) { - var ds = dataSourcesFromConfig(name, config, this.connectors, this.registry); - this.dataSources[name] = - this.dataSources[classify(name)] = - this.dataSources[camelize(name)] = ds; - return ds; + try { + var 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; + } }; /** @@ -304,11 +325,13 @@ app.enableAuth = function(options) { var Model = app.registry.findModel(m); if (!Model) { throw new Error( - 'Authentication requires model ' + m + ' to be defined.'); + g.f('Authentication requires model %s to be defined.', m)); } - if (m.dataSource || m.app) return; + if (Model.dataSource || Model.app) return; + // Find descendants of Model that are attached, + // for example "Customer" extending "User" model for (var name in appModels) { var candidate = appModels[name]; var isSubclass = candidate.prototype instanceof Model; @@ -360,17 +383,17 @@ app.enableAuth = function(options) { var messages = { 403: { - message: 'Access Denied', - code: 'ACCESS_DENIED' + message: g.f('Access Denied'), + code: 'ACCESS_DENIED', }, 404: { - message: ('could not find ' + modelName + ' with id ' + modelId), - code: 'MODEL_NOT_FOUND' + message: (g.f('could not find %s with id %s', modelName, modelId)), + code: 'MODEL_NOT_FOUND', }, 401: { - message: 'Authorization Required', - code: 'AUTHORIZATION_REQUIRED' - } + message: g.f('Authorization Required'), + code: 'AUTHORIZATION_REQUIRED', + }, }; var e = new Error(messages[errStatusCode].message || messages[403].message); @@ -390,14 +413,14 @@ app.enableAuth = function(options) { app.boot = function(options) { throw new Error( - '`app.boot` was removed, use the new module loopback-boot instead'); + g.f('{{`app.boot`}} was removed, use the new module {{loopback-boot}} instead')); }; function dataSourcesFromConfig(name, config, connectorRegistry, registry) { var connectorPath; assert(typeof config === 'object', - 'cannont create data source without config object'); + 'can not create data source without config object'); if (typeof config.connector === 'string') { name = config.connector; @@ -410,6 +433,8 @@ function dataSourcesFromConfig(name, config, connectorRegistry, registry) { config.connector = require(connectorPath); } } + if (!config.connector.name) + config.connector.name = name; } return registry.createDataSource(config); @@ -466,7 +491,7 @@ function setSharedMethodSharedProperties(model, app, modelConfigs) { var settingValue = settings[setting]; var settingValueType = typeof settingValue; if (settingValueType !== 'boolean') - throw new TypeError('Expected boolean, got ' + settingValueType); + throw new TypeError(g.f('Expected boolean, got %s', settingValueType)); }); // set sharedMethod.shared using the merged settings @@ -551,7 +576,11 @@ app.listen = function(cb) { (arguments.length == 1 && typeof arguments[0] == 'function'); if (useAppConfig) { - server.listen(this.get('port'), this.get('host'), cb); + var 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); } diff --git a/lib/browser-express.js b/lib/browser-express.js index 2a7dbe912..3b4237202 100644 --- a/lib/browser-express.js +++ b/lib/browser-express.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 EventEmitter = require('events').EventEmitter; var util = require('util'); diff --git a/lib/builtin-models.js b/lib/builtin-models.js index 78bf63990..dc23b409f 100644 --- a/lib/builtin-models.js +++ b/lib/builtin-models.js @@ -1,6 +1,15 @@ +// Copyright IBM Corp. 2014,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 = 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/lib/connectors/base-connector.js b/lib/connectors/base-connector.js index c1e37b7ba..a11dcce31 100644 --- a/lib/connectors/base-connector.js +++ b/lib/connectors/base-connector.js @@ -1,3 +1,8 @@ +// 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 + /** * Expose `Connector`. */ diff --git a/lib/connectors/mail.js b/lib/connectors/mail.js index a36984c39..735e30115 100644 --- a/lib/connectors/mail.js +++ b/lib/connectors/mail.js @@ -1,7 +1,14 @@ +// 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 + /** * Dependencies. */ +var g = require('strong-globalize')(); + var mailer = require('nodemailer'); var assert = require('assert'); var debug = require('debug')('loopback:connector:mail'); @@ -54,10 +61,11 @@ MailConnector.prototype.DataAccessObject = Mailer; * Example: * * Email.setupTransport({ - * type: 'SMTP', + * type: "SMTP", * host: "smtp.gmail.com", // hostname * secureConnection: true, // use SSL * port: 465, // port for secure SMTP + * alias: "gmail", // optional alias for use with 'transport' option when sending * auth: { * user: "gmail.user@gmail.com", * pass: "userpass" @@ -83,7 +91,7 @@ MailConnector.prototype.setupTransport = function(setting) { transport = mailer.createTransport(transportModule(setting)); } - connector.transportsIndex[setting.type] = transport; + connector.transportsIndex[setting.alias || setting.type] = transport; connector.transports.push(transport); }; @@ -122,7 +130,8 @@ MailConnector.prototype.defaultTransport = function() { * to: "bar@blurdybloop.com, baz@blurdybloop.com", // list of receivers * subject: "Hello ✔", // Subject line * text: "Hello world ✔", // plaintext body - * html: "Hello world ✔" // html body + * html: "Hello world ✔", // html body + * transport: "gmail", // See 'alias' option above in setupTransport * } * * See https://github.com/andris9/Nodemailer for other supported options. @@ -144,22 +153,22 @@ Mailer.send = function(options, fn) { } if (debug.enabled || settings && settings.debug) { - console.log('Sending Mail:'); + g.log('Sending Mail:'); if (options.transport) { - console.log('\t TRANSPORT:', options.transport); + console.log(g.f('\t TRANSPORT:%s', options.transport)); } - console.log('\t TO:', options.to); - console.log('\t FROM:', options.from); - console.log('\t SUBJECT:', options.subject); - console.log('\t TEXT:', options.text); - console.log('\t HTML:', options.html); + g.log('\t TO:%s', options.to); + g.log('\t FROM:%s', options.from); + g.log('\t SUBJECT:%s', options.subject); + g.log('\t TEXT:%s', options.text); + g.log('\t HTML:%s', options.html); } if (transport) { assert(transport.sendMail, 'You must supply an Email.settings.transports containing a valid transport'); transport.sendMail(options, fn); } else { - console.warn('Warning: No email transport specified for sending email.' + + g.warn('Warning: No email transport specified for sending email.' + ' Setup a transport to send mail messages.'); process.nextTick(function() { fn(null, options); diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index 6a34417cd..f62448f61 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -1,3 +1,8 @@ +// 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 + /** * Expose `Memory`. */ diff --git a/lib/current-context.js b/lib/current-context.js new file mode 100644 index 000000000..185b7b16e --- /dev/null +++ b/lib/current-context.js @@ -0,0 +1,78 @@ +// Copyright IBM Corp. 2015,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 juggler = require('loopback-datasource-juggler'); +var remoting = require('strong-remoting'); +var LoopBackContext = require('loopback-context'); +var deprecated = require('depd')('loopback'); +var g = require('strong-globalize')(); + +module.exports = function(loopback) { + + /** + * Get the current context object. The context is preserved + * across async calls, it behaves like a thread-local storage. + * + * @returns {ChainedContext} The context object or null. + */ + loopback.getCurrentContext = function() { + // NOTE(bajtos) LoopBackContext.getCurrentContext is overriden whenever + // the context changes, therefore we cannot simply assign + // LoopBackContext.getCurrentContext() to loopback.getCurrentContext() + deprecated(g.f('%s is deprecated. See %s for more details.', + 'loopback.getCurrentContext()', + 'https://docs.strongloop.com/display/APIC/Using%20current%20context')); + return LoopBackContext.getCurrentContext(); + }; + + juggler.getCurrentContext = + remoting.getCurrentContext = loopback.getCurrentContext; + + /** + * Run the given function in such way that + * `loopback.getCurrentContext` returns the + * provided context object. + * + * **NOTE** + * + * The method is supported on the server only, it does not work + * in the browser at the moment. + * + * @param {Function} fn The function to run, it will receive arguments + * (currentContext, currentDomain). + * @param {ChainedContext} context An optional context object. + * When no value is provided, then the default global context is used. + */ + loopback.runInContext = function(fn, ctx) { + deprecated(g.f('%s is deprecated. See %s for more details.', + 'loopback.runInContext()', + 'https://docs.strongloop.com/display/APIC/Using%20current%20context')); + return LoopBackContext.runInContext(fn, ctx); + }; + + /** + * Create a new LoopBackContext instance that can be used + * for `loopback.runInContext`. + * + * **NOTES** + * + * At the moment, `loopback.getCurrentContext` supports + * a single global context instance only. If you call `createContext()` + * multiple times, `getCurrentContext` will return the last context + * created. + * + * The method is supported on the server only, it does not work + * in the browser at the moment. + * + * @param {String} scopeName An optional scope name. + * @return {ChainedContext} The new context object. + */ + loopback.createContext = function(scopeName) { + deprecated(g.f('%s is deprecated. See %s for more details.', + 'loopback.createContext()', + 'https://docs.strongloop.com/display/APIC/Using%20current%20context')); + return LoopBackContext.createContext(scopeName); + }; +}; diff --git a/lib/express-middleware.js b/lib/express-middleware.js index f058a74a6..98fae0408 100644 --- a/lib/express-middleware.js +++ b/lib/express-middleware.js @@ -1,4 +1,10 @@ +// Copyright IBM Corp. 2014,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 path = require('path'); +var deprecated = require('depd')('loopback'); var middlewares = exports; @@ -24,12 +30,10 @@ var middlewareModules = { 'cookieParser': 'cookie-parser', 'cookieSession': 'cookie-session', 'csrf': 'csurf', - 'errorHandler': 'errorhandler', 'session': 'express-session', 'methodOverride': 'method-override', 'logger': 'morgan', 'responseTime': 'response-time', - 'favicon': 'serve-favicon', 'directory': 'serve-index', // 'static': 'serve-static', 'vhost': 'vhost' @@ -39,14 +43,20 @@ middlewares.bodyParser = safeRequire('body-parser'); middlewares.json = middlewares.bodyParser && middlewares.bodyParser.json; middlewares.urlencoded = middlewares.bodyParser && middlewares.bodyParser.urlencoded; +['bodyParser', 'json', 'urlencoded'].forEach(function(name) { + if (!middlewares[name]) return; + middlewares[name] = deprecated.function( + middlewares[name], + deprecationMessage(name, 'body-parser')); +}); + for (var m in middlewareModules) { var moduleName = middlewareModules[m]; middlewares[m] = safeRequire(moduleName) || createMiddlewareNotInstalled(m, moduleName); + deprecated.property(middlewares, m, deprecationMessage(m, moduleName)); } -// serve-favicon requires a path -var favicon = middlewares.favicon; -middlewares.favicon = function(icon, options) { - icon = icon || path.join(__dirname, '../favicon.ico'); - return favicon(icon, options); -}; +function deprecationMessage(accessor, moduleName) { + return 'loopback.' + accessor + ' is deprecated. ' + + 'Use `require(\'' + moduleName + '\');` instead.'; +} diff --git a/lib/loopback.js b/lib/loopback.js index fd770e20d..3b3555393 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -1,3 +1,8 @@ +// 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 dependencies. */ @@ -217,7 +222,7 @@ loopback.template = function(file) { }); }; -require('../server/current-context')(loopback); +require('../lib/current-context')(loopback); /** * Create a named vanilla JavaScript class constructor with an attached diff --git a/lib/model.js b/lib/model.js index f3ec47b25..89cd47c4d 100644 --- a/lib/model.js +++ b/lib/model.js @@ -1,10 +1,20 @@ +// Copyright IBM Corp. 2014,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 Dependencies. */ + +var g = require('strong-globalize')(); + var assert = require('assert'); +var debug = require('debug')('loopback:model'); var RemoteObjects = require('strong-remoting'); var SharedClass = require('strong-remoting').SharedClass; var extend = require('util')._extend; +var format = require('util').format; module.exports = function(registry) { @@ -116,31 +126,32 @@ module.exports = function(registry) { var options = this.settings; var typeName = this.modelName; - var remotingOptions = {}; - extend(remotingOptions, options.remoting || {}); - - // create a sharedClass - var sharedClass = ModelCtor.sharedClass = new SharedClass( - ModelCtor.modelName, - ModelCtor, - remotingOptions - ); - - // setup a remoting type converter for this model - RemoteObjects.convert(typeName, function(val) { - return val ? new ModelCtor(val) : val; - }); - // support remoting prototype methods - ModelCtor.sharedCtor = function(data, id, fn) { + // it's important to setup this function *before* calling `new SharedClass` + // otherwise remoting metadata from our base model is picked up + 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; @@ -150,36 +161,38 @@ module.exports = function(registry) { } } - if (id && data) { + if (id != null && data) { var model = new ModelCtor(data); model.id = id; fn(null, model); } else if (data) { fn(null, new ModelCtor(data)); - } else if (id) { - ModelCtor.findById(id, function(err, model) { + } else if (id != null) { + var filter = {}; + ModelCtor.findById(id, filter, options, function(err, model) { if (err) { fn(err); } else if (model) { fn(null, model); } else { - err = new Error('could not find a model with id ' + id); + err = new Error(g.f('could not find a model with {{id}} %s', id)); err.statusCode = 404; err.code = 'MODEL_NOT_FOUND'; fn(err); } }); } else { - fn(new Error('must specify an id or data')); + fn(new Error(g.f('must specify an {{id}} or {{data}}'))); } }; var idDesc = ModelCtor.modelName + ' id'; - ModelCtor.sharedCtor.accepts = [ + ModelCtor.sharedCtor.accepts = this._removeOptionsArgIfDisabled([ {arg: 'id', type: 'any', required: true, http: {source: 'path'}, - description: idDesc} + description: idDesc}, // {arg: 'instance', type: 'object', http: {source: 'body'}} - ]; + {arg: 'options', type: 'object', http: createOptionsViaModelMethod}, + ]); ModelCtor.sharedCtor.http = [ {path: '/:id'} @@ -187,13 +200,28 @@ module.exports = function(registry) { ModelCtor.sharedCtor.returns = {root: true}; + var remotingOptions = {}; + extend(remotingOptions, options.remoting || {}); + + // create a sharedClass + var sharedClass = ModelCtor.sharedClass = new SharedClass( + ModelCtor.modelName, + ModelCtor, + remotingOptions + ); + + // setup a remoting type converter for this model + RemoteObjects.convert(typeName, function(val) { + return val ? new ModelCtor(val) : val; + }); + // before remote hook ModelCtor.beforeRemote = function(name, fn) { var className = this.modelName; this._runWhenAttachedToApp(function(app) { var remotes = app.remotes(); remotes.before(className + '.' + name, function(ctx, next) { - fn(ctx, ctx.result, next); + return fn(ctx, ctx.result, next); }); }); }; @@ -204,7 +232,7 @@ module.exports = function(registry) { this._runWhenAttachedToApp(function(app) { var remotes = app.remotes(); remotes.after(className + '.' + name, function(ctx, next) { - fn(ctx, ctx.result, next); + return fn(ctx, ctx.result, next); }); }); }; @@ -229,6 +257,14 @@ module.exports = function(registry) { sharedClass.resolve(function resolver(define) { var relations = ModelCtor.relations || {}; + var defineRaw = define; + define = function(name, options, fn) { + if (options.accepts) { + options = extend({}, options); + options.accepts = setupOptionsArgs(options.accepts); + } + defineRaw(name, options, fn); + }; // get the relations for (var relationName in relations) { @@ -357,6 +393,8 @@ module.exports = function(registry) { return ACL.WRITE; case 'updateOrCreate': return ACL.WRITE; + case 'upsertWithWhere': + return ACL.WRITE; case 'upsert': return ACL.WRITE; case 'exists': @@ -417,9 +455,40 @@ module.exports = function(registry) { if (options.isStatic === undefined) { options.isStatic = true; } + + if (options.accepts) { + options = extend({}, options); + options.accepts = setupOptionsArgs(options.accepts); + } + this.sharedClass.defineMethod(name, options); }; + function setupOptionsArgs(accepts) { + if (!Array.isArray(accepts)) + accepts = [accepts]; + + return accepts.map(function(arg) { + if (arg.http && arg.http === 'optionsFromRequest') { + // clone to preserve the input value + arg = extend({}, arg); + arg.http = createOptionsViaModelMethod; + } + return arg; + }); + } + + function createOptionsViaModelMethod(ctx) { + var EMPTY_OPTIONS = {}; + var ModelCtor = ctx.method && ctx.method.ctor; + if (!ModelCtor) + return EMPTY_OPTIONS; + if (typeof ModelCtor.createOptionsFromRemotingContext !== 'function') + return EMPTY_OPTIONS; + debug('createOptionsFromRemotingContext for %s', ctx.method.stringName); + return ModelCtor.createOptionsFromRemotingContext(ctx); + } + /** * Disable remote invocation for the method with the given name. * @@ -430,7 +499,21 @@ module.exports = function(registry) { */ Model.disableRemoteMethod = function(name, isStatic) { - this.sharedClass.disableMethod(name, isStatic || false); + var key = this.sharedClass.getKeyFromMethodNameAndTarget(name, isStatic); + this.sharedClass.disableMethodByName(key); + this.emit('remoteMethodDisabled', this.sharedClass, key); + }; + + /** + * Disable remote invocation for the method with the given name. + * + * @param {String} name The name of the method (include "prototype." if the method is defined on the prototype). + * + */ + + Model.disableRemoteMethodByName = function(name) { + this.sharedClass.disableMethodByName(name); + this.emit('remoteMethodDisabled', this.sharedClass, name); }; Model.belongsToRemoting = function(relationName, relation, define) { @@ -441,10 +524,13 @@ module.exports = function(registry) { define('__get__' + relationName, { isStatic: false, http: {verb: 'get', path: '/' + pathName}, - accepts: {arg: 'refresh', type: 'boolean', http: {source: 'query'}}, + accepts: this._removeOptionsArgIfDisabled([ + {arg: 'refresh', type: 'boolean', http: {source: 'query'}}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), accessType: 'READ', - description: 'Fetches belongsTo relation ' + relationName + '.', - returns: {arg: relationName, type: modelName, root: true} + description: format('Fetches belongsTo relation %s.', relationName), + returns: {arg: relationName, type: modelName, root: true}, }, fn); }; @@ -452,7 +538,7 @@ module.exports = function(registry) { if (ctx.result !== null) return cb(); var fk = ctx.getArgByName('fk'); - var msg = 'Unknown "' + toModelName + '" id "' + fk + '".'; + var msg = g.f('Unknown "%s" id "%s".', toModelName, fk); var error = new Error(msg); error.statusCode = error.status = 404; error.code = 'MODEL_NOT_FOUND'; @@ -466,8 +552,11 @@ module.exports = function(registry) { define('__get__' + relationName, { isStatic: false, http: {verb: 'get', path: '/' + pathName}, - accepts: {arg: 'refresh', type: 'boolean', http: {source: 'query'}}, - description: 'Fetches hasOne relation ' + relationName + '.', + accepts: this._removeOptionsArgIfDisabled([ + {arg: 'refresh', type: 'boolean', http: {source: 'query'}}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Fetches hasOne relation %s.', relationName), accessType: 'READ', returns: {arg: relationName, type: relation.modelTo.modelName, root: true}, rest: {after: convertNullToNotFoundError.bind(null, toModelName)} @@ -476,8 +565,14 @@ module.exports = function(registry) { define('__create__' + relationName, { isStatic: false, http: {verb: 'post', path: '/' + pathName}, - accepts: {arg: 'data', type: toModelName, http: {source: 'body'}}, - description: 'Creates a new instance in ' + relationName + ' of this model.', + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'data', type: 'object', model: toModelName, + http: {source: 'body'}, + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Creates a new instance in %s of this model.', relationName), accessType: 'WRITE', returns: {arg: 'data', type: toModelName, root: true} }); @@ -485,8 +580,14 @@ module.exports = function(registry) { define('__update__' + relationName, { isStatic: false, http: {verb: 'put', path: '/' + pathName}, - accepts: {arg: 'data', type: toModelName, http: {source: 'body'}}, - description: 'Update ' + relationName + ' of this model.', + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'data', type: 'object', model: toModelName, + http: {source: 'body'}, + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Update %s of this model.', relationName), accessType: 'WRITE', returns: {arg: 'data', type: toModelName, root: true} }); @@ -494,8 +595,11 @@ module.exports = function(registry) { define('__destroy__' + relationName, { isStatic: false, http: {verb: 'delete', path: '/' + pathName}, - description: 'Deletes ' + relationName + ' of this model.', - accessType: 'WRITE' + accepts: this._removeOptionsArgIfDisabled([ + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Deletes %s of this model.', relationName), + accessType: 'WRITE', }); }; @@ -507,10 +611,16 @@ module.exports = function(registry) { define('__findById__' + relationName, { isStatic: false, http: {verb: 'get', path: '/' + pathName + '/:fk'}, - accepts: {arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}, - description: 'Find a related item by id for ' + relationName + '.', + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'fk', type: 'any', + description: format('Foreign key for %s', relationName), + required: true, + http: {source: 'path'}, + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Find a related item by id for %s.', relationName), accessType: 'READ', returns: {arg: 'result', type: toModelName, root: true}, rest: {after: convertNullToNotFoundError.bind(null, toModelName)} @@ -520,10 +630,16 @@ module.exports = function(registry) { define('__destroyById__' + relationName, { isStatic: false, http: {verb: 'delete', path: '/' + pathName + '/:fk'}, - accepts: {arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}, - description: 'Delete a related item by id for ' + relationName + '.', + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'fk', type: 'any', + description: format('Foreign key for %s', relationName), + required: true, + http: {source: 'path'}, + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Delete a related item by id for %s.', relationName), accessType: 'WRITE', returns: [] }, destroyByIdFunc); @@ -532,13 +648,15 @@ module.exports = function(registry) { define('__updateById__' + relationName, { isStatic: false, http: {verb: 'put', path: '/' + pathName + '/:fk'}, - accepts: [ + accepts: this._removeOptionsArgIfDisabled([ {arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}, - {arg: 'data', type: toModelName, http: {source: 'body'}} - ], - description: 'Update a related item by id for ' + relationName + '.', + description: format('Foreign key for %s', relationName), + required: true, + http: { source: 'path' }}, + {arg: 'data', type: 'object', model: toModelName, http: {source: 'body'}}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Update a related item by id for %s.', relationName), accessType: 'WRITE', returns: {arg: 'result', type: toModelName, root: true} }, updateByIdFunc); @@ -549,17 +667,21 @@ module.exports = function(registry) { var accepts = []; if (relation.type === 'hasMany' && relation.modelThrough) { // Restrict: only hasManyThrough relation can have additional properties - accepts.push({arg: 'data', type: modelThrough.modelName, http: {source: 'body'}}); + accepts.push({arg: 'data', type: 'object', model: modelThrough.modelName, http: {source: 'body'}}); } var addFunc = this.prototype['__link__' + relationName]; define('__link__' + relationName, { isStatic: false, http: {verb: 'put', path: '/' + pathName + '/rel/:fk'}, - accepts: [{arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}].concat(accepts), - description: 'Add a related item by id for ' + relationName + '.', + accepts: [{ arg: 'fk', type: 'any', + description: format('Foreign key for %s', relationName), + required: true, + http: {source: 'path'}}, + ].concat(accepts).concat(this._removeOptionsArgIfDisabled([ + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ])), + description: format('Add a related item by id for %s.', relationName), accessType: 'WRITE', returns: {arg: relationName, type: modelThrough.modelName, root: true} }, addFunc); @@ -568,10 +690,16 @@ module.exports = function(registry) { define('__unlink__' + relationName, { isStatic: false, http: {verb: 'delete', path: '/' + pathName + '/rel/:fk'}, - accepts: {arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}, - description: 'Remove the ' + relationName + ' relation to an item by id.', + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'fk', type: 'any', + description: format('Foreign key for %s', relationName), + required: true, + http: {source: 'path'}, + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Remove the %s relation to an item by id.', relationName), accessType: 'WRITE', returns: [] }, removeFunc); @@ -582,10 +710,16 @@ module.exports = function(registry) { define('__exists__' + relationName, { isStatic: false, http: {verb: 'head', path: '/' + pathName + '/rel/:fk'}, - accepts: {arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}, - description: 'Check the existence of ' + relationName + ' relation to an item by id.', + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'fk', type: 'any', + description: format('Foreign key for %s', relationName), + required: true, + http: {source: 'path'}, + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Check the existence of %s relation to an item by id.', relationName), accessType: 'READ', returns: {arg: 'exists', type: 'boolean', root: true}, rest: { @@ -594,7 +728,7 @@ module.exports = function(registry) { if (ctx.result === false) { var modelName = ctx.method.sharedClass.name; var id = ctx.getArgByName('id'); - var msg = 'Unknown "' + modelName + '" id "' + id + '".'; + var msg = g.f('Unknown "%s" {{id}} "%s".', modelName, id); var error = new Error(msg); error.statusCode = error.status = 404; error.code = 'MODEL_NOT_FOUND'; @@ -627,8 +761,11 @@ module.exports = function(registry) { define('__get__' + scopeName, { isStatic: isStatic, http: {verb: 'get', path: '/' + pathName}, - accepts: {arg: 'filter', type: 'object'}, - description: 'Queries ' + scopeName + ' of ' + this.modelName + '.', + accepts: this._removeOptionsArgIfDisabled([ + {arg: 'filter', type: 'object'}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Queries %s of %s.', scopeName, this.modelName), accessType: 'READ', returns: {arg: scopeName, type: [toModelName], root: true} }); @@ -636,8 +773,16 @@ module.exports = function(registry) { define('__create__' + scopeName, { isStatic: isStatic, http: {verb: 'post', path: '/' + pathName}, - accepts: {arg: 'data', type: toModelName, http: {source: 'body'}}, - description: 'Creates a new instance in ' + scopeName + ' of this model.', + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'data', + type: 'object', + model: toModelName, + http: {source: 'body'}, + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Creates a new instance in %s of this model.', scopeName), accessType: 'WRITE', returns: {arg: 'data', type: toModelName, root: true} }); @@ -645,21 +790,46 @@ module.exports = function(registry) { define('__delete__' + scopeName, { isStatic: isStatic, http: {verb: 'delete', path: '/' + pathName}, - description: 'Deletes all ' + scopeName + ' of this model.', - accessType: 'WRITE' + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'where', type: 'object', + // The "where" argument is not exposed in the REST API + // but we need to provide a value so that we can pass "options" + // as the third argument. + http: function(ctx) { return undefined; }, + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Deletes all %s of this model.', scopeName), + accessType: 'WRITE', }); define('__count__' + scopeName, { isStatic: isStatic, http: {verb: 'get', path: '/' + pathName + '/count'}, - accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, - description: 'Counts ' + scopeName + ' of ' + this.modelName + '.', + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'where', type: 'object', + description: 'Criteria to match model instances', + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + description: format('Counts %s of %s.', scopeName, this.modelName), accessType: 'READ', returns: {arg: 'count', type: 'number'} }); }; + Model._removeOptionsArgIfDisabled = function(accepts) { + if (this.settings.injectOptionsFromRemoteContext) + return accepts; + var lastArg = accepts[accepts.length - 1]; + var hasOptions = lastArg.arg === 'options' && lastArg.type === 'object'; + assert(hasOptions, 'last accepts argument is "options" arg'); + return accepts.slice(0, -1); + }; + /** * Enabled deeply-nested queries of related models via REST API. * @@ -702,9 +872,9 @@ module.exports = function(registry) { acceptArgs = [ { arg: paramName, type: 'any', http: { source: 'path' }, - description: 'Foreign key for ' + relation.name + '.', - required: true - } + description: format('Foreign key for %s.', relation.name), + required: true, + }, ]; } else { httpPath = pathName; @@ -732,12 +902,12 @@ module.exports = function(registry) { var getterFn = relation.modelFrom.prototype[getterName]; if (typeof getterFn !== 'function') { - throw new Error('Invalid remote method: `' + getterName + '`'); + throw new Error(g.f('Invalid remote method: `%s`', getterName)); } var nestedFn = relation.modelTo.prototype[method.name]; if (typeof nestedFn !== 'function') { - throw new Error('Invalid remote method: `' + method.name + '`'); + throw new Error(g.f('Invalid remote method: `%s`', method.name)); } var opts = {}; @@ -806,8 +976,8 @@ module.exports = function(registry) { listenerTree.before = listenerTree.before || {}; listenerTree.after = listenerTree.after || {}; - var beforeListeners = remotes.listenerTree.before[toModelName] || {}; - var afterListeners = remotes.listenerTree.after[toModelName] || {}; + var beforeListeners = listenerTree.before[toModelName] || {}; + var afterListeners = listenerTree.after[toModelName] || {}; sharedClass.methods().forEach(function(method) { var delegateTo = method.rest && method.rest.delegateTo; @@ -830,12 +1000,53 @@ module.exports = function(registry) { }); } else { - throw new Error('Relation `' + relationName + '` does not exist for model `' + this.modelName + '`'); + var msg = g.f('Relation `%s` does not exist for model `%s`', relationName, this.modelName); + throw new Error(msg); } }; Model.ValidationError = require('loopback-datasource-juggler').ValidationError; + /** + * Create "options" value to use when invoking model methods + * via strong-remoting (e.g. REST). + * + * Example + * + * ```js + * MyModel.myMethod = function(options, cb) { + * // by default, options contains only one property "accessToken" + * var accessToken = options && options.accessToken; + * var userId = accessToken && accessToken.userId; + * var message = 'Hello ' + (userId ? 'user #' + userId : 'anonymous'); + * cb(null, message); + * }); + * + * MyModel.remoteMethod('myMethod', { + * accepts: { + * arg: 'options', + * type: 'object', + * // "optionsFromRequest" is a loopback-specific HTTP mapping that + * // calls Model's createOptionsFromRemotingContext + * // to build the argument value + * http: 'optionsFromRequest' + * }, + * returns: { + * arg: 'message', + * type: 'string' + * } + * }); + * ``` + * + * @param {Object} ctx A strong-remoting Context instance + * @returns {Object} The value to pass to "options" argument. + */ + Model.createOptionsFromRemotingContext = function(ctx) { + return { + accessToken: ctx.req.accessToken, + }; + }; + // setup the initial model Model.setup(); diff --git a/lib/persisted-model.js b/lib/persisted-model.js index b3bb62281..fd7d9db11 100644 --- a/lib/persisted-model.js +++ b/lib/persisted-model.js @@ -1,7 +1,13 @@ +// Copyright IBM Corp. 2014,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 Dependencies. */ +var g = require('strong-globalize')(); var runtime = require('./runtime'); var assert = require('assert'); var async = require('async'); @@ -60,9 +66,10 @@ module.exports = function(registry) { function throwNotAttached(modelName, methodName) { throw new Error( - 'Cannot call ' + modelName + '.' + methodName + '().' + - ' The ' + methodName + ' method has not been setup.' + - ' The PersistedModel has not been correctly attached to a DataSource!' + g.f('Cannot call %s.%s().' + + ' The %s method has not been setup.' + + ' The {{PersistedModel}} has not been correctly attached to a {{DataSource}}!', + modelName, methodName, methodName) ); } @@ -77,7 +84,7 @@ module.exports = function(registry) { var modelName = ctx.method.sharedClass.name; var id = ctx.getArgByName('id'); - var msg = 'Unknown "' + modelName + '" id "' + id + '".'; + var msg = g.f('Unknown "%s" {{id}} "%s".', modelName, id); var error = new Error(msg); error.statusCode = error.status = 404; error.code = 'MODEL_NOT_FOUND'; @@ -106,22 +113,78 @@ module.exports = function(registry) { * @param {Object} model Updated model instance. */ - PersistedModel.upsert = PersistedModel.updateOrCreate = function upsert(data, callback) { + PersistedModel.upsert = PersistedModel.updateOrCreate = PersistedModel.patchOrCreate = + function upsert(data, callback) { throwNotAttached(this.modelName, 'upsert'); }; /** - * Find one record matching the optional `where` filter. The same as `find`, but limited to one object. - * Returns an object, not collection. - * If not found, create the object using data provided as second argument. - * - * @param {Object} where Where clause, such as `{test: 'me'}` + * Update or insert a model instance based on the search criteria. + * If there is a single instance retrieved, update the retrieved model. + * Creates a new model if no model instances were found. + * Returns an error if multiple instances are found. + * * @param {Object} [where] `where` filter, like + * ``` + * { key: val, key2: {gt: 'val2'}, ...} + * ``` *
see * [Where filter](https://docs.strongloop.com/display/LB/Where+filter#Wherefilter-Whereclauseforothermethods). + * @param {Object} data The model instance data to insert. + * @callback {Function} callback Callback function called with `cb(err, obj)` signature. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} model Updated model instance. + */ + + PersistedModel.upsertWithWhere = + PersistedModel.patchOrCreateWithWhere = function upsertWithWhere(where, data, callback) { + throwNotAttached(this.modelName, 'upsertWithWhere'); + }; + + /** + * Replace or insert a model instance; replace existing record if one is found, + * such that parameter `data.id` matches `id` of model instance; otherwise, + * insert a new record. + * @param {Object} data The model instance data. + * @options {Object} [options] Options for replaceOrCreate + * @property {Boolean} validate Perform validation before saving. Default is true. + * @callback {Function} callback Callback function called with `cb(err, obj)` signature. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} model Replaced model instance. + */ + + PersistedModel.replaceOrCreate = function replaceOrCreate(data, callback) { + throwNotAttached(this.modelName, 'replaceOrCreate'); + }; + + /** + * Finds one record matching the optional filter object. If not found, creates + * the object using the data provided as second argument. In this sense it is + * the same as `find`, but limited to one object. Returns an object, not + * collection. If you don't provide the filter object argument, it tries to + * locate an existing object that matches the `data` argument. + * + * @options {Object} [filter] Optional Filter object; see below. + * @property {String|Object|Array} fields Identify fields to include in return result. + *
See [Fields filter](http://docs.strongloop.com/display/LB/Fields+filter). + * @property {String|Object|Array} include See PersistedModel.include documentation. + *
See [Include filter](http://docs.strongloop.com/display/LB/Include+filter). + * @property {Number} limit Maximum number of instances to return. + *
See [Limit filter](http://docs.strongloop.com/display/LB/Limit+filter). + * @property {String} order Sort order: either "ASC" for ascending or "DESC" for descending. + *
See [Order filter](http://docs.strongloop.com/display/LB/Order+filter). + * @property {Number} skip Number of results to skip. + *
See [Skip filter](http://docs.strongloop.com/display/LB/Skip+filter). + * @property {Object} where Where clause, like + * ``` + * {where: {key: val, key2: {gt: val2}, ...}} + * ``` + *
See + * [Where filter](https://docs.strongloop.com/display/LB/Where+filter#Wherefilter-Whereclauseforqueries). * @param {Object} data Data to insert if object matching the `where` filter is not found. - * @callback {Function} callback Callback function called with `cb(err, instance)` arguments. Required. + * @callback {Function} callback Callback function called with `cb(err, instance, created)` arguments. Required. * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). * @param {Object} instance Model instance matching the `where` filter, if found. + * @param {Boolean} created True if the instance matching the `where` filter was created. */ PersistedModel.findOrCreate = function findOrCreate(query, data, callback) { @@ -456,10 +519,45 @@ module.exports = function(registry) { * @param {Object} instance Updated instance. */ - PersistedModel.prototype.updateAttributes = function updateAttributes(data, cb) { + PersistedModel.prototype.updateAttributes = PersistedModel.prototype.patchAttributes = + function updateAttributes(data, cb) { throwNotAttached(this.modelName, 'updateAttributes'); }; + /** + * Replace attributes for a model instance and persist it into the datasource. + * Performs validation before replacing. + * + * @param {Object} data Data to replace. + * @options {Object} [options] Options for replace + * @property {Boolean} validate Perform validation before saving. Default is true. + * @callback {Function} callback Callback function called with `(err, instance)` arguments. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} instance Replaced instance. + */ + + PersistedModel.prototype.replaceAttributes = function replaceAttributes(data, cb) { + throwNotAttached(this.modelName, 'replaceAttributes'); + }; + + /** + * Replace attributes for a model instance whose id is the first input + * argument and persist it into the datasource. + * Performs validation before replacing. + * + * @param {*} id The ID value of model instance to replace. + * @param {Object} data Data to replace. + * @options {Object} [options] Options for replace + * @property {Boolean} validate Perform validation before saving. Default is true. + * @callback {Function} callback Callback function called with `(err, instance)` arguments. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} instance Replaced instance. + */ + + PersistedModel.replaceById = function replaceById(id, data, cb) { + throwNotAttached(this.modelName, 'replaceById'); + }; + /** * Reload object from persistence. Requires `id` member of `object` to be able to call `find`. * @callback {Function} callback Callback function called with `(err, instance)` arguments. Required. @@ -528,6 +626,9 @@ module.exports = function(registry) { var typeName = PersistedModel.modelName; var options = PersistedModel.settings; + // This is just for LB 2.x + options.replaceOnPUT = options.replaceOnPUT === true; + function setRemoting(scope, name, options) { var fn = scope[name]; fn._delegate = true; @@ -538,24 +639,82 @@ 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', description: 'Model instance data', http: {source: 'body'}}, + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'data', type: 'object', model: typeName, allowArray: true, + description: 'Model instance data', + http: {source: 'body'}, + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'post', path: '/'} }); - setRemoting(PersistedModel, 'upsert', { - aliases: ['updateOrCreate'], - description: 'Update an existing model instance or insert a new one into the data source.', + var upsertOptions = { + aliases: ['patchOrCreate', 'updateOrCreate'], + description: 'Patch an existing model instance or insert a new one into the data source.', + accessType: 'WRITE', + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'data', type: 'object', model: typeName, http: {source: 'body'}, + description: 'Model instance data', + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + returns: {arg: 'data', type: typeName, root: true}, + http: [{verb: 'patch', path: '/'}], + }; + + if (!options.replaceOnPUT) { + upsertOptions.http.unshift({ verb: 'put', path: '/' }); + } + setRemoting(PersistedModel, 'upsert', upsertOptions); + + var replaceOrCreateOptions = { + description: 'Replace an existing model instance or insert a new one into the data source.', accessType: 'WRITE', - accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'data', type: 'object', model: typeName, + http: {source: 'body'}, + description: 'Model instance data', + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), returns: {arg: 'data', type: typeName, root: true}, - http: {verb: 'put', path: '/'} + http: [{verb: 'post', path: '/replaceOrCreate'}], + }; + + if (options.replaceOnPUT) { + replaceOrCreateOptions.http.push({ verb: 'put', path: '/' }); + } + + setRemoting(PersistedModel, 'replaceOrCreate', replaceOrCreateOptions); + + setRemoting(PersistedModel, 'upsertWithWhere', { + aliases: ['patchOrCreateWithWhere'], + description: 'Update an existing model instance or insert a new one into ' + + 'the data source based on the where criteria.', + accessType: 'WRITE', + accepts: this._removeOptionsArgIfDisabled([ + {arg: 'where', type: 'object', http: {source: 'query'}, + 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: 'optionsFromRequest'}, + ]), + returns: { arg: 'data', type: typeName, root: true }, + http: { verb: 'post', path: '/upsertWithWhere' }, }); 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: this._removeOptionsArgIfDisabled([ + {arg: 'id', type: 'any', description: 'Model id', required: true}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), returns: {arg: 'exists', type: 'boolean'}, http: [ {verb: 'get', path: '/:id/exists'}, @@ -584,23 +743,51 @@ module.exports = function(registry) { }); setRemoting(PersistedModel, 'findById', { - description: 'Find a model instance by id from the data source.', + description: 'Find a model instance by {{id}} from the data source.', accessType: 'READ', - accepts: [ + accepts: this._removeOptionsArgIfDisabled([ { arg: 'id', type: 'any', description: 'Model id', required: true, http: {source: 'path'}}, - { arg: 'filter', type: 'object', - description: 'Filter defining fields and include'} - ], + {arg: 'filter', type: 'object', + description: + 'Filter defining fields and include - must be a JSON-encoded string (' + + '{"something":"value"})'}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'get', path: '/:id'}, rest: {after: convertNullToNotFoundError} }); + var replaceByIdOptions = { + description: 'Replace attributes for a model instance and persist it into the data source.', + accessType: 'WRITE', + accepts: this._removeOptionsArgIfDisabled([ + {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: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), + returns: { arg: 'data', type: typeName, root: true }, + http: [{ verb: 'post', path: '/:id/replace' }], + }; + + if (options.replaceOnPUT) { + replaceByIdOptions.http.push({ verb: 'put', path: '/:id' }); + } + + setRemoting(PersistedModel, 'replaceById', replaceByIdOptions); + 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: this._removeOptionsArgIfDisabled([ + {arg: 'filter', type: 'object', description: + 'Filter defining fields, where, include, order, offset, and limit - must be a ' + + 'JSON-encoded string ({"something":"value"})'}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), returns: {arg: 'data', type: [typeName], root: true}, http: {verb: 'get', path: '/'} }); @@ -608,7 +795,12 @@ 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: this._removeOptionsArgIfDisabled([ + {arg: 'filter', type: 'object', description: + 'Filter defining fields, where, include, order, offset, and limit - must be a ' + + 'JSON-encoded string ({"something":"value"})'}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'get', path: '/findOne'}, rest: {after: convertNullToNotFoundError} @@ -617,7 +809,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: this._removeOptionsArgIfDisabled([ + {arg: 'where', type: 'object', description: 'filter.where object'}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), returns: { arg: 'count', type: 'object', @@ -630,29 +825,38 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'updateAll', { aliases: ['update'], - description: 'Update instances of the model matched by where from the data source.', + description: 'Update instances of the model matched by {{where}} from the data source.', accessType: 'WRITE', - accepts: [ - {arg: 'where', type: 'object', http: {source: 'query'}, + accepts: this._removeOptionsArgIfDisabled([ + {arg: 'where', type: 'object', http: { source: 'query'}, description: 'Criteria to match model instances'}, - {arg: 'data', type: 'object', http: {source: 'body'}, + {arg: 'data', type: 'object', model: typeName, http: {source: 'body'}, description: 'An object of model property name/value pairs'}, - ], + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), returns: { - arg: 'count', - description: 'The number of instances updated', - type: 'object', - root: true + arg: 'info', + description: 'Information related to the outcome of the operation', + type: { + count: { + type: 'number', + description: 'The number of instances updated', + }, + }, + root: true, }, http: {verb: 'post', path: '/update'} }); setRemoting(PersistedModel, 'deleteById', { aliases: ['destroyById', 'removeById'], - description: 'Delete a model instance by id from the data source.', + 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: this._removeOptionsArgIfDisabled([ + {arg: 'id', type: 'any', description: 'Model id', required: true, + http: {source: 'path'}}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), http: {verb: 'del', path: '/:id'}, returns: {arg: 'count', type: 'object', root: true} }); @@ -660,18 +864,35 @@ 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: this._removeOptionsArgIfDisabled([ + {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), returns: {arg: 'count', type: 'number'}, http: {verb: 'get', path: '/count'} }); - setRemoting(PersistedModel.prototype, 'updateAttributes', { - description: 'Update attributes for a model instance and persist it into the data source.', + var updateAttributesOptions = { + aliases: ['patchAttributes'], + description: 'Patch attributes for a model instance and persist it into the data source.', accessType: 'WRITE', - accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, + accepts: this._removeOptionsArgIfDisabled([ + { + arg: 'data', type: 'object', model: typeName, + http: {source: 'body'}, + description: 'An object of model property name/value pairs', + }, + {arg: 'options', type: 'object', http: 'optionsFromRequest'}, + ]), returns: {arg: 'data', type: typeName, root: true}, - http: {verb: 'put', path: '/'} - }); + http: [{verb: 'patch', path: '/'}], + }; + + setRemoting(PersistedModel.prototype, 'updateAttributes', updateAttributesOptions); + + if (!options.replaceOnPUT) { + updateAttributesOptions.http.unshift({ verb: 'put', path: '/' }); + } if (options.trackChanges || options.enableRemoteReplication) { setRemoting(PersistedModel, 'diff', { @@ -680,7 +901,7 @@ module.exports = function(registry) { accepts: [ {arg: 'since', type: 'number', description: 'Find deltas since this checkpoint'}, {arg: 'remoteChanges', type: 'array', description: 'an array of change objects', - http: {source: 'body'}} + http: {source: 'body'}} ], returns: {arg: 'result', type: 'object', root: true}, http: {verb: 'post', path: '/diff'} @@ -746,10 +967,9 @@ module.exports = function(registry) { }); setRemoting(PersistedModel, 'updateLastChange', { - description: [ - 'Update the properties of the most recent change record', - 'kept for this instance.' - ], + description: + 'Update the properties of the most recent change record ' + + 'kept for this instance.', accessType: 'WRITE', accepts: [ { @@ -757,8 +977,8 @@ module.exports = function(registry) { description: 'Model id' }, { - arg: 'data', type: 'object', http: {source: 'body'}, - description: 'An object of Change property name/value pairs' + arg: 'data', type: 'object', model: typeName, http: {source: 'body'}, + description: 'An object of Change property name/value pairs', }, ], returns: { arg: 'result', type: this.Change.modelName, root: true }, @@ -882,12 +1102,7 @@ module.exports = function(registry) { PersistedModel.checkpoint = function(cb) { var Checkpoint = this.getChangeModel().getCheckpointModel(); - this.getSourceId(function(err, sourceId) { - if (err) return cb(err); - Checkpoint.create({ - sourceId: sourceId - }, cb); - }); + Checkpoint.bumpLastSeq(cb); }; /** @@ -908,7 +1123,7 @@ module.exports = function(registry) { * * @param {Number} [since] Since this checkpoint * @param {Model} targetModel Target this model class - * @param {Object} [options] + * @param {Object} [options] An optional options object to pass to underlying data-access calls. * @param {Object} [options.filter] Replicate models that match this filter * @callback {Function} [callback] Callback function called with `(err, conflicts)` arguments. * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). @@ -933,6 +1148,10 @@ module.exports = function(registry) { since = { source: since, target: since }; } + if (typeof options === 'function') { + options = {}; + } + options = options || {}; var sourceModel = this; @@ -1047,7 +1266,7 @@ module.exports = function(registry) { function bulkUpdate(_updates, cb) { debug('\tstarting bulk update'); updates = _updates; - targetModel.bulkUpdate(updates, function(err) { + targetModel.bulkUpdate(updates, options, function(err) { var conflicts = err && err.details && err.details.conflicts; if (conflicts && err.statusCode == 409) { diff.conflicts = conflicts; @@ -1131,7 +1350,7 @@ module.exports = function(registry) { if (err) return cb(err); if (!inst) { return cb && - cb(new Error('Missing data for change: ' + change.modelId)); + cb(new Error(g.f('Missing data for change: %s', change.modelId))); } if (inst.toObject) { update.data = inst.toObject(); @@ -1161,15 +1380,28 @@ module.exports = function(registry) { * **Note: this is not atomic** * * @param {Array} updates An updates list, usually from [createUpdates()](#persistedmodel-createupdates). + * @param {Object} [options] An optional options object to pass to underlying data-access calls. * @param {Function} callback Callback function. */ - PersistedModel.bulkUpdate = function(updates, callback) { + PersistedModel.bulkUpdate = function(updates, options, callback) { var tasks = []; var Model = this; var Change = this.getChangeModel(); var conflicts = []; + var lastArg = arguments[arguments.length - 1]; + + if (typeof lastArg === 'function' && arguments.length > 1) { + callback = lastArg; + } + + if (typeof options === 'function') { + options = {}; + } + + options = options || {}; + buildLookupOfAffectedModelData(Model, updates, function(err, currentMap) { if (err) return callback(err); @@ -1179,18 +1411,18 @@ module.exports = function(registry) { switch (update.type) { case Change.UPDATE: tasks.push(function(cb) { - applyUpdate(Model, id, current, update.data, update.change, conflicts, cb); + applyUpdate(Model, id, current, update.data, update.change, conflicts, options, cb); }); break; case Change.CREATE: tasks.push(function(cb) { - applyCreate(Model, id, current, update.data, update.change, conflicts, cb); + applyCreate(Model, id, current, update.data, update.change, conflicts, options, cb); }); break; case Change.DELETE: tasks.push(function(cb) { - applyDelete(Model, id, current, update.change, conflicts, cb); + applyDelete(Model, id, current, update.change, conflicts, options, cb); }); break; } @@ -1199,7 +1431,7 @@ module.exports = function(registry) { async.parallel(tasks, function(err) { if (err) return callback(err); if (conflicts.length) { - err = new Error('Conflict'); + err = new Error(g.f('Conflict')); err.statusCode = 409; err.details = { conflicts: conflicts }; return callback(err); @@ -1224,7 +1456,7 @@ module.exports = function(registry) { }); } - function applyUpdate(Model, id, current, data, change, conflicts, cb) { + function applyUpdate(Model, id, current, data, change, conflicts, options, cb) { var Change = Model.getChangeModel(); var rev = current ? Change.revisionForInst(current) : null; @@ -1242,7 +1474,7 @@ module.exports = function(registry) { // but not included in `data` // See https://github.com/strongloop/loopback/issues/1215 - Model.updateAll(current.toObject(), data, function(err, result) { + Model.updateAll(current.toObject(), data, options, function(err, result) { if (err) return cb(err); var count = result && result.count; @@ -1263,22 +1495,22 @@ module.exports = function(registry) { case undefined: case null: return cb(new Error( - 'Cannot apply bulk updates, ' + + g.f('Cannot apply bulk updates, ' + 'the connector does not correctly report ' + - 'the number of updated records.')); + 'the number of updated records.'))); default: debug('%s.updateAll modified unexpected number of instances: %j', Model.modelName, count); return cb(new Error( - 'Bulk update failed, the connector has modified unexpected ' + - 'number of records: ' + JSON.stringify(count))); + g.f('Bulk update failed, the connector has modified unexpected ' + + 'number of records: %s', JSON.stringify(count)))); } }); } - function applyCreate(Model, id, current, data, change, conflicts, cb) { - Model.create(data, function(createErr) { + function applyCreate(Model, id, current, data, change, conflicts, options, cb) { + Model.create(data, options, function(createErr) { if (!createErr) return cb(); // We don't have a reliable way how to detect the situation @@ -1306,7 +1538,7 @@ module.exports = function(registry) { } } - function applyDelete(Model, id, current, change, conflicts, cb) { + function applyDelete(Model, id, current, change, conflicts, options, cb) { if (!current) { // The instance was either already deleted or not created at all, // we are done. @@ -1324,7 +1556,7 @@ module.exports = function(registry) { return Change.rectifyModelChanges(Model.modelName, [id], cb); } - Model.deleteAll(current.toObject(), function(err, result) { + Model.deleteAll(current.toObject(), options, function(err, result) { if (err) return cb(err); var count = result && result.count; @@ -1345,16 +1577,16 @@ module.exports = function(registry) { case undefined: case null: return cb(new Error( - 'Cannot apply bulk updates, ' + + g.f('Cannot apply bulk updates, ' + 'the connector does not correctly report ' + - 'the number of deleted records.')); + 'the number of deleted records.'))); default: debug('%s.deleteAll modified unexpected number of instances: %j', Model.modelName, count); return cb(new Error( - 'Bulk update failed, the connector has deleted unexpected ' + - 'number of records: ' + JSON.stringify(count))); + g.f('Bulk update failed, the connector has deleted unexpected ' + + 'number of records: %s', JSON.stringify(count)))); } }); } @@ -1413,14 +1645,16 @@ module.exports = function(registry) { var idDefn = idProp && idProp.defaultFn; if (idType !== String || !(idDefn === 'uuid' || idDefn === 'guid')) { deprecated('The model ' + this.modelName + ' is tracking changes, ' + - 'which requries a string id with GUID/UUID default value.'); + 'which requires a string id with GUID/UUID default value.'); } Model.observe('after save', rectifyOnSave); Model.observe('after delete', rectifyOnDelete); - if (runtime.isServer) { + // Only run if the run time is server + // Can switch off cleanup by setting the interval to -1 + if (runtime.isServer && cleanupInterval > 0) { // initial cleanup cleanup(); @@ -1449,7 +1683,7 @@ module.exports = function(registry) { ctx.instance, ctx.currentInstance, ctx.where, ctx.data); } - if (id) { + if (id != null) { ctx.Model.rectifyChange(id, reportErrorAndNext); } else { ctx.Model.rectifyAllChanges(reportErrorAndNext); @@ -1473,7 +1707,7 @@ module.exports = function(registry) { debug('context instance:%j where:%j', ctx.instance, ctx.where); } - if (id) { + if (id != null) { ctx.Model.rectifyChange(id, reportErrorAndNext); } else { ctx.Model.rectifyAllChanges(reportErrorAndNext); @@ -1569,8 +1803,8 @@ module.exports = function(registry) { this.findLastChange(id, function(err, inst) { if (err) return cb(err); if (!inst) { - err = new Error('No change record found for ' + - self.modelName + ' with id ' + id); + err = new Error(g.f('No change record found for %s with id %s', + self.modelName, id)); err.statusCode = 404; return cb(err); } diff --git a/lib/registry.js b/lib/registry.js index ade0e2e88..f114e37c0 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -1,3 +1,9 @@ +// Copyright IBM Corp. 2014,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 g = require('strong-globalize')(); var assert = require('assert'); var extend = require('util')._extend; var juggler = require('loopback-datasource-juggler'); @@ -108,11 +114,11 @@ Registry.prototype.createModel = function(name, properties, options) { if (BaseModel === undefined) { if (baseName === 'DataModel') { - console.warn('Model `%s` is extending deprecated `DataModel. ' + + g.warn('Model `%s` is extending deprecated `DataModel. ' + 'Use `PersistedModel` instead.', name); BaseModel = this.getModel('PersistedModel'); } else { - console.warn('Model `%s` is extending an unknown model `%s`. ' + + g.warn('Model `%s` is extending an unknown model `%s`. ' + 'Using `PersistedModel` as the base.', name, baseName); } } @@ -192,7 +198,7 @@ Registry.prototype.configureModel = function(ModelCtor, config) { relations[key] = extend(relations[key] || {}, config.relations[key]); }); } else if (config.relations != null) { - console.warn('The relations property of `%s` configuration ' + + g.warn('The relations property of `%s` configuration ' + 'must be an object', modelName); } @@ -203,7 +209,7 @@ Registry.prototype.configureModel = function(ModelCtor, config) { addACL(acls, acl); }); } else if (config.acls != null) { - console.warn('The acls property of `%s` configuration ' + + g.warn('The acls property of `%s` configuration ' + 'must be an array of objects', modelName); } @@ -220,12 +226,12 @@ Registry.prototype.configureModel = function(ModelCtor, config) { if (!(p in excludedProperties)) { settings[p] = config.options[p]; } else { - console.warn('Property `%s` cannot be reconfigured for `%s`', + g.warn('Property `%s` cannot be reconfigured for `%s`', p, modelName); } } } else if (config.options != null) { - console.warn('The options property of `%s` configuration ' + + g.warn('The options property of `%s` configuration ' + 'must be an object', modelName); } @@ -244,8 +250,8 @@ Registry.prototype.configureModel = function(ModelCtor, config) { } else { debug('Model `%s` is not attached to any DataSource, possibly by a mistake.', modelName); - console.warn( - 'The configuration of `%s` is missing `dataSource` property.\n' + + g.warn( + 'The configuration of `%s` is missing {{`dataSource`}} property.\n' + 'Use `null` or `false` to mark models not attached to any data source.', modelName); } @@ -257,7 +263,7 @@ Registry.prototype.configureModel = function(ModelCtor, config) { Registry.prototype._defineRemoteMethods = function(ModelCtor, methods) { if (!methods) return; if (typeof methods !== 'object') { - console.warn('Ignoring non-object "methods" setting of "%s".', + g.warn('Ignoring non-object "methods" setting of "%s".', ModelCtor.modelName); return; } @@ -265,11 +271,11 @@ Registry.prototype._defineRemoteMethods = function(ModelCtor, methods) { Object.keys(methods).forEach(function(key) { var meta = methods[key]; if (typeof meta.isStatic !== 'boolean') { - console.warn('Remoting metadata for "%s.%s" is missing "isStatic" ' + + g.warn('Remoting metadata for "%s.%s" is missing "isStatic" ' + 'flag, the method is registered as an instance method.', ModelCtor.modelName, key); - console.warn('This behaviour may change in the next major version.'); + g.warn('This behaviour may change in the next major version.'); } ModelCtor.remoteMethod(key, meta); }); @@ -301,7 +307,7 @@ Registry.prototype.getModel = function(modelName) { var model = this.findModel(modelName); if (model) return model; - throw new Error('Model not found: ' + modelName); + throw new Error(g.f('Model not found: %s', modelName)); }; /** diff --git a/lib/runtime.js b/lib/runtime.js index 7e791f5b1..8799447bb 100644 --- a/lib/runtime.js +++ b/lib/runtime.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + /* * This is an internal file that should not be used outside of loopback. * All exported entities can be accessed via the `loopback` object. diff --git a/lib/server-app.js b/lib/server-app.js index 237a62540..f6b7b1257 100644 --- a/lib/server-app.js +++ b/lib/server-app.js @@ -1,3 +1,10 @@ +// Copyright IBM Corp. 2014,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 g = require('strong-globalize')(); + var assert = require('assert'); var express = require('express'); var merge = require('util')._extend; @@ -183,7 +190,7 @@ proto.middleware = function(name, paths, handler) { } if (this._requestHandlingPhases.indexOf(name) === -1) - throw new Error('Unknown middleware phase ' + name); + throw new Error(g.f('Unknown {{middleware}} phase %s', name)); debug('use %s %s %s', fullPhaseName, paths, handlerName); diff --git a/lib/utils.js b/lib/utils.js index 306a1764a..555a18616 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2015,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + exports.createPromiseCallback = createPromiseCallback; function createPromiseCallback() { diff --git a/package.json b/package.json index bccf39157..941c37598 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "loopback", - "version": "2.26.2", + "version": "2.42.0", + "publishConfig": { + "tag": "lts" + }, "description": "LoopBack: Open Source Framework for Node.js", "homepage": "http://loopback.io", "keywords": [ @@ -29,64 +32,78 @@ "mBaaS" ], "scripts": { - "test": "grunt mocha-and-karma" + "coverage": "nyc report --reporter=text-lcov | coveralls", + "test": "nyc grunt mocha-and-karma" + }, + "engines": { + "node": ">=4.0.0" }, "dependencies": { - "async": "^0.9.0", + "async": "^2.0.1", "bcryptjs": "^2.1.0", "body-parser": "^1.12.0", "canonical-json": "0.0.4", - "continuation-local-storage": "^3.1.3", "cookie-parser": "^1.3.4", "debug": "^2.1.2", "depd": "^1.0.0", "ejs": "^2.3.1", "errorhandler": "^1.3.4", - "express": "^4.12.2", + "express": "^4.16.2", "inflection": "^1.6.0", + "isemail": "^1.2.0", "loopback-connector-remote": "^1.0.3", + "loopback-context": "^1.0.0", "loopback-phase": "^1.2.0", - "nodemailer": "^1.3.1", - "nodemailer-stub-transport": "^0.1.5", + "nodemailer": "^2.5.0", + "nodemailer-stub-transport": "^1.0.0", "serve-favicon": "^2.2.0", "stable": "^0.1.5", + "strong-globalize": "^2.6.2", "strong-remoting": "^2.21.0", "uid2": "0.0.3", "underscore.string": "^3.0.3" }, "peerDependencies": { - "loopback-datasource-juggler": "^2.19.0" + "loopback-datasource-juggler": "^2.56.0" }, "devDependencies": { - "bluebird": "^2.9.9", - "browserify": "^10.0.0", - "chai": "^2.1.1", + "babel-preset-es2015": "^6.24.1", + "babelify": "^7.3.0", + "bluebird": "^3.4.1", + "browserify": "^13.1.0", + "chai": "^3.5.0", + "coveralls": "^2.11.15", "es5-shim": "^4.1.0", - "grunt": "^0.4.5", - "grunt-browserify": "^3.5.0", - "grunt-cli": "^0.1.13", - "grunt-contrib-jshint": "^0.11.0", - "grunt-contrib-uglify": "^0.9.1", - "grunt-contrib-watch": "^0.6.1", - "grunt-jscs": "^1.5.0", - "grunt-karma": "^0.10.1", + "express-session": "^1.14.0", + "grunt": "^1.0.1", + "grunt-browserify": "^5.0.0", + "grunt-cli": "^1.2.0", + "grunt-contrib-jshint": "^1.0.0", + "grunt-contrib-uglify": "^2.0.0", + "grunt-contrib-watch": "^1.0.0", + "grunt-jscs": "^3.0.1", + "grunt-karma": "^2.0.0", "grunt-mocha-test": "^0.12.7", - "karma": "^0.12.31", - "karma-browserify": "^4.0.0", - "karma-chrome-launcher": "^0.1.7", - "karma-firefox-launcher": "^0.1.4", - "karma-html2js-preprocessor": "^0.1.0", - "karma-junit-reporter": "^0.2.2", - "karma-mocha": "^0.1.10", - "karma-phantomjs-launcher": "^0.1.4", - "karma-script-launcher": "^0.1.0", + "karma": "^1.1.2", + "karma-browserify": "^5.0.5", + "karma-chrome-launcher": "^1.0.1", + "karma-firefox-launcher": "^1.0.0", + "karma-html2js-preprocessor": "^1.0.0", + "karma-junit-reporter": "^1.0.0", + "karma-mocha": "^1.1.1", + "karma-phantomjs-launcher": "^1.0.0", + "karma-script-launcher": "^1.0.0", "loopback-boot": "^2.7.0", - "loopback-datasource-juggler": "^2.19.1", - "loopback-testing": "~1.1.0", - "mocha": "^2.1.0", + "loopback-datasource-juggler": "^2.56.0", + "loopback-testing": "^1.4.0", + "mocha": "^3.0.0", + "nyc": "^10.1.2", + "phantomjs-prebuilt": "^2.1.7", "sinon": "^1.13.0", + "sinon-chai": "^2.8.0", "strong-task-emitter": "^0.0.6", - "supertest": "^0.15.0" + "supertest": "^2.0.0", + "supertest-as-promised": "^4.0.2" }, "repository": { "type": "git", @@ -95,15 +112,22 @@ "browser": { "express": "./lib/browser-express.js", "./lib/server-app.js": "./lib/browser-express.js", - "./server/current-context.js": "./browser/current-context.js", "connect": false, "nodemailer": false, "supertest": false, "depd": "loopback-datasource-juggler/lib/browser.depd.js", "bcrypt": false }, - "license": "MIT", - "optionalDependencies": { - "sl-blip": "http://blip.strongloop.com/loopback@2.26.2" - } + "config": { + "ci": { + "debug": "*,-mocha:*,-eslint:*" + } + }, + "ci": { + "downstreamIgnoreList": [ + "dashboard-controller", + "gateway-director-management-interface" + ] + }, + "license": "MIT" } diff --git a/server/current-context.js b/server/current-context.js deleted file mode 100644 index 6b8304e22..000000000 --- a/server/current-context.js +++ /dev/null @@ -1,138 +0,0 @@ -var juggler = require('loopback-datasource-juggler'); -var remoting = require('strong-remoting'); -var cls = require('continuation-local-storage'); -var domain = require('domain'); - -module.exports = function(loopback) { - - /** - * Get the current context object. The context is preserved - * across async calls, it behaves like a thread-local storage. - * - * @returns {ChainedContext} The context object or null. - */ - loopback.getCurrentContext = function() { - // A placeholder method, see loopback.createContext() for the real version - return null; - }; - - /** - * Run the given function in such way that - * `loopback.getCurrentContext` returns the - * provided context object. - * - * **NOTE** - * - * The method is supported on the server only, it does not work - * in the browser at the moment. - * - * @param {Function} fn The function to run, it will receive arguments - * (currentContext, currentDomain). - * @param {ChainedContext} context An optional context object. - * When no value is provided, then the default global context is used. - */ - loopback.runInContext = function(fn, context) { - var currentDomain = domain.create(); - currentDomain.oldBind = currentDomain.bind; - currentDomain.bind = function(callback, context) { - return currentDomain.oldBind(ns.bind(callback, context), context); - }; - - var ns = context || loopback.createContext('loopback'); - - currentDomain.run(function() { - ns.run(function executeInContext(context) { - fn(ns, currentDomain); - }); - }); - }; - - /** - * Create a new LoopBackContext instance that can be used - * for `loopback.runInContext`. - * - * **NOTES** - * - * At the moment, `loopback.getCurrentContext` supports - * a single global context instance only. If you call `createContext()` - * multiple times, `getCurrentContext` will return the last context - * created. - * - * The method is supported on the server only, it does not work - * in the browser at the moment. - * - * @param {String} scopeName An optional scope name. - * @return {ChainedContext} The new context object. - */ - loopback.createContext = function(scopeName) { - // Make the namespace globally visible via the process.context property - process.context = process.context || {}; - var ns = process.context[scopeName]; - if (!ns) { - ns = cls.createNamespace(scopeName); - process.context[scopeName] = ns; - // Set up loopback.getCurrentContext() - loopback.getCurrentContext = function() { - return ns && ns.active ? ns : null; - }; - - chain(juggler); - chain(remoting); - } - return ns; - }; - - /** - * Create a chained context - * @param {Object} child The child context - * @param {Object} parent The parent context - * @private - * @constructor - */ - function ChainedContext(child, parent) { - this.child = child; - this.parent = parent; - } - - /** - * Get the value by name from the context. If it doesn't exist in the child - * context, try the parent one - * @param {String} name Name of the context property - * @returns {*} Value of the context property - * @private - */ - ChainedContext.prototype.get = function(name) { - var val = this.child && this.child.get(name); - if (val === undefined) { - return this.parent && this.parent.get(name); - } - }; - - ChainedContext.prototype.set = function(name, val) { - if (this.child) { - return this.child.set(name, val); - } else { - return this.parent && this.parent.set(name, val); - } - }; - - ChainedContext.prototype.reset = function(name, val) { - if (this.child) { - return this.child.reset(name, val); - } else { - return this.parent && this.parent.reset(name, val); - } - }; - - function chain(child) { - if (typeof child.getCurrentContext === 'function') { - var childContext = new ChainedContext(child.getCurrentContext(), - loopback.getCurrentContext()); - child.getCurrentContext = function() { - return childContext; - }; - } else { - child.getCurrentContext = loopback.getCurrentContext; - } - } -}; diff --git a/server/middleware/context.js b/server/middleware/context.js index 95352018f..4c5fe7b0b 100644 --- a/server/middleware/context.js +++ b/server/middleware/context.js @@ -1,52 +1,15 @@ -var loopback = require('../../lib/loopback'); - -module.exports = context; - -var name = 'loopback'; - -/** - * Context middleware. - * ```js - * var app = loopback(); - * app.use(loopback.context(options); - * app.use(loopback.rest()); - * app.listen(); - * ``` - * @options {Object} [options] Options for context - * @property {String} name Context scope name. - * @property {Boolean} enableHttpContext Whether HTTP context is enabled. Default is false. - * @header loopback.context([options]) - */ - -function context(options) { - options = options || {}; - var scope = options.name || name; - var enableHttpContext = options.enableHttpContext || false; - var ns = loopback.createContext(scope); - - // Return the middleware - return function contextHandler(req, res, next) { - if (req.loopbackContext) { - return next(); - } - - loopback.runInContext(function processRequestInContext(ns, domain) { - req.loopbackContext = ns; - - // Bind req/res event emitters to the given namespace - ns.bindEmitter(req); - ns.bindEmitter(res); - - // Add req/res event emitters to the current domain - domain.add(req); - domain.add(res); - - // Run the code in the context of the namespace - if (enableHttpContext) { - // Set up the transport context - ns.set('http', {req: req, res: res}); - } - next(); - }); - }; -} +// Copyright IBM Corp. 2014,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 deprecated = require('depd')('loopback'); +var g = require('strong-globalize')(); +var perRequestContext = require('loopback-context').perRequest; + +module.exports = function() { + deprecated(g.f('%s middleware is deprecated. See %s for more details.', + 'loopback#context', + 'https://docs.strongloop.com/display/APIC/Using%20current%20context')); + return perRequestContext.apply(this, arguments); +}; diff --git a/server/middleware/error-handler.js b/server/middleware/error-handler.js index c549944bf..1d30ae289 100644 --- a/server/middleware/error-handler.js +++ b/server/middleware/error-handler.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2015,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 expressErrorHandler = require('errorhandler'); expressErrorHandler.title = 'Loopback'; diff --git a/server/middleware/favicon.js b/server/middleware/favicon.js index d2e1fa40d..48ffac4ff 100644 --- a/server/middleware/favicon.js +++ b/server/middleware/favicon.js @@ -1,5 +1,16 @@ +// Copyright IBM Corp. 2014,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 favicon = require('serve-favicon'); +var path = require('path'); + /** * Serve the LoopBack favicon. * @header loopback.favicon() */ -module.exports = require('../../lib/express-middleware').favicon; +module.exports = function(icon, options) { + icon = icon || path.join(__dirname, '../../favicon.ico'); + return favicon(icon, options); +}; diff --git a/server/middleware/rest.js b/server/middleware/rest.js index 9c7e23a28..f1eb3ecec 100644 --- a/server/middleware/rest.js +++ b/server/middleware/rest.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 dependencies. */ @@ -68,8 +73,21 @@ function rest() { if (handlers.length === 1) { return handlers[0](req, res, next); } - async.eachSeries(handlers, function(handler, done) { - handler(req, res, done); - }, next); + + executeHandlers(handlers, req, res, next); }; } + +// A trimmed-down version of async.series that preserves current CLS context +function executeHandlers(handlers, req, res, cb) { + var ix = -1; + next(); + + function next(err) { + if (err || ++ix >= handlers.length) { + cb(err); + } else { + handlers[ix](req, res, next); + } + } +} diff --git a/server/middleware/static.js b/server/middleware/static.js index c01a538df..6f253dfa5 100644 --- a/server/middleware/static.js +++ b/server/middleware/static.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + /** * Serve static assets of a LoopBack application. * diff --git a/server/middleware/status.js b/server/middleware/status.js index 3e9308115..f064a9d83 100644 --- a/server/middleware/status.js +++ b/server/middleware/status.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + /*! * Export the middleware. */ diff --git a/server/middleware/token.js b/server/middleware/token.js index e80eb560b..58ca056fc 100644 --- a/server/middleware/token.js +++ b/server/middleware/token.js @@ -1,7 +1,14 @@ +// Copyright IBM Corp. 2014,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 dependencies. */ +'use strict'; +var g = require('strong-globalize')(); var loopback = require('../../lib/loopback'); var assert = require('assert'); var debug = require('debug')('loopback:middleware:token'); @@ -15,18 +22,33 @@ module.exports = token; /* * Rewrite the url to replace current user literal with the logged in user id */ -function rewriteUserLiteral(req, currentUserLiteral) { - if (req.accessToken && req.accessToken.userId && currentUserLiteral) { +function rewriteUserLiteral(req, currentUserLiteral, next) { + if (!currentUserLiteral) return next(); + var literalRegExp = new RegExp('/' + currentUserLiteral + '(/|$|\\?)', 'g'); + + if (req.accessToken && req.accessToken.userId) { // Replace /me/ with /current-user-id/ var urlBeforeRewrite = req.url; - req.url = req.url.replace( - new RegExp('/' + currentUserLiteral + '(/|$|\\?)', 'g'), + req.url = req.url.replace(literalRegExp, '/' + req.accessToken.userId + '$1'); + if (req.url !== urlBeforeRewrite) { debug('req.url has been rewritten from %s to %s', urlBeforeRewrite, req.url); } + } else if (!req.accessToken && literalRegExp.test(req.url)) { + debug( + 'URL %s matches current-user literal %s,' + + ' but no (valid) access token was provided.', + req.url, currentUserLiteral); + + var e = new Error(g.f('Authorization Required')); + e.status = e.statusCode = 401; + e.code = 'AUTHORIZATION_REQUIRED'; + return next(e); } + + next(); } function escapeRegExp(str) { @@ -62,6 +84,8 @@ function escapeRegExp(str) { * @property {Array} [headers] Array of header names. * @property {Array} [params] Array of param names. * @property {Boolean} [searchDefaultTokenKeys] Use the default search locations for Token in request + * @property {Boolean} [enableDoublecheck] Execute middleware although an instance mounted earlier in the chain didn't find a token + * @property {Boolean} [overwriteExistingToken] only has effect in combination with `enableDoublecheck`. If truthy, will allow to overwrite an existing accessToken. * @property {Function|String} [model] AccessToken model name or class to use. * @property {String} [currentUserLiteral] String literal for the current user. * @header loopback.token([options]) @@ -80,6 +104,9 @@ function token(options) { currentUserLiteral = escapeRegExp(currentUserLiteral); } + var enableDoublecheck = !!options.enableDoublecheck; + var overwriteExistingToken = !!options.overwriteExistingToken; + return function(req, res, next) { var app = req.app; var registry = app.registry; @@ -97,15 +124,27 @@ function token(options) { 'loopback.token() middleware requires a AccessToken model'); if (req.accessToken !== undefined) { - rewriteUserLiteral(req, currentUserLiteral); - return next(); + if (!enableDoublecheck) { + // req.accessToken is defined already (might also be "null" or "false") and enableDoublecheck + // has not been set --> skip searching for credentials + return rewriteUserLiteral(req, currentUserLiteral, next); + } + if (req.accessToken && req.accessToken.id && !overwriteExistingToken) { + // req.accessToken.id is defined, which means that some other middleware has identified a valid user. + // when overwriteExistingToken is not set to a truthy value, skip searching for credentials. + return rewriteUserLiteral(req, currentUserLiteral, next); + } + // continue normal operation (as if req.accessToken was undefined) } + TokenModel.findForRequest(req, options, function(err, token) { req.accessToken = token || null; - rewriteUserLiteral(req, currentUserLiteral); - var ctx = loopback.getCurrentContext(); - if (ctx) ctx.set('accessToken', token); - next(err); + + var ctx = req.loopbackContext; + if (ctx && ctx.active) ctx.set('accessToken', token); + + if (err) return next(err); + rewriteUserLiteral(req, currentUserLiteral, next); }); }; } diff --git a/server/middleware/url-not-found.js b/server/middleware/url-not-found.js index dd696d79f..d204f1575 100644 --- a/server/middleware/url-not-found.js +++ b/server/middleware/url-not-found.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + /*! * Export the middleware. * See discussion in Connect pull request #954 for more details diff --git a/test/access-control.integration.js b/test/access-control.integration.js index 3125a6535..23bcbf5c3 100644 --- a/test/access-control.integration.js +++ b/test/access-control.integration.js @@ -1,3 +1,8 @@ +// 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 + /*jshint -W030 */ var loopback = require('../'); @@ -11,8 +16,15 @@ var CURRENT_USER = {email: 'current@test.test', password: 'test'}; var debug = require('debug')('loopback:test:access-control.integration'); describe('access control - integration', function() { + before(function(done) { + if (app.booting) { + return app.once('booted', done); + } + done(); + }); lt.beforeEach.withApp(app); + lt.beforeEach.withUserModel('user'); /* describe('accessToken', function() { @@ -94,7 +106,7 @@ describe('access control - integration', function() { lt.describe.whenLoggedInAsUser(CURRENT_USER, function() { beforeEach(function() { - this.url = '/api/users/' + this.user.id + '?ok'; + this.url = '/api/users/' + this.loggedInAccessToken.userId + '?ok'; }); lt.describe.whenCalledRemotely('DELETE', '/api/users/:id', function() { lt.it.shouldBeAllowed(); @@ -110,11 +122,21 @@ describe('access control - integration', function() { assert.equal(user.password, undefined); }); }); + + // user has replaceOnPUT = false; so then both PUT and PATCH should be allowed for update lt.describe.whenCalledRemotely('PUT', '/api/users/:id', function() { lt.it.shouldBeAllowed(); }); + + lt.describe.whenCalledRemotely('PATCH', '/api/users/:id', function() { + lt.it.shouldBeAllowed(); + }); }); + lt.it.shouldBeDeniedWhenCalledAnonymously('POST', '/api/users/upsertWithWhere'); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', '/api/users/upsertWithWhere'); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', '/api/users/upsertWithWhere'); + lt.it.shouldBeDeniedWhenCalledAnonymously('DELETE', urlForUser); lt.it.shouldBeDeniedWhenCalledUnauthenticated('DELETE', urlForUser); lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'DELETE', urlForUser); @@ -126,6 +148,7 @@ describe('access control - integration', function() { var userCounter; function newUserData() { userCounter = userCounter ? ++userCounter : 1; + return { email: 'new-' + userCounter + '@test.test', password: 'test' @@ -134,6 +157,34 @@ describe('access control - integration', function() { }); describe('/banks', function() { + var SPECIAL_USER = { email: 'special@test.test', password: 'test' }; + + // define dynamic role that would only grant access when the authenticated user's email is equal to + // SPECIAL_USER's email + + before(function() { + var roleModel = app.registry.getModel('Role'); + var userModel = app.registry.getModel('user'); + + roleModel.registerResolver('$dynamic-role', function(role, context, callback) { + if (!(context && context.accessToken && context.accessToken.userId)) { + return process.nextTick(function() { + callback && callback(null, false); + }); + } + var accessToken = context.accessToken; + userModel.findById(accessToken.userId, function(err, user) { + if (err) { + return callback(err, false); + } + if (user && user.email === SPECIAL_USER.email) { + return callback(null, true); + } + return callback(null, false); + }); + }); + }); + lt.beforeEach.givenModel('bank'); lt.it.shouldBeAllowedWhenCalledAnonymously('GET', '/api/banks'); @@ -155,13 +206,18 @@ describe('access control - integration', function() { lt.it.shouldBeDeniedWhenCalledAnonymously('DELETE', urlForBank); lt.it.shouldBeDeniedWhenCalledUnauthenticated('DELETE', urlForBank); lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'DELETE', urlForBank); + lt.it.shouldBeAllowedWhenCalledByUser(SPECIAL_USER, 'DELETE', urlForBank); + + lt.it.shouldBeDeniedWhenCalledAnonymously('POST', '/api/banks/upsertWithWhere'); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', '/api/banks/upsertWithWhere'); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', '/api/banks/upsertWithWhere'); function urlForBank() { return '/api/banks/' + this.bank.id; } }); - describe('/accounts', function() { + describe('/accounts with replaceOnPUT true', function() { var count = 0; before(function() { var roleModel = loopback.getModelByType(loopback.Role); @@ -175,47 +231,135 @@ describe('access control - integration', function() { }); }); - lt.beforeEach.givenModel('account'); + lt.beforeEach.givenModel('accountWithReplaceOnPUTtrue'); - lt.it.shouldBeDeniedWhenCalledAnonymously('GET', '/api/accounts'); - lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', '/api/accounts'); - lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', '/api/accounts'); + lt.it.shouldBeDeniedWhenCalledAnonymously('GET', '/api/accounts-replacing'); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', '/api/accounts-replacing'); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', '/api/accounts-replacing'); lt.it.shouldBeDeniedWhenCalledAnonymously('GET', urlForAccount); lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', urlForAccount); lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', urlForAccount); - lt.it.shouldBeDeniedWhenCalledAnonymously('POST', '/api/accounts'); - lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', '/api/accounts'); - lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', '/api/accounts'); + lt.it.shouldBeDeniedWhenCalledAnonymously('POST', '/api/accounts-replacing'); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', '/api/accounts-replacing'); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', '/api/accounts-replacing'); + + lt.it.shouldBeDeniedWhenCalledAnonymously('POST', urlForReplaceAccountPOST); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', urlForReplaceAccountPOST); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', urlForReplaceAccountPOST); lt.it.shouldBeDeniedWhenCalledAnonymously('PUT', urlForAccount); lt.it.shouldBeDeniedWhenCalledUnauthenticated('PUT', urlForAccount); lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PUT', urlForAccount); + lt.it.shouldBeDeniedWhenCalledAnonymously('PATCH', urlForAccount); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('PATCH', urlForAccount); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PATCH', urlForAccount); + lt.describe.whenLoggedInAsUser(CURRENT_USER, function() { + var actId; beforeEach(function(done) { var self = this; - // Create an account under the given user - app.models.account.create({ - userId: self.user.id, + app.models.accountWithReplaceOnPUTtrue.create({ + userId: self.loggedInAccessToken.userId, balance: 100 }, function(err, act) { - self.url = '/api/accounts/' + act.id; + actId = act.id; + self.url = '/api/accounts-replacing/' + actId; + done(); + }); + }); + + lt.describe.whenCalledRemotely('PATCH', '/api/accounts-replacing/:id', function() { + lt.it.shouldBeAllowed(); + }); + lt.describe.whenCalledRemotely('PUT', '/api/accounts-replacing/:id', function() { + lt.it.shouldBeAllowed(); + }); + lt.describe.whenCalledRemotely('GET', '/api/accounts-replacing/:id', function() { + lt.it.shouldBeAllowed(); + }); + lt.describe.whenCalledRemotely('DELETE', '/api/accounts-replacing/:id', function() { + lt.it.shouldBeDenied(); + }); + describe('replace on POST verb', function() { + beforeEach(function(done) { + this.url = '/api/accounts-replacing/' + actId + '/replace'; + done(); + }); + lt.describe.whenCalledRemotely('POST', '/api/accounts-replacing/:id/replace', function() { + lt.it.shouldBeAllowed(); + }); + }); + }); + + lt.it.shouldBeDeniedWhenCalledAnonymously('DELETE', urlForAccount); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('DELETE', urlForAccount); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'DELETE', urlForAccount); + + function urlForAccount() { + return '/api/accounts-replacing/' + this.accountWithReplaceOnPUTtrue.id; + } + function urlForReplaceAccountPOST() { + return '/api/accounts-replacing/' + this.accountWithReplaceOnPUTtrue.id + '/replace'; + } + }); + + describe('/accounts with replaceOnPUT false', function() { + lt.beforeEach.givenModel('accountWithReplaceOnPUTfalse'); + lt.it.shouldBeDeniedWhenCalledAnonymously('POST', urlForReplaceAccountPOST); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', urlForReplaceAccountPOST); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', urlForReplaceAccountPOST); + + lt.it.shouldBeDeniedWhenCalledAnonymously('PUT', urlForAccount); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('PUT', urlForAccount); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PUT', urlForAccount); + + lt.it.shouldBeDeniedWhenCalledAnonymously('PATCH', urlForAccount); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('PATCH', urlForAccount); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PATCH', urlForAccount); + + lt.describe.whenLoggedInAsUser(CURRENT_USER, function() { + var actId; + beforeEach(function(done) { + var self = this; + // Create an account under the given user + app.models.accountWithReplaceOnPUTfalse.create({ + userId: self.loggedInAccessToken.userId, + balance: 100, + }, function(err, act) { + actId = act.id; + self.url = '/api/accounts-updating/' + actId; done(); }); + }); + lt.describe.whenCalledRemotely('PATCH', '/api/accounts-updating/:id', function() { + lt.it.shouldBeAllowed(); }); - lt.describe.whenCalledRemotely('PUT', '/api/accounts/:id', function() { + + lt.describe.whenCalledRemotely('PUT', '/api/accounts-updating/:id', function() { + lt.it.shouldBeAllowed(); }); - lt.describe.whenCalledRemotely('GET', '/api/accounts/:id', function() { + lt.describe.whenCalledRemotely('GET', '/api/accounts-updating/:id', function() { lt.it.shouldBeAllowed(); }); - lt.describe.whenCalledRemotely('DELETE', '/api/accounts/:id', function() { + lt.describe.whenCalledRemotely('DELETE', '/api/accounts-updating/:id', function() { lt.it.shouldBeDenied(); }); + + describe('replace on POST verb', function() { + beforeEach(function(done) { + this.url = '/api/accounts-updating/' + actId + '/replace'; + done(); + }); + lt.describe.whenCalledRemotely('POST', '/api/accounts-updating/:id/replace', function() { + lt.it.shouldBeAllowed(); + }); + }); }); lt.it.shouldBeDeniedWhenCalledAnonymously('DELETE', urlForAccount); @@ -223,7 +367,10 @@ describe('access control - integration', function() { lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'DELETE', urlForAccount); function urlForAccount() { - return '/api/accounts/' + this.account.id; + return '/api/accounts-updating/' + this.accountWithReplaceOnPUTfalse.id; + } + function urlForReplaceAccountPOST() { + return '/api/accounts-updating/' + this.accountWithReplaceOnPUTfalse.id + '/replace'; } }); diff --git a/test/access-token.test.js b/test/access-token.test.js index 9e57cba2a..d12f17abe 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -1,5 +1,13 @@ +// 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 cookieParser = require('cookie-parser'); var loopback = require('../'); var extend = require('util')._extend; +var session = require('express-session'); + var Token = loopback.AccessToken.extend('MyToken'); var ds = loopback.createDataSource({connector: loopback.Memory}); Token.attachTo(ds); @@ -65,7 +73,7 @@ describe('loopback.token(options)', function() { .end(done); }); - describe('populating req.toen from HTTP Basic Auth formatted authorization header', function() { + describe('populating req.token from HTTP Basic Auth formatted authorization header', function() { it('parses "standalone-token"', function(done) { var token = this.token.id; token = 'Basic ' + new Buffer(token).toString('base64'); @@ -144,7 +152,8 @@ describe('loopback.token(options)', function() { .set('authorization', id) .end(function(err, res) { assert(!err); - assert.deepEqual(res.body, {userId: userId}); + assert.deepEqual(res.body, { userId: userId }); + done(); }); }); @@ -159,7 +168,8 @@ describe('loopback.token(options)', function() { .set('authorization', id) .end(function(err, res) { assert(!err); - assert.deepEqual(res.body, {userId: userId, state: 1}); + assert.deepEqual(res.body, { userId: userId, state: 1 }); + done(); }); }); @@ -174,15 +184,47 @@ describe('loopback.token(options)', function() { .set('authorization', id) .end(function(err, res) { assert(!err); - assert.deepEqual(res.body, {userId: userId, state: 1}); + assert.deepEqual(res.body, { userId: userId, state: 1 }); + done(); }); }); + it('should generate a 401 on a current user literal route without an authToken', + function(done) { + var app = createTestApp(null, done); + request(app) + .get('/users/me') + .set('authorization', null) + .expect(401) + .end(done); + }); + + it('should generate a 401 on a current user literal route with empty authToken', + function(done) { + var app = createTestApp(null, done); + request(app) + .get('/users/me') + .set('authorization', '') + .expect(401) + .end(done); + }); + + it('should generate a 401 on a current user literal route with invalid authToken', + function(done) { + var app = createTestApp(this.token, done); + request(app) + .get('/users/me') + .set('Authorization', 'invald-token-id') + .expect(401) + .end(done); + }); + it('should skip when req.token is already present', function(done) { var tokenStub = { id: 'stub id' }; app.use(function(req, res, next) { req.accessToken = tokenStub; + next(); }); app.use(loopback.token({ model: Token })); @@ -195,10 +237,139 @@ describe('loopback.token(options)', function() { .expect(200) .end(function(err, res) { if (err) return done(err); + expect(res.body).to.eql(tokenStub); + done(); }); }); + + describe('loading multiple instances of token middleware', function() { + it('should skip when req.token is already present and no further options are set', + function(done) { + var tokenStub = { id: 'stub id' }; + app.use(function(req, res, next) { + req.accessToken = tokenStub; + + next(); + }); + app.use(loopback.token({ model: Token })); + app.get('/', function(req, res, next) { + res.send(req.accessToken); + }); + + request(app).get('/') + .set('Authorization', this.token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + + expect(res.body).to.eql(tokenStub); + + done(); + }); + }); + + it('should not overwrite valid existing token (has "id" property) ' + + ' when overwriteExistingToken is falsy', + function(done) { + var tokenStub = { id: 'stub id' }; + app.use(function(req, res, next) { + req.accessToken = tokenStub; + + next(); + }); + app.use(loopback.token({ + model: Token, + enableDoublecheck: true, + })); + app.get('/', function(req, res, next) { + res.send(req.accessToken); + }); + + request(app).get('/') + .set('Authorization', this.token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + + expect(res.body).to.eql(tokenStub); + + done(); + }); + }); + + it('should overwrite invalid existing token (is !== undefined and has no "id" property) ' + + ' when enableDoubkecheck is true', + function(done) { + var token = this.token; + + app.use(function(req, res, next) { + req.accessToken = null; + next(); + }); + + app.use(loopback.token({ + model: Token, + enableDoublecheck: true, + })); + + app.get('/', function(req, res, next) { + res.send(req.accessToken); + }); + + request(app).get('/') + .set('Authorization', token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.body).to.eql({ + id: token.id, + ttl: token.ttl, + userId: token.userId, + created: token.created.toJSON(), + }); + done(); + }); + }); + + it('should overwrite existing token when enableDoublecheck ' + + 'and overwriteExistingToken options are truthy', + function(done) { + var token = this.token; + var tokenStub = { id: 'stub id' }; + + app.use(function(req, res, next) { + req.accessToken = tokenStub; + + next(); + }); + app.use(loopback.token({ + model: Token, + enableDoublecheck: true, + overwriteExistingToken: true, + })); + app.get('/', function(req, res, next) { + res.send(req.accessToken); + }); + + request(app).get('/') + .set('Authorization', token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + + expect(res.body).to.eql({ + id: token.id, + ttl: token.ttl, + userId: token.userId, + created: token.created.toJSON(), + }); + + done(); + }); + }); + }); }); describe('AccessToken', function() { @@ -214,10 +385,38 @@ describe('AccessToken', function() { assert(Object.prototype.toString.call(this.token.created), '[object Date]'); }); - it('should be validateable', function(done) { - this.token.validate(function(err, isValid) { - assert(isValid); - done(); + describe('.validate()', function() { + it('accepts valid tokens', function(done) { + this.token.validate(function(err, isValid) { + assert(isValid); + done(); + }); + }); + + it('rejects eternal TTL by default', function(done) { + this.token.ttl = -1; + this.token.validate(function(err, isValid) { + if (err) return done(err); + expect(isValid, 'isValid').to.equal(false); + done(); + }); + }); + + it('allows eternal tokens when enabled by User.allowEternalTokens', + function(done) { + var Token = givenLocalTokenModel(); + + // Overwrite User settings - enable eternal tokens + Token.app.models.User.settings.allowEternalTokens = true; + + Token.create({ userId: '123', ttl: -1 }, function(err, token) { + if (err) return done(err); + token.validate(function(err, isValid) { + if (err) return done(err); + expect(isValid, 'isValid').to.equal(true); + done(); + }); + }); }); }); @@ -232,7 +431,9 @@ describe('AccessToken', function() { Token.findForRequest(req, function(err, token) { if (err) return done(err); + expect(token.id).to.eql(expectedTokenId); + done(); }); }); @@ -255,6 +456,9 @@ describe('AccessToken', function() { }); describe('app.enableAuth()', function() { + beforeEach(function setupAuthWithModels() { + app.enableAuth({ dataSource: ds }); + }); beforeEach(createTestingToken); it('prevents remote call with 401 status on denied ACL', function(done) { @@ -266,15 +470,17 @@ describe('app.enableAuth()', function() { if (err) { return done(err); } + var errorResponse = res.body.error; assert(errorResponse); assert.equal(errorResponse.code, 'AUTHORIZATION_REQUIRED'); + done(); }); }); it('prevent remote call with app setting status on denied ACL', function(done) { - createTestAppAndRequest(this.token, {app:{aclErrorStatus:403}}, done) + createTestAppAndRequest(this.token, {app: {aclErrorStatus: 403}}, done) .del('/tests/123') .expect(403) .set('authorization', this.token.id) @@ -282,15 +488,17 @@ describe('app.enableAuth()', function() { if (err) { return done(err); } + var errorResponse = res.body.error; assert(errorResponse); assert.equal(errorResponse.code, 'ACCESS_DENIED'); + done(); }); }); it('prevent remote call with app setting status on denied ACL', function(done) { - createTestAppAndRequest(this.token, {model:{aclErrorStatus:404}}, done) + createTestAppAndRequest(this.token, {model: {aclErrorStatus: 404}}, done) .del('/tests/123') .expect(404) .set('authorization', this.token.id) @@ -298,9 +506,11 @@ describe('app.enableAuth()', function() { if (err) { return done(err); } + var errorResponse = res.body.error; assert(errorResponse); assert.equal(errorResponse.code, 'MODEL_NOT_FOUND'); + done(); }); }); @@ -314,9 +524,11 @@ describe('app.enableAuth()', function() { if (err) { return done(err); } + var errorResponse = res.body.error; assert(errorResponse); assert.equal(errorResponse.code, 'AUTHORIZATION_REQUIRED'); + done(); }); }); @@ -324,7 +536,8 @@ describe('app.enableAuth()', function() { it('stores token in the context', function(done) { var TestModel = loopback.createModel('TestModel', { base: 'Model' }); TestModel.getToken = function(cb) { - cb(null, loopback.getCurrentContext().get('accessToken') || null); + var ctx = loopback.getCurrentContext(); + cb(null, ctx && ctx.get('accessToken') || null); }; TestModel.remoteMethod('getToken', { returns: { arg: 'token', type: 'object' }, @@ -347,17 +560,44 @@ describe('app.enableAuth()', function() { .expect('Content-Type', /json/) .end(function(err, res) { if (err) return done(err); + expect(res.body.token.id).to.eql(token.id); + done(); }); }); + + // See https://github.com/strongloop/loopback-context/issues/6 + it('checks whether context is active', function(done) { + var app = loopback(); + + app.enableAuth(); + app.use(loopback.context()); + app.use(session({ + secret: 'kitty', + saveUninitialized: true, + resave: true + })); + app.use(loopback.token({ model: Token })); + app.get('/', function(req, res) { res.send('OK'); }); + app.use(loopback.rest()); + + request(app) + .get('/') + .set('authorization', this.token.id) + .set('cookie', 'connect.sid=s%3AFTyno9_MbGTJuOwdh9bxsYCVxlhlulTZ.PZvp85jzLXZBCBkhCsSfuUjhij%2Fb0B1K2RYZdxSQU0c') + .expect(200, 'OK') + .end(done); + }); }); function createTestingToken(done) { var test = this; Token.create({userId: '123'}, function(err, token) { if (err) return done(err); + test.token = token; + done(); }); } @@ -380,8 +620,9 @@ function createTestApp(testToken, settings, done) { }, settings.token); var app = loopback(); + app.set('logoutSessionsOnSensitiveChanges', true); - app.use(loopback.cookieParser('secret')); + app.use(cookieParser('secret')); app.use(loopback.token(tokenSettings)); app.get('/token', function(req, res) { res.cookie('authorization', testToken.id, {signed: true}); @@ -439,3 +680,17 @@ function createTestApp(testToken, settings, done) { return app; } + +function givenLocalTokenModel() { + var app = loopback({ localRegistry: true, loadBuiltinModels: true }); + app.set('logoutSessionsOnSensitiveChanges', true); + app.dataSource('db', { connector: 'memory' }); + + var User = app.registry.getModel('User'); + app.model(User, { dataSource: 'db' }); + + var Token = app.registry.getModel('AccessToken'); + app.model(Token, { dataSource: 'db' }); + + return Token; +} diff --git a/test/acl.test.js b/test/acl.test.js index d8706eec3..430d71fe2 100644 --- a/test/acl.test.js +++ b/test/acl.test.js @@ -1,3 +1,8 @@ +// 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 assert = require('assert'); var loopback = require('../index'); var Scope = loopback.Scope; @@ -358,19 +363,17 @@ describe('security ACLs', function() { }); describe('access check', function() { - var app; - before(function() { - app = loopback(); - app.use(loopback.rest()); - app.enableAuth(); - app.dataSource('test', {connector: 'memory'}); - }); - it('should occur before other remote hooks', function(done) { - var MyTestModel = app.model('MyTestModel', {base: 'PersistedModel', dataSource: 'test'}); + var app = loopback(); + var MyTestModel = app.registry.createModel('MyTestModel'); var checkAccessCalled = false; var beforeHookCalled = false; + app.use(loopback.rest()); + app.enableAuth(); + app.dataSource('test', { connector: 'memory' }); + app.model(MyTestModel, { dataSource: 'test' }); + // fake / spy on the checkAccess method MyTestModel.checkAccess = function() { var cb = arguments[arguments.length - 1]; @@ -382,7 +385,9 @@ describe('access check', function() { MyTestModel.beforeRemote('find', function(ctx, next) { // ensure this is called after checkAccess if (!checkAccessCalled) return done(new Error('incorrect order')); + beforeHookCalled = true; + next(); }); @@ -391,6 +396,7 @@ describe('access check', function() { .end(function(err, result) { assert(beforeHookCalled, 'the before hook should be called'); assert(checkAccessCalled, 'checkAccess should have been called'); + done(); }); }); diff --git a/test/app.test.js b/test/app.test.js index 2701ef9d2..7df1e7937 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -1,3 +1,8 @@ +// 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 + /*jshint -W030 */ var async = require('async'); @@ -9,6 +14,7 @@ var loopback = require('../'); var PersistedModel = loopback.PersistedModel; var describe = require('./util/describe'); +var expect = require('chai').expect; var it = require('./util/it'); describe('app', function() { @@ -34,10 +40,12 @@ describe('app', function() { executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(steps).to.eql([ 'initial', 'session', 'auth', 'parse', 'main', 'routes', 'files', 'final' ]); + done(); }); }); @@ -48,7 +56,9 @@ describe('app', function() { executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(steps).to.eql(['first', 'second']); + done(); }); }); @@ -60,7 +70,9 @@ describe('app', function() { executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(steps).to.eql(['routes:before', 'main', 'routes:after']); + done(); }); }); @@ -80,7 +92,9 @@ describe('app', function() { expect(found).have.property('phase', 'routes:before'); executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(steps).to.eql(['my-handler', 'extra-handler']); + done(); }); }); @@ -98,7 +112,9 @@ describe('app', function() { expect(found).have.property('phase', 'routes:before'); executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(steps).to.eql(['my-handler']); + done(); }); }); @@ -116,7 +132,9 @@ describe('app', function() { expect(found).have.property('phase', 'routes:before'); executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(steps).to.eql(['my-handler']); + done(); }); }); @@ -126,6 +144,7 @@ describe('app', function() { app.middleware('initial', function(req, res, next) { steps.push('initial'); + next(expectedError); }); @@ -133,12 +152,15 @@ describe('app', function() { app.use(function errorHandler(err, req, res, next) { expect(err).to.equal(expectedError); steps.push('error'); + next(); }); executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(steps).to.eql(['initial', 'error']); + done(); }); }); @@ -152,6 +174,7 @@ describe('app', function() { executeMiddlewareHandlers(app, function(err) { expect(err).to.equal(expectedError); + done(); }); }); @@ -170,12 +193,15 @@ describe('app', function() { app.middleware('initial', function(err, req, res, next) { handledError = err; + next(); }); executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(handledError).to.equal(expectedError); + done(); }); }); @@ -188,7 +214,9 @@ describe('app', function() { function(url, next) { executeMiddlewareHandlers(app, url, next); }, function(err) { if (err) return done(err); + expect(steps).to.eql(['/scope', '/scope/item']); + done(); }); }); @@ -201,7 +229,9 @@ describe('app', function() { function(url, next) { executeMiddlewareHandlers(app, url, next); }, function(err) { if (err) return done(err); + expect(steps).to.eql(['/a', '/b']); + done(); }); }); @@ -214,7 +244,9 @@ describe('app', function() { function(url, next) { executeMiddlewareHandlers(app, url, next); }, function(err) { if (err) return done(err); + expect(steps).to.eql(['/a', '/b', '/scope']); + done(); }); }); @@ -222,12 +254,15 @@ describe('app', function() { it('sets req.url to a sub-path', function(done) { app.middleware('initial', ['/scope'], function(req, res, next) { steps.push(req.url); + next(); }); executeMiddlewareHandlers(app, '/scope/id', function(err) { if (err) return done(err); + expect(steps).to.eql(['/id']); + done(); }); }); @@ -239,11 +274,13 @@ describe('app', function() { app.middleware('initial', function(rq, rs, next) { req = rq; res = rs; + next(); }); executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(getObjectAndPrototypeKeys(req), 'request').to.include.members([ 'accepts', 'get', @@ -273,12 +310,15 @@ describe('app', function() { var reqProps; app.middleware('initial', function(req, res, next) { reqProps = { baseUrl: req.baseUrl, originalUrl: req.originalUrl }; + next(); }); executeMiddlewareHandlers(app, '/test/url', function(err) { if (err) return done(err); + expect(reqProps).to.eql({ baseUrl: '', originalUrl: '/test/url' }); + done(); }); }); @@ -290,7 +330,9 @@ describe('app', function() { executeMiddlewareHandlers(app, '/test', function(err) { if (err) return done(err); + expect(steps).to.eql(['route', 'files']); + done(); }); }); @@ -310,7 +352,9 @@ describe('app', function() { executeMiddlewareHandlers(app, function(err) { if (err) return done; + expect(steps).to.eql(numbers); + done(); }); }); @@ -324,6 +368,7 @@ describe('app', function() { mountpath: req.app.mountpath, parent: req.app.parent }; + next(); }); subapp.on('mount', function() { mountWasEmitted = true; }); @@ -332,11 +377,13 @@ describe('app', function() { executeMiddlewareHandlers(app, '/mountpath/test', function(err) { if (err) return done(err); + expect(mountWasEmitted, 'mountWasEmitted').to.be.true; expect(data).to.eql({ mountpath: '/mountpath', parent: app }); + done(); }); }); @@ -350,25 +397,30 @@ describe('app', function() { subapp.use(function verifyTestAssumptions(req, res, next) { expect(req.__proto__).to.not.equal(expected.req); expect(res.__proto__).to.not.equal(expected.res); + next(); }); app.middleware('initial', function saveOriginalValues(req, res, next) { expected.req = req.__proto__; expected.res = res.__proto__; + next(); }); app.middleware('routes', subapp); app.middleware('final', function saveActualValues(req, res, next) { actual.req = req.__proto__; actual.res = res.__proto__; + next(); }); executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(actual.req, 'req').to.equal(expected.req); expect(actual.res, 'res').to.equal(expected.res); + done(); }); }); @@ -383,6 +435,7 @@ describe('app', function() { function pathSavingHandler() { return function(req, res, next) { steps.push(req.originalUrl); + next(); }; } @@ -406,6 +459,7 @@ describe('app', function() { var args = Array.prototype.slice.apply(arguments); return function(req, res, next) { steps.push(args); + next(); }; }; @@ -456,12 +510,14 @@ describe('app', function() { executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(steps).to.eql([ ['before'], [expectedConfig], ['after', 2], [{x: 1}] ]); + done(); }); }); @@ -472,6 +528,7 @@ describe('app', function() { function factory() { return function(req, res, next) { steps.push(req.originalUrl); + next(); }; }, @@ -485,7 +542,9 @@ describe('app', function() { function(url, next) { executeMiddlewareHandlers(app, url, next); }, function(err) { if (err) return done(err); + expect(steps).to.eql(['/a', '/b', '/scope']); + done(); }); }); @@ -542,24 +601,27 @@ describe('app', function() { names.forEach(function(it) { app.middleware(it, function(req, res, next) { steps.push(it); + next(); }); }); executeMiddlewareHandlers(app, function(err) { if (err) return done(err); + expect(steps).to.eql(names); + done(); }); } }); describe('app.model(Model)', function() { - var app; - var db; + var app, db, MyTestModel; beforeEach(function() { app = loopback(); - db = loopback.createDataSource({connector: loopback.Memory}); + db = loopback.createDataSource({ connector: loopback.Memory }); + MyTestModel = app.registry.createModel('MyTestModel', {}, {base: 'Model'}); }); it('Expose a `Model` to remote clients', function() { @@ -570,8 +632,8 @@ describe('app', function() { expect(app.models()).to.eql([Color]); }); - it('uses singlar name as app.remoteObjects() key', function() { - var Color = PersistedModel.extend('color', {name: String}); + it('uses singular name as app.remoteObjects() key', function() { + var Color = PersistedModel.extend('color', { name: String }); app.model(Color); Color.attachTo(db); expect(app.remoteObjects()).to.eql({ color: Color }); @@ -606,6 +668,22 @@ describe('app', function() { expect(remotedClass).to.eql(Color.sharedClass); }); + it('emits a `remoteMethodDisabled` event', function() { + var Color = PersistedModel.extend('color', { name: String }); + Color.shared = true; + var remoteMethodDisabledClass, disabledRemoteMethod; + app.on('remoteMethodDisabled', function(sharedClass, methodName) { + remoteMethodDisabledClass = sharedClass; + disabledRemoteMethod = methodName; + }); + app.model(Color); + app.models.Color.disableRemoteMethodByName('findOne'); + expect(remoteMethodDisabledClass).to.exist; + expect(remoteMethodDisabledClass).to.eql(Color.sharedClass); + expect(disabledRemoteMethod).to.exist; + expect(disabledRemoteMethod).to.eql('findOne'); + }); + it.onServer('updates REST API when a new model is added', function(done) { app.use(loopback.rest()); request(app).get('/colors').expect(404, function(err, res) { @@ -617,18 +695,22 @@ describe('app', function() { }); }); - it('accepts null dataSource', function() { - app.model('MyTestModel', { dataSource: null }); + it('accepts null dataSource', function(done) { + app.model(MyTestModel, { dataSource: null }); + expect(MyTestModel.dataSource).to.eql(null); + done(); }); - it('accepts false dataSource', function() { - app.model('MyTestModel', { dataSource: false }); + it('accepts false dataSource', function(done) { + app.model(MyTestModel, { dataSource: false }); + expect(MyTestModel.getDataSource()).to.eql(null); + done(); }); - it('should not require dataSource', function() { - app.model('MyTestModel', {}); + it('does not require dataSource', function(done) { + app.model(MyTestModel); + done(); }); - }); describe('app.model(name, config)', function() { @@ -636,6 +718,7 @@ describe('app', function() { beforeEach(function() { app = loopback(); + app.set('logoutSessionsOnSensitiveChanges', true); app.dataSource('db', { connector: 'memory' }); @@ -689,7 +772,6 @@ describe('app', function() { expect(app.models.foo.app).to.equal(app); expect(app.models.foo.shared).to.equal(true); }); - }); describe('app.model(ModelCtor, config)', function() { @@ -702,7 +784,8 @@ describe('app', function() { } assert(!previousModel || !previousModel.dataSource); - app.model('TestModel', { dataSource: 'db' }); + var TestModel = app.registry.createModel('TestModel'); + app.model(TestModel, { dataSource: 'db' }); expect(app.models.TestModel.dataSource).to.equal(app.dataSources.db); }); }); @@ -710,7 +793,8 @@ describe('app', function() { describe('app.models', function() { it('is unique per app instance', function() { app.dataSource('db', { connector: 'memory' }); - var Color = app.model('Color', { dataSource: 'db' }); + var Color = app.registry.createModel('Color'); + app.model(Color, { dataSource: 'db' }); expect(app.models.Color).to.equal(Color); var anotherApp = loopback(); expect(anotherApp.models.Color).to.equal(undefined); @@ -732,6 +816,22 @@ describe('app', function() { app.dataSource('custom', { connector: 'custom' }); expect(app.dataSources.custom.name).to.equal(loopback.Memory.name); }); + + it('adds data source name to error messages', function() { + app.connector('throwing', { + initialize: function() { throw new Error('expected test error'); }, + }); + + expect(function() { + app.dataSource('bad-ds', { connector: 'throwing' }); + }).to.throw(/bad-ds.*throwing/); + }); + + it('adds app reference to the data source object', function() { + app.dataSource('ds', { connector: 'memory' }); + expect(app.datasources.ds.app).to.not.equal(undefined); + expect(app.datasources.ds.app).to.equal(app); + }); }); describe.onServer('listen()', function() { @@ -755,6 +855,7 @@ describe('app', function() { app.listen(function() { expect(app.get('port'), 'port').to.not.equal(0); + done(); }); }); @@ -768,6 +869,7 @@ describe('app', function() { var host = process.platform === 'win32' ? 'localhost' : app.get('host'); var expectedUrl = 'http://' + host + ':' + app.get('port') + '/'; expect(app.get('url'), 'url').to.equal(expectedUrl); + done(); }); }); @@ -778,6 +880,7 @@ describe('app', function() { app.listen(0, '127.0.0.1', function() { expect(app.get('port'), 'port').to.not.equal(0).and.not.equal(1); expect(this.address().address).to.equal('127.0.0.1'); + done(); }); }); @@ -788,6 +891,7 @@ describe('app', function() { app.set('port', 1); app.listen(0).on('listening', function() { expect(app.get('port'), 'port') .to.not.equal(0).and.not.equal(1); + done(); }); } @@ -802,6 +906,7 @@ describe('app', function() { app.listen() .on('listening', function() { expect(this.address().address).to.equal('127.0.0.1'); + done(); }); }); @@ -818,6 +923,7 @@ describe('app', function() { var AUTH_MODELS = ['User', 'ACL', 'AccessToken', 'Role', 'RoleMapping']; var app = loopback({ localRegistry: true, loadBuiltinModels: true }); require('../lib/builtin-models')(app.registry); + app.set('logoutSessionsOnSensitiveChanges', true); var db = app.dataSource('db', { connector: 'memory' }); app.enableAuth({ dataSource: 'db' }); @@ -833,6 +939,7 @@ describe('app', function() { it('detects already configured subclass of a required model', function() { var app = loopback({ localRegistry: true, loadBuiltinModels: true }); + app.set('logoutSessionsOnSensitiveChanges', true); var db = app.dataSource('db', { connector: 'memory' }); var Customer = app.registry.createModel('Customer', {}, { base: 'User' }); app.model(Customer, { dataSource: 'db' }); @@ -853,18 +960,14 @@ describe('app', function() { .end(function(err, res) { if (err) return done(err); - assert.equal(typeof res.body, 'object'); - assert(res.body.started); - // The number can be 0 - assert(res.body.uptime !== undefined); + expect(res.body).to.be.an('object'); + expect(res.body).to.have.property('started'); + expect(res.body.uptime, 'uptime').to.be.gte(0); var elapsed = Date.now() - Number(new Date(res.body.started)); - // elapsed should be a positive number... - assert(elapsed >= 0); - - // less than 100 milliseconds - assert(elapsed < 100); + // elapsed should be a small positive number... + expect(elapsed, 'elapsed').to.be.within(0, 300); done(); }); @@ -957,8 +1060,18 @@ describe('app', function() { }); function executeMiddlewareHandlers(app, urlPath, callback) { + var handlerError; var server = http.createServer(function(req, res) { - app.handle(req, res, callback); + app.handle(req, res, function(err) { + if (err) { + handlerError = err; + res.statusCode = err.status || err.statusCode || 500; + res.end(err.stack || err); + } else { + res.statusCode = 204; + res.end(); + } + }); }); if (callback === undefined && typeof urlPath === 'function') { @@ -969,6 +1082,6 @@ function executeMiddlewareHandlers(app, urlPath, callback) { request(server) .get(urlPath) .end(function(err) { - if (err) return callback(err); + callback(handlerError || err); }); } diff --git a/test/change-stream.test.js b/test/change-stream.test.js index ab7405214..86dfc77fd 100644 --- a/test/change-stream.test.js +++ b/test/change-stream.test.js @@ -1,10 +1,16 @@ +// Copyright IBM Corp. 2015,2016. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + describe('PersistedModel.createChangeStream()', function() { describe('configured to source changes locally', function() { before(function() { var test = this; - var app = loopback({localRegistry: true}); - var ds = app.dataSource('ds', {connector: 'memory'}); - this.Score = app.model('Score', { + var app = loopback({ localRegistry: true }); + var ds = app.dataSource('ds', { connector: 'memory' }); + var Score = app.registry.createModel('Score'); + this.Score = app.model(Score, { dataSource: 'ds', changeDataSource: false // use only local observers }); @@ -17,6 +23,7 @@ describe('PersistedModel.createChangeStream()', function() { changes.on('data', function(change) { expect(change.type).to.equal('create'); changes.destroy(); + done(); }); @@ -31,6 +38,7 @@ describe('PersistedModel.createChangeStream()', function() { changes.on('data', function(change) { expect(change.type).to.equal('update'); changes.destroy(); + done(); }); newScore.updateAttributes({ @@ -47,6 +55,7 @@ describe('PersistedModel.createChangeStream()', function() { changes.on('data', function(change) { expect(change.type).to.equal('remove'); changes.destroy(); + done(); }); diff --git a/test/change.test.js b/test/change.test.js index 55d551c72..220d8055f 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 async = require('async'); var expect = require('chai').expect; @@ -28,9 +33,11 @@ describe('Change', function() { }; TestModel.create(test.data, function(err, model) { if (err) return done(err); + test.model = model; test.modelId = model.id; test.revisionForModel = Change.revisionForInst(model); + done(); }); }); @@ -61,6 +68,7 @@ describe('Change', function() { var test = this; Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trackedChanges) { if (err) return done(err); + done(); }); }); @@ -69,6 +77,7 @@ describe('Change', function() { var test = this; Change.find(function(err, trackedChanges) { assert.equal(trackedChanges[0].modelId, test.modelId.toString()); + done(); }); }); @@ -76,12 +85,47 @@ describe('Change', function() { it('should only create one change', function(done) { Change.count(function(err, count) { assert.equal(count, 1); + done(); }); }); }); }); + describe('Change.rectifyModelChanges - promise variant', function() { + describe('using an existing untracked model', function() { + beforeEach(function(done) { + var test = this; + Change.rectifyModelChanges(this.modelName, [this.modelId]) + .then(function(trackedChanges) { + done(); + }) + .catch(done); + }); + + it('should create an entry', function(done) { + var test = this; + Change.find() + .then(function(trackedChanges) { + assert.equal(trackedChanges[0].modelId, test.modelId.toString()); + + done(); + }) + .catch(done); + }); + + it('should only create one change', function(done) { + Change.count() + .then(function(count) { + assert.equal(count, 1); + + done(); + }) + .catch(done); + }); + }); + }); + describe('Change.findOrCreateChange(modelName, modelId, callback)', function() { describe('when a change doesnt exist', function() { @@ -89,7 +133,9 @@ describe('Change', function() { var test = this; Change.findOrCreateChange(this.modelName, this.modelId, function(err, result) { if (err) return done(err); + test.result = result; + done(); }); }); @@ -98,7 +144,33 @@ describe('Change', function() { var test = this; Change.findById(this.result.id, function(err, change) { if (err) return done(err); + assert.equal(change.id, test.result.id); + + done(); + }); + }); + }); + + describe('when a change doesnt exist - promise variant', function() { + beforeEach(function(done) { + var test = this; + Change.findOrCreateChange(this.modelName, this.modelId) + .then(function(result) { + test.result = result; + + done(); + }) + .catch(done); + }); + + it('should create an entry', function(done) { + var test = this; + Change.findById(this.result.id, function(err, change) { + if (err) return done(err); + + assert.equal(change.id, test.result.id); + done(); }); }); @@ -112,6 +184,7 @@ describe('Change', function() { modelId: test.modelId }, function(err, change) { test.existingChange = change; + done(); }); }); @@ -120,7 +193,9 @@ describe('Change', function() { var test = this; Change.findOrCreateChange(this.modelName, this.modelId, function(err, result) { if (err) return done(err); + test.result = result; + done(); }); }); @@ -128,6 +203,7 @@ describe('Change', function() { it('should find the entry', function(done) { var test = this; assert.equal(test.existingChange.id, test.result.id); + done(); }); }); @@ -143,6 +219,7 @@ describe('Change', function() { }, function(err, ch) { change = ch; + done(err); }); }); @@ -151,6 +228,7 @@ describe('Change', function() { var test = this; change.rectify(function(err, ch) { assert.equal(ch.rev, test.revisionForModel); + done(); }); }); @@ -174,6 +252,7 @@ describe('Change', function() { expect(change.type(), 'type').to.equal('update'); expect(change.prev, 'prev').to.equal(originalRev); expect(change.rev, 'rev').to.equal(test.revisionForModel); + next(); } ], done); @@ -185,7 +264,9 @@ describe('Change', function() { function checkpoint(next) { TestModel.checkpoint(function(err, inst) { if (err) return next(err); + cp = inst.seq; + next(); }); } @@ -196,6 +277,7 @@ describe('Change', function() { model.name += 'updated'; model.save(function(err) { test.revisionForModel = Change.revisionForInst(model); + next(err); }); } @@ -211,14 +293,40 @@ describe('Change', function() { change.rectify(function(err, c) { if (err) return done(err); + expect(c.rev, 'rev').to.equal(originalRev); // sanity check expect(c.checkpoint, 'checkpoint').to.equal(originalCheckpoint); + done(); }); }); }); }); + describe('change.rectify - promise variant', function() { + var change; + beforeEach(function(done) { + Change.findOrCreateChange(this.modelName, this.modelId) + .then(function(ch) { + change = ch; + + done(); + }) + .catch(done); + }); + + it('should create a new change with the correct revision', function(done) { + var test = this; + change.rectify() + .then(function(ch) { + assert.equal(ch.rev, test.revisionForModel); + + done(); + }) + .catch(done); + }); + }); + describe('change.currentRevision(callback)', function() { it('should get the correct revision', function(done) { var test = this; @@ -229,11 +337,30 @@ describe('Change', function() { change.currentRevision(function(err, rev) { assert.equal(rev, test.revisionForModel); + done(); }); }); }); + describe('change.currentRevision - promise variant', function() { + it('should get the correct revision', function(done) { + var test = this; + var change = new Change({ + modelName: this.modelName, + modelId: this.modelId + }); + + change.currentRevision() + .then(function(rev) { + assert.equal(rev, test.revisionForModel); + + done(); + }) + .catch(done); + }); + }); + describe('Change.hash(str)', function() { // todo(ritch) test other hashing algorithms it('should hash the given string', function() { @@ -368,12 +495,34 @@ describe('Change', function() { Change.diff(this.modelName, 0, remoteChanges, function(err, diff) { if (err) return done(err); + assert.equal(diff.deltas.length, 1); assert.equal(diff.conflicts.length, 1); + done(); }); }); + it('should return delta and conflict lists - promise variant', function(done) { + var remoteChanges = [ + // an update => should result in a delta + {rev: 'foo2', prev: 'foo', modelName: this.modelName, modelId: 9, checkpoint: 1}, + // no change => should not result in a delta / conflict + {rev: 'bar', prev: 'bar', modelName: this.modelName, modelId: 10, checkpoint: 1}, + // a conflict => should result in a conflict + {rev: 'bat2', prev: 'bat0', modelName: this.modelName, modelId: 11, checkpoint: 1}, + ]; + + Change.diff(this.modelName, 0, remoteChanges) + .then(function(diff) { + assert.equal(diff.deltas.length, 1); + assert.equal(diff.conflicts.length, 1); + + done(); + }) + .catch(done); + }); + it('should set "prev" to local revision in non-conflicting delta', function(done) { var updateRecord = { rev: 'foo-new', @@ -384,6 +533,7 @@ describe('Change', function() { }; Change.diff(this.modelName, 0, [updateRecord], function(err, diff) { if (err) return done(err); + expect(diff.conflicts, 'conflicts').to.have.length(0); expect(diff.deltas, 'deltas').to.have.length(1); var actual = diff.deltas[0].toObject(); @@ -395,6 +545,7 @@ describe('Change', function() { prev: 'foo', // this is the current local revision rev: 'foo-new', }); + done(); }); }); @@ -411,6 +562,7 @@ describe('Change', function() { // with rev=foo CP=1 Change.diff(this.modelName, 2, [updateRecord], function(err, diff) { if (err) return done(err); + expect(diff.conflicts, 'conflicts').to.have.length(0); expect(diff.deltas, 'deltas').to.have.length(1); var actual = diff.deltas[0].toObject(); @@ -422,6 +574,7 @@ describe('Change', function() { prev: 'foo', // this is the current local revision rev: 'foo-new', }); + done(); }); }); @@ -437,6 +590,7 @@ describe('Change', function() { Change.diff(this.modelName, 0, [updateRecord], function(err, diff) { if (err) return done(err); + expect(diff.conflicts).to.have.length(0); expect(diff.deltas).to.have.length(1); var actual = diff.deltas[0].toObject(); @@ -448,6 +602,7 @@ describe('Change', function() { prev: null, // this is the current local revision rev: 'new-rev', }); + done(); }); }); diff --git a/test/checkpoint.test.js b/test/checkpoint.test.js index 98e248efb..b5b9093ae 100644 --- a/test/checkpoint.test.js +++ b/test/checkpoint.test.js @@ -1,28 +1,98 @@ +// Copyright IBM Corp. 2014,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 async = require('async'); var loopback = require('../'); +var expect = require('chai').expect; -// create a unique Checkpoint model var Checkpoint = loopback.Checkpoint.extend('TestCheckpoint'); -var memory = loopback.createDataSource({ - connector: loopback.Memory -}); -Checkpoint.attachTo(memory); - describe('Checkpoint', function() { - describe('current()', function() { + describe('bumpLastSeq() and current()', function() { + beforeEach(function() { + var memory = loopback.createDataSource({ + connector: loopback.Memory + }); + Checkpoint.attachTo(memory); + }); + it('returns the highest `seq` value', function(done) { async.series([ - Checkpoint.create.bind(Checkpoint), - Checkpoint.create.bind(Checkpoint), + Checkpoint.bumpLastSeq.bind(Checkpoint), + Checkpoint.bumpLastSeq.bind(Checkpoint), function(next) { Checkpoint.current(function(err, seq) { if (err) next(err); + expect(seq).to.equal(3); + next(); }); } ], done); }); + + it('Should be no race condition for current() when calling in parallel', function(done) { + async.parallel([ + function(next) { Checkpoint.current(next); }, + function(next) { Checkpoint.current(next); } + ], function(err, list) { + if (err) return done(err); + + Checkpoint.find(function(err, data) { + if (err) return done(err); + + expect(data).to.have.length(1); + + done(); + }); + }); + }); + + it('Should be no race condition for bumpLastSeq() when calling in parallel', function(done) { + async.parallel([ + function(next) { Checkpoint.bumpLastSeq(next); }, + function(next) { Checkpoint.bumpLastSeq(next); } + ], function(err, list) { + if (err) return done(err); + + Checkpoint.find(function(err, data) { + if (err) return done(err); + // The invariant "we have at most 1 checkpoint instance" is preserved + // even when multiple calls are made in parallel + expect(data).to.have.length(1); + // There is a race condition here, we could end up with both 2 or 3 as the "seq". + // The current implementation of the memory connector always yields 2 though. + expect(data[0].seq).to.equal(2); + // In this particular case, since the new last seq is always 2, both results + // should be 2. + expect(list.map(function(it) {return it.seq;})) + .to.eql([2, 2]); + + done(); + }); + }); + }); + + it('Checkpoint.current() for non existing checkpoint should initialize checkpoint', function(done) { + Checkpoint.current(function(err, seq) { + expect(seq).to.equal(1); + + done(err); + }); + }); + + it('bumpLastSeq() works when singleton instance does not exists yet', function(done) { + Checkpoint.bumpLastSeq(function(err, cp) { + // We expect `seq` to be 2 since `checkpoint` does not exist and + // `bumpLastSeq` for the first time not only initializes it to one, + // but also increments the initialized value by one. + expect(cp.seq).to.equal(2); + + done(err); + }); + }); }); }); diff --git a/test/context-options.test.js b/test/context-options.test.js new file mode 100644 index 000000000..ac8f50412 --- /dev/null +++ b/test/context-options.test.js @@ -0,0 +1,454 @@ +// 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 + +'use strict'; + +var expect = require('chai').expect; +var loopback = require('..'); +var supertest = require('supertest-as-promised')(require('bluebird')); + +describe('OptionsFromRemotingContext', function() { + var app, request, accessToken, userId, Product, actualOptions; + + beforeEach(setupAppAndRequest); + beforeEach(resetActualOptions); + + context('when making updates via REST', function() { + beforeEach(observeOptionsBeforeSave); + + it('injects options to create()', function() { + return request.post('/products') + .send({name: 'Pen'}) + .expect(200) + .then(expectInjectedOptions); + }); + + it('injects options to patchOrCreate()', function() { + return request.patch('/products') + .send({id: 1, name: 'Pen'}) + .expect(200) + .then(expectInjectedOptions); + }); + + it('injects options to replaceOrCreate()', function() { + return request.put('/products') + .send({id: 1, name: 'Pen'}) + .expect(200) + .then(expectInjectedOptions); + }); + + it('injects options to patchOrCreateWithWhere()', function() { + return request.post('/products/upsertWithWhere?where[name]=Pen') + .send({name: 'Pencil'}) + .expect(200) + .then(expectInjectedOptions); + }); + + it('injects options to replaceById()', function() { + return Product.create({id: 1, name: 'Pen'}) + .then(function(p) { + return request.put('/products/1') + .send({name: 'Pencil'}) + .expect(200); + }) + .then(expectInjectedOptions); + }); + + it('injects options to prototype.patchAttributes()', function() { + return Product.create({id: 1, name: 'Pen'}) + .then(function(p) { + return request.patch('/products/1') + .send({name: 'Pencil'}) + .expect(200); + }) + .then(expectInjectedOptions); + }); + + it('injects options to updateAll()', function() { + return request.post('/products/update?where[name]=Pen') + .send({name: 'Pencil'}) + .expect(200) + .then(expectInjectedOptions); + }); + }); + + context('when deleting via REST', function() { + beforeEach(observeOptionsBeforeDelete); + + it('injects options to deleteById()', function() { + return Product.create({id: 1, name: 'Pen'}) + .then(function(p) { + return request.delete('/products/1').expect(200); + }) + .then(expectInjectedOptions); + }); + }); + + context('when querying via REST', function() { + beforeEach(observeOptionsOnAccess); + beforeEach(givenProductId1); + + it('injects options to find()', function() { + return request.get('/products').expect(200) + .then(expectInjectedOptions); + }); + + it('injects options to findById()', function() { + return request.get('/products/1').expect(200) + .then(expectInjectedOptions); + }); + + it('injects options to findOne()', function() { + return request.get('/products/findOne?where[id]=1').expect(200) + .then(expectInjectedOptions); + }); + + it('injects options to exists()', function() { + return request.head('/products/1').expect(200) + .then(expectInjectedOptions); + }); + + it('injects options to count()', function() { + return request.get('/products/count').expect(200) + .then(expectInjectedOptions); + }); + }); + + context('when invoking prototype methods', function() { + beforeEach(observeOptionsOnAccess); + beforeEach(givenProductId1); + + it('injects options to sharedCtor', function() { + Product.prototype.dummy = function(cb) { cb(); }; + Product.remoteMethod('dummy', {isStatic: false}); + return request.post('/products/1/dummy').expect(204) + .then(expectInjectedOptions); + }); + }); + + it('honours injectOptionsFromRemoteContext in sharedCtor', function() { + var settings = { + forceId: false, + injectOptionsFromRemoteContext: false, + }; + var TestModel = app.registry.createModel('TestModel', {}, settings); + app.model(TestModel, {dataSource: 'db'}); + + TestModel.prototype.dummy = function(cb) { cb(); }; + TestModel.remoteMethod('dummy', {isStatic: false}); + + observeOptionsOnAccess(TestModel); + + return TestModel.create({id: 1}) + .then(function() { + return request.post('/TestModels/1/dummy').expect(204); + }) + .then(function() { + expect(actualOptions).to.eql({}); + }); + }); + + // Catch: because relations methods are defined on "modelFrom", + // they will invoke createOptionsFromRemotingContext on "modelFrom" too, + // despite the fact that under the hood a method on "modelTo" is called. + + context('hasManyThrough', function() { + var Category, ThroughModel; + + beforeEach(givenCategoryHasManyProductsThroughAnotherModel); + beforeEach(givenCategoryAndProduct); + + it('injects options to findById', function() { + observeOptionsOnAccess(Product); + return request.get('/categories/1/products/1').expect(200) + .then(expectOptionsInjectedFromCategory); + }); + + it('injects options to destroyById', function() { + observeOptionsBeforeDelete(Product); + return request.del('/categories/1/products/1').expect(204) + .then(expectOptionsInjectedFromCategory); + }); + + it('injects options to updateById', function() { + observeOptionsBeforeSave(Product); + return request.put('/categories/1/products/1') + .send({description: 'a description'}) + .expect(200) + .then(expectInjectedOptions); + }); + + context('through-model operations', function() { + it('injects options to link', function() { + observeOptionsBeforeSave(ThroughModel); + return Product.create({id: 2, name: 'Car2'}) + .then(function() { + return request.put('/categories/1/products/rel/2') + .send({description: 'a description'}) + .expect(200); + }) + .then(expectOptionsInjectedFromCategory); + }); + + it('injects options to unlink', function() { + observeOptionsBeforeDelete(ThroughModel); + return request.del('/categories/1/products/rel/1').expect(204) + .then(expectOptionsInjectedFromCategory); + }); + + it('injects options to exists', function() { + observeOptionsOnAccess(ThroughModel); + return request.head('/categories/1/products/rel/1').expect(200) + .then(expectOptionsInjectedFromCategory); + }); + }); + + context('scope operations', function() { + it('injects options to get', function() { + observeOptionsOnAccess(Product); + return request.get('/categories/1/products').expect(200) + .then(expectOptionsInjectedFromCategory); + }); + + it('injects options to create', function() { + observeOptionsBeforeSave(Product); + return request.post('/categories/1/products') + .send({name: 'Pen'}) + .expect(200) + .then(expectOptionsInjectedFromCategory); + }); + + it('injects options to delete', function() { + observeOptionsBeforeDelete(ThroughModel); + return request.del('/categories/1/products').expect(204) + .then(expectOptionsInjectedFromCategory); + }); + + it('injects options to count', function() { + observeOptionsOnAccess(ThroughModel); + return request.get('/categories/1/products/count').expect(200) + .then(expectOptionsInjectedFromCategory); + }); + }); + + function givenCategoryHasManyProductsThroughAnotherModel() { + var settings = { + forceId: false, + replaceOnPUT: true, + injectOptionsFromRemoteContext: true, + }; + Category = app.registry.createModel( + 'Category', + {name: String}, + settings); + + app.model(Category, {dataSource: 'db'}); + // This is a shortcut for creating CategoryProduct "through" model + Category.hasAndBelongsToMany(Product); + + Category.createOptionsFromRemotingContext = function(ctx) { + return {injectedFrom: 'Category'}; + }; + + ThroughModel = app.registry.getModel('CategoryProduct'); + } + + function givenCategoryAndProduct() { + return Category.create({id: 1, name: 'First Category'}) + .then(function(cat) { + return cat.products.create({id: 1, name: 'Pen'}); + }); + } + + function expectOptionsInjectedFromCategory() { + expect(actualOptions).to.have.property('injectedFrom', 'Category'); + } + }); + + context('hasOne', function() { + var Category; + + beforeEach(givenCategoryHasOneProduct); + beforeEach(givenCategoryId1); + + it('injects options to get', function() { + observeOptionsOnAccess(Product); + return givenProductInCategory1() + .then(function() { + return request.get('/categories/1/product').expect(200); + }) + .then(expectOptionsInjectedFromCategory); + }); + + it('injects options to create', function() { + observeOptionsBeforeSave(Product); + return request.post('/categories/1/product') + .send({name: 'Pen'}) + .expect(200) + .then(expectOptionsInjectedFromCategory); + }); + + it('injects options to update', function() { + return givenProductInCategory1() + .then(function() { + observeOptionsBeforeSave(Product); + return request.put('/categories/1/product') + .send({description: 'a description'}) + .expect(200); + }) + .then(expectInjectedOptions); + }); + + it('injects options to destroy', function() { + observeOptionsBeforeDelete(Product); + return givenProductInCategory1() + .then(function() { + return request.del('/categories/1/product').expect(204); + }) + .then(expectOptionsInjectedFromCategory); + }); + + function givenCategoryHasOneProduct() { + var settings = { + forceId: false, + replaceOnPUT: true, + injectOptionsFromRemoteContext: true, + }; + Category = app.registry.createModel( + 'Category', + {name: String}, + settings); + + app.model(Category, {dataSource: 'db'}); + Category.hasOne(Product); + + Category.createOptionsFromRemotingContext = function(ctx) { + return {injectedFrom: 'Category'}; + }; + } + + function givenCategoryId1() { + return Category.create({id: 1, name: 'First Category'}); + } + + function givenProductInCategory1() { + return Product.create({id: 1, name: 'Pen', categoryId: 1}); + } + + function expectOptionsInjectedFromCategory() { + expect(actualOptions).to.have.property('injectedFrom', 'Category'); + } + }); + + context('belongsTo', function() { + var Category; + + beforeEach(givenCategoryBelongsToProduct); + + it('injects options to get', function() { + observeOptionsOnAccess(Product); + return Product.create({id: 1, name: 'Pen'}) + .then(function() { + return Category.create({id: 1, name: 'a name', productId: 1}); + }) + .then(function() { + return request.get('/categories/1/product').expect(200); + }) + .then(expectOptionsInjectedFromCategory); + }); + + function givenCategoryBelongsToProduct() { + var settings = { + forceId: false, + replaceOnPUT: true, + injectOptionsFromRemoteContext: true, + }; + Category = app.registry.createModel( + 'Category', + {name: String}, + settings); + + app.model(Category, {dataSource: 'db'}); + Category.belongsTo(Product); + + Category.createOptionsFromRemotingContext = function(ctx) { + return {injectedFrom: 'Category'}; + }; + } + + function givenCategoryId1() { + return Category.create({id: 1, name: 'First Category'}); + } + + function givenProductInCategory1() { + return Product.create({id: 1, name: 'Pen', categoryId: 1}); + } + + function expectOptionsInjectedFromCategory() { + expect(actualOptions).to.have.property('injectedFrom', 'Category'); + } + }); + + function setupAppAndRequest() { + app = loopback({localRegistry: true}); + app.dataSource('db', {connector: 'memory'}); + + var settings = { + forceId: false, + replaceOnPUT: true, + injectOptionsFromRemoteContext: true, + }; + + Product = app.registry.createModel( + 'Product', + {name: String}, + settings); + + Product.createOptionsFromRemotingContext = function(ctx) { + return {injectedFrom: 'Product'}; + }; + + app.model(Product, {dataSource: 'db'}); + + app.use(loopback.rest()); + request = supertest(app); + } + + function resetActualOptions() { + actualOptions = undefined; + } + + function observeOptionsBeforeSave() { + var Model = arguments[0] || Product; + Model.observe('before save', function(ctx, next) { + actualOptions = ctx.options; + next(); + }); + } + + function observeOptionsBeforeDelete() { + var Model = arguments[0] || Product; + Model.observe('before delete', function(ctx, next) { + actualOptions = ctx.options; + next(); + }); + } + + function observeOptionsOnAccess() { + var Model = arguments[0] || Product; + Model.observe('access', function(ctx, next) { + actualOptions = ctx.options; + next(); + }); + } + + function givenProductId1() { + return Product.create({id: 1, name: 'Pen'}); + } + + function expectInjectedOptions(name) { + expect(actualOptions).to.have.property('injectedFrom'); + } +}); diff --git a/test/data-source.test.js b/test/data-source.test.js index 662c07184..a3ec55ef0 100644 --- a/test/data-source.test.js +++ b/test/data-source.test.js @@ -1,3 +1,8 @@ +// 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 + describe('DataSource', function() { var memory; @@ -17,6 +22,7 @@ describe('DataSource', function() { assert.isFunc(Color, 'findOne'); assert.isFunc(Color, 'create'); assert.isFunc(Color, 'updateOrCreate'); + assert.isFunc(Color, 'upsertWithWhere'); assert.isFunc(Color, 'upsert'); assert.isFunc(Color, 'findOrCreate'); assert.isFunc(Color, 'exists'); @@ -78,6 +84,7 @@ describe('DataSource', function() { existsAndShared('_forDB', false); existsAndShared('create', true); existsAndShared('updateOrCreate', true); + existsAndShared('upsertWithWhere', true); existsAndShared('upsert', true); existsAndShared('findOrCreate', false); existsAndShared('exists', true); diff --git a/test/e2e/remote-connector.e2e.js b/test/e2e/remote-connector.e2e.js index 1a3424718..e24436929 100644 --- a/test/e2e/remote-connector.e2e.js +++ b/test/e2e/remote-connector.e2e.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 path = require('path'); var loopback = require('../../'); var models = require('../fixtures/e2e/models'); @@ -19,7 +24,9 @@ describe('RemoteConnector', function() { foo: 'bar' }, function(err, inst) { if (err) return done(err); + assert(inst.id); + done(); }); }); @@ -30,7 +37,9 @@ describe('RemoteConnector', function() { }); m.save(function(err, data) { if (err) return done(err); + assert(data.foo === 'bar'); + done(); }); }); diff --git a/test/e2e/replication.e2e.js b/test/e2e/replication.e2e.js index 24f6967e0..ad7d57363 100644 --- a/test/e2e/replication.e2e.js +++ b/test/e2e/replication.e2e.js @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2014,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 path = require('path'); var loopback = require('../../'); var models = require('../fixtures/e2e/models'); @@ -27,8 +32,10 @@ describe('Replication', function() { }, function(err, created) { LocalTestModel.replicate(0, TestModel, function() { if (err) return done(err); - TestModel.findOne({n: RANDOM}, function(err, found) { + + TestModel.findOne({ n: RANDOM }, function(err, found) { assert.equal(created.id, found.id); + done(); }); }); diff --git a/test/email.test.js b/test/email.test.js index 018f543ca..c1303d8db 100644 --- a/test/email.test.js +++ b/test/email.test.js @@ -1,3 +1,8 @@ +// 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 loopback = require('../'); var MyEmail; var assert = require('assert'); @@ -33,6 +38,14 @@ describe('Email connector', function() { assert(connector.transportForName('smtp')); }); + it('should set up a aliased transport for SMTP' , function() { + var connector = new MailConnector({transport: + {type: 'smtp', service: 'ses-us-east-1', alias: 'ses-smtp'} + }); + + assert(connector.transportForName('ses-smtp')); + }); + }); describe('Email and SMTP', function() { @@ -61,6 +74,7 @@ describe('Email and SMTP', function() { assert(mail.response); assert(mail.envelope); assert(mail.messageId); + done(err); }); }); @@ -78,6 +92,7 @@ describe('Email and SMTP', function() { assert(mail.response); assert(mail.envelope); assert(mail.messageId); + done(err); }); }); diff --git a/test/error-handler.test.js b/test/error-handler.test.js index d19abf47e..d522c5e6c 100644 --- a/test/error-handler.test.js +++ b/test/error-handler.test.js @@ -1,6 +1,11 @@ +// Copyright IBM Corp. 2015,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 loopback = require('../'); var app; -var assert = require('assert'); +var expect = require('chai').expect; var request = require('supertest'); describe('loopback.errorHandler(options)', function() { @@ -16,7 +21,9 @@ describe('loopback.errorHandler(options)', function() { request(app) .get('/url-does-not-exist') .end(function(err, res) { - assert.ok(res.error.text.match(/