diff --git a/lib/mongodb.js b/lib/mongodb.js index c4e58a69f..13ba0f77e 100644 --- a/lib/mongodb.js +++ b/lib/mongodb.js @@ -332,15 +332,12 @@ MongoDB.prototype.connect = function(callback) { if (callback) callback(err); } - const urlObj = new URL(self.settings.url); - - if ((urlObj.pathname === '' || - urlObj.pathname.split('/')[1] === '') && - typeof self.settings.database === 'string') { - urlObj.pathname = self.settings.database; - self.settings.url = urlObj.toString(); + // This is special processing if database is not part of url, but is in settings + if (self.settings.url && self.settings.database) { + if (self.settings.url.indexOf('/' + self.settings.database) === -1) { + self.settings.url = processMongoDBURL(self.settings.database, self.settings.url); + } } - new mongodb.MongoClient(self.settings.url, validOptions).connect(function( err, client, @@ -2120,6 +2117,105 @@ MongoDB.prototype.rollback = function(tx, cb) { }); }; +exports.processMongoDBURL = processMongoDBURL; +/** + * This method parses a Mongo connection url string and refers the formats + * specified at: https://www.mongodb.com/docs/manual/reference/connection-string/. + * Since there are cases where database is not specified in the url, but as a settings property, + * the code has to reflect that in the url otherwise the MongoDB driver defaults to 'admin' db. + * @param {string} settingsDatabase - the database that will be added if url doesn't have a db specified + * @param {string} mongoUrl - the url to be processed for database manipulation + */ +function processMongoDBURL(settingsDatabase, mongoUrl) { + // Reference: https://www.mongodb.com/docs/manual/reference/connection-string/ + // Standard format::: mongodb://[username:password@]host1[:port1][,...hostN[:portN]][/[defaultauthdb][?options]] + // DNS SeedList format::: mongodb+srv://server.example.com/?connectTimeoutMS=300000&authSource=aDifferentAuthDB + // Actual replicaset example::: mongodb://mongodb1.example.com:27317,mongodb2.example.com:27017/?connectTimeoutMS=300000&replicaSet=mySet&authSource=aDifferentAuthDB + + if (mongoUrl) { + // 1. Know the protocol + let baseUrl = ''; + if (mongoUrl.startsWith('mongodb:')) + baseUrl = 'mongodb://'; + else if (mongoUrl.startsWith('mongodb+srv:')) + baseUrl = 'mongodb+srv://'; + else if (mongoUrl.startsWith('loopback-connector-mongodb:')) + baseUrl = 'loopback-connector-mongodb://'; + else if (mongoUrl.startsWith('loopback-connector-mongodb+srv:')) + baseUrl = 'loopback-connector-mongodb+srv://'; + else + return mongoUrl; // Not a MongoURL that we can process + + let remainderUrl = mongoUrl.substring(baseUrl.length); + // 2. Check if userId/password is present + let uidPassword = ''; + if (remainderUrl.indexOf('@') !== -1) { + const parts = remainderUrl.split('@'); + uidPassword = parts[0]; + if (parts.length === 2) + remainderUrl = parts[1]; + else + remainderUrl = ''; + } + let hosts = ''; + let dbName = ''; + let options = ''; + let hostsArray = []; + // 3. Check if comma separated replicas are specified + if (remainderUrl.indexOf(',') !== -1) { + hostsArray = remainderUrl.split(','); + remainderUrl = hostsArray[hostsArray.length - 1]; + } + + // 4. Check if authDB is specified in the URL + const slashIndex = remainderUrl.indexOf('/'); + if ((slashIndex !== -1)) { + if (slashIndex !== (remainderUrl.length - 1)) { + const optionsIndex = remainderUrl.indexOf('?'); + if (optionsIndex !== -1) { + options = remainderUrl.substring(optionsIndex + 1); + dbName = remainderUrl.substring(slashIndex + 1, optionsIndex); + } else { + // No DB options specified + dbName = remainderUrl.substring(slashIndex + 1); + } + } + + if (hostsArray.length > 1) { + const newHosts = hostsArray; + newHosts.pop(); + newHosts.push(remainderUrl.substring(0, slashIndex)); + hosts = newHosts.join(','); + } else { + hosts = remainderUrl.substring(0, slashIndex); + } + } else { + // No database specified + if (hostsArray.length > 1) + hosts = hostsArray.join(','); + else + hosts = remainderUrl; + } + + // 5. Reconstruct url, but this time add database from settings if URL didn't have it + // The below code has an overlap with generateMongoDBURL() + let modifiedUrl = baseUrl; + + if (uidPassword) + modifiedUrl += uidPassword + '@'; + if (hosts) + modifiedUrl += hosts; + + modifiedUrl += '/' + (dbName ? dbName : settingsDatabase); + + if (options) + modifiedUrl += '?' + options; + + return modifiedUrl; + } + return mongoUrl; +} + function isInTransation(options) { const ops = {}; if (options && options.transaction && options.transaction.isInTransation) diff --git a/package-lock.json b/package-lock.json index 6c0002d4d..0797bfa55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "loopback-connector-mongodb", - "version": "6.2.0", + "version": "6.3.0", "license": "MIT", "dependencies": { "async": "^3.2.4", diff --git a/test/mongodb.test.js b/test/mongodb.test.js index 8e6402688..c1e4423d8 100644 --- a/test/mongodb.test.js +++ b/test/mongodb.test.js @@ -449,6 +449,80 @@ describe('mongodb connector', function() { }); }); }); + it("should honor the settings database if url doesn't have db", function(done) { + const cfg = JSON.parse(JSON.stringify(global.config)); + const testDb = cfg.database; + cfg.url = 'mongodb://' + cfg.host + ':' + cfg.port; + const ds = global.getDataSource(cfg); + ds.once('connected', function() { + const db = ds.connector.db; + let validationError = null; + try { + db.should.have.property('databaseName', testDb); // check the db name in the db instance + } catch (err) { + // async error + validationError = err; + } + ds.ping(function(err) { + if (err && !validationError) validationError = err; + ds.disconnect(function(disconnectError) { + if (disconnectError && !validationError) + validationError = disconnectError; + done(validationError); + }); + }); + }); + }); + + it('should honor the url database if both replicaset url and settings has db', function(done) { + const cfg = JSON.parse(JSON.stringify(global.config)); + const testDb = 'lb-ds-overriden-test-1'; + cfg.url = 'mongodb://' + cfg.host + ':' + cfg.port + ',' + cfg.host + ':' + cfg.port + '/' + testDb; + const ds = global.getDataSource(cfg); + ds.once('connected', function() { + const db = ds.connector.db; + let validationError = null; + try { + db.should.have.property('databaseName', testDb); // check the db name in the db instance + } catch (err) { + // async error + validationError = err; + } + ds.ping(function(err) { + if (err && !validationError) validationError = err; + ds.disconnect(function(disconnectError) { + if (disconnectError && !validationError) + validationError = disconnectError; + done(validationError); + }); + }); + }); + }); + + it("should honor the settings database if replicaset url doesn't have db has slash", function(done) { + const cfg = JSON.parse(JSON.stringify(global.config)); + const testDb = cfg.database; + cfg.url = 'mongodb://' + cfg.host + ':' + cfg.port + ',' + cfg.host + ':' + cfg.port + '/'; + const ds = global.getDataSource(cfg); + ds.once('connected', function() { + const db = ds.connector.db; + let validationError = null; + try { + db.should.have.property('databaseName', testDb); // check the db name in the db instance + } catch (err) { + // async error + validationError = err; + } + ds.ping(function(err) { + if (err && !validationError) validationError = err; + ds.disconnect(function(disconnectError) { + if (disconnectError && !validationError) + validationError = disconnectError; + done(validationError); + }); + }); + }); + }); }); describe('order filters', function() { @@ -1331,9 +1405,12 @@ describe('mongodb connector', function() { function(err, updatedusers) { should.exist(err); err.name.should.equal('MongoError'); - err.errmsg.should.equal( - 'The dollar ($) prefixed ' + - "field '$rename' in '$rename' is not valid for storage.", + err.errmsg.should.equalOneOf( + ("The dollar ($) prefixed field '$rename' in '$rename' is not " + + "allowed in the context of an update's replacement document. Consider using an " + + 'aggregation pipeline with $replaceWith.'), + ('The dollar ($) prefixed ' + + "field '$rename' in '$rename' is not valid for storage."), ); done(); }, @@ -1356,9 +1433,12 @@ describe('mongodb connector', function() { function(err, updatedusers) { should.exist(err); err.name.should.equal('MongoError'); - err.errmsg.should.equal( - 'The dollar ($) prefixed ' + - "field '$rename' in '$rename' is not valid for storage.", + err.errmsg.should.equalOneOf( + ("The dollar ($) prefixed field '$rename' in '$rename' is not " + + "allowed in the context of an update's replacement document. Consider using an " + + 'aggregation pipeline with $replaceWith.'), + ('The dollar ($) prefixed ' + + "field '$rename' in '$rename' is not valid for storage."), ); done(); }, @@ -1413,9 +1493,12 @@ describe('mongodb connector', function() { function(err, updatedusers) { should.exist(err); err.name.should.equal('MongoError'); - err.errmsg.should.equal( - 'The dollar ($) prefixed ' + - "field '$rename' in '$rename' is not valid for storage.", + err.errmsg.should.equalOneOf( + ("The dollar ($) prefixed field '$rename' in '$rename' is not " + + "allowed in the context of an update's replacement document. Consider using an " + + 'aggregation pipeline with $replaceWith.'), + ('The dollar ($) prefixed ' + + "field '$rename' in '$rename' is not valid for storage."), ); done(); }, @@ -3363,15 +3446,169 @@ describe('mongodb connector', function() { hostname: 'fakeHostname', port: 9999, database: 'fakeDatabase', - username: 'fakeUsername', password: 'fakePassword', + username: 'fakeUsername', }; // mongodb+srv url should not have the port in it module.generateMongoDBURL(options).should.be.eql('mongodb+srv://fakeUsername:fakePassword@fakeHostname/fakeDatabase'); }); }); }); + describe('Test processMongoDBUrl function', function() { + const module = require('../'); + + context('should process mongodb url for default database manipulation', function() { + it('when no seetings db, but url has db, multiple hosts, user credentials and options', function() { + const url = 'mongodb://userid:password@db1.example.com:27017,db2.example.com:32667/testdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'; + const database = ''; + module.processMongoDBURL('', url).should.be.eql('mongodb://userid:password@db1.example.com:27017,db2.example.com:32667/testdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'); + }); + + it('when settings has db, url too has db, multiple hosts, user credentials and options', function() { + const url = 'mongodb://userid:password@db1.example.com:27017,db2.example.com:32667/testdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb://userid:password@db1.example.com:27017,db2.example.com:32667/testdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'); + }); + + it('when settings has db, url too has db, multiple hosts, user credentials and no options', function() { + const url = 'mongodb://userid:password@db1.example.com:27017,db2.example.com:32667/testdb'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb://userid:password@db1.example.com:27017,db2.example.com:32667/testdb'); + }); + + it("when settings has db, url doesn't have db, multiple hosts, user credentials and no options", function() { + const url = 'mongodb://userid:password@db1.example.com:27017,db2.example.com:32667'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb://userid:password@db1.example.com:27017,db2.example.com:32667/mydb'); + }); + + it("when settings has db, url doesn't have db, has slash, multiple hosts, user creds and no options", function() { + const url = 'mongodb://userid:password@db1.example.com:27017,db2.example.com:32667/'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb://userid:password@db1.example.com:27017,db2.example.com:32667/mydb'); + }); + + it('when no seetings db, but url has db, user id but no passwordm multiple hosts, and options', function() { + const url = 'mongodb://userid:@db1.example.com:27017,db2.example.com:32667/testdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('mongodb://userid:@db1.example.com:27017,db2.example.com:32667/testdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'); + }); + + it('when no seetings db, but url has db, no user credentials, multiple hosts, and options', function() { + const url = 'mongodb://db1.example.com:27017,db2.example.com:32667/testdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('mongodb://db1.example.com:27017,db2.example.com:32667/testdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'); + }); + + it('when no seetings db, url too has no db, no user credentials, multiple hosts, and options', function() { + const url = 'mongodb://db1.example.com:27017,db2.example.com:32667/?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('mongodb://db1.example.com:27017,db2.example.com:32667/?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'); + }); + + it('when seetings has db, url has no db, no user credentials, multiple hosts, and options', function() { + const url = 'mongodb://db1.example.com:27017,db2.example.com:32667/?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb://db1.example.com:27017,db2.example.com:32667/mydb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'); + }); + + it('when no seetings db, lb4 url has no db, no user credentials, single host, and no options', function() { + const url = 'loopback-connector-mongodb://localhost:27017'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('loopback-connector-mongodb://localhost:27017/'); + }); + + it('when no seetings db, lb4 srv url has no db, no user credentials, single host, and no options', function() { + const url = 'loopback-connector-mongodb+srv://localhost:27017'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('loopback-connector-mongodb+srv://localhost:27017/'); + }); + + it('when it is not mongo url', function() { + const url = 'http://localhost:27017'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('http://localhost:27017'); + }); + + it('when no seetings db, url has no db, no user credentials, single host, and no options', function() { + const url = 'mongodb://localhost:27017'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('mongodb://localhost:27017/'); + }); + + it('when seetings has db, url has no db, no user credentials, single host, and no options', function() { + const url = 'mongodb://localhost:27017'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb://localhost:27017/mydb'); + }); + + it('when no seetings db, url has no db, a slash, no user credentials, single host, and no options', function() { + const url = 'mongodb://localhost:27017/'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('mongodb://localhost:27017/'); + }); + + it('when seetings has db, url has no db, a slash, no user credentials, single host, and no options', function() { + const url = 'mongodb://localhost:27017/'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb://localhost:27017/mydb'); + }); + + it('when no seetings db, url has db, no user credentials, single host, and np options', function() { + const url = 'mongodb://localhost:27017/yourdb'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('mongodb://localhost:27017/yourdb'); + }); + + it('when seetings has db, url has db, no user credentials, single host, and no options', function() { + const url = 'mongodb://localhost:27017/yourdb'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb://localhost:27017/yourdb'); + }); + it('when no seetings db, url has db, no user credentials, single host, and options', function() { + const url = 'mongodb://localhost:27017/yourdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('mongodb://localhost:27017/yourdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'); + }); + + it('when seetings has db, url has db, no user credentials, single host, and options', function() { + const url = 'mongodb://localhost:27017/yourdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb://localhost:27017/yourdb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'); + }); + + it('when seetings has db, url has no db, no user credentials, single host, and options', function() { + const url = 'mongodb://localhost:27017/?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb://localhost:27017/mydb?authSource=admin&replicaSet=replset&readPreference=primary&ssl=true'); + }); + + it('when seetings has no db, DNSSeedList url has no db, no user creds, single host, and no options', function() { + const url = 'mongodb+srv://server.example.com/'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('mongodb+srv://server.example.com/'); + }); + + it('when seetings has db, DNSSeedList url has no db, no user creds, single host, and no options', function() { + const url = 'mongodb+srv://server.example.com/'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb+srv://server.example.com/mydb'); + }); + + it('when seetings has no db, DNSSeedList url has no db, no user creds, single host, and options', function() { + const url = 'mongodb+srv://server.example.com/?connectTimeoutMS=300000&authSource=aDifferentAuthDB'; + const database = ''; + module.processMongoDBURL(database, url).should.be.eql('mongodb+srv://server.example.com/?connectTimeoutMS=300000&authSource=aDifferentAuthDB'); + }); + + it('when seetings has db, DNSSeedList url has no db, no user creds, single host, and optionsß', function() { + const url = 'mongodb+srv://server.example.com/?connectTimeoutMS=300000&authSource=aDifferentAuthDB'; + const database = 'mydb'; + module.processMongoDBURL(database, url).should.be.eql('mongodb+srv://server.example.com/mydb?connectTimeoutMS=300000&authSource=aDifferentAuthDB'); + }); + }); + }); context('fieldsArrayToObj', function() { const fieldsArrayToObj = require('../').fieldsArrayToObj; it('should export the fieldsArrayToObj function', function() {