Skip to content
This repository has been archived by the owner on Feb 4, 2022. It is now read-only.

Commit

Permalink
feat(uri-parser): add initial implementation of uri parser for core
Browse files Browse the repository at this point in the history
NODE-1295
  • Loading branch information
mbroadst committed Jan 23, 2018
1 parent 01c3120 commit 8f797a7
Show file tree
Hide file tree
Showing 2 changed files with 242 additions and 0 deletions.
169 changes: 169 additions & 0 deletions lib/uri_parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
'use strict';
const URL = require('url');
const qs = require('querystring');
const punycode = require('punycode');

const HOSTS_RX = /(mongodb(?:\+srv|)):\/\/(?: (?:[^:]*) (?: : ([^@]*) )? @ )?([^/?]*)(?:\/|)(.*)/;
/*
This regular expression has the following cpature groups: [
protocol, username, password, hosts
]
*/

/**
*
* @param {*} value
*/
function parseQueryStringItemValue(value) {
if (Array.isArray(value)) {
// deduplicate and simplify arrays
value = value.filter((value, idx) => value.indexOf(value) === idx);
if (value.length === 1) value = value[0];
} else if (value.indexOf(':') > 0) {
value = value.split(',').reduce((result, pair) => {
const parts = pair.split(':');
result[parts[0]] = parseQueryStringItemValue(parts[1]);
return result;
}, {});
} else if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
value = value.toLowerCase() === 'true';
} else if (!Number.isNaN(value)) {
const numericValue = parseFloat(value);
if (!Number.isNaN(numericValue)) {
value = parseFloat(value);
}
}

return value;
}

/**
*
* @param {*} query
*/
function parseQueryString(query) {
const result = {};
let parsedQueryString = qs.parse(query);
for (const key in parsedQueryString) {
const value = parsedQueryString[key];
if (value === '' || value == null) {
return new Error('Incomplete key value pair for option');
}

result[key.toLowerCase()] = parseQueryStringItemValue(value);
}

// special cases for known deprecated options
if (result.wtimeout && result.wtimeoutms) {
delete result.wtimeout;
// TODO: emit a warning
}

return Object.keys(result).length ? result : null;
}

const SUPPORTED_PROTOCOLS = ['mongodb', 'mongodb+srv'];

/**
* Parses a MongoDB Connection string
*
* @param {*} uri the MongoDB connection string to parse
* @param {parseCallback} callback
*/
function parseConnectionString(uri, callback) {
const cap = uri.match(HOSTS_RX);
if (!cap) {
return callback(new Error('Invalid connection string'));
}

const protocol = cap[1];
if (SUPPORTED_PROTOCOLS.indexOf(protocol) === -1) {
return callback(new Error('Invalid protocol provided'));
}

const dbAndQuery = cap[4].split('?');
const db = dbAndQuery.length > 0 ? dbAndQuery[0] : null;
const query = dbAndQuery.length > 1 ? dbAndQuery[1] : null;
const options = parseQueryString(query);
if (options instanceof Error) {
return callback(options);
}

const auth = { username: null, password: null, db: db && db !== '' ? qs.unescape(db) : null };
if (cap[4].split('?')[0].indexOf('@') !== -1) {
return callback(new Error('Unescaped slash in userinfo section'));
}

const authorityParts = cap[3].split('@');
if (authorityParts.length > 2) {
return callback(new Error('Unescaped at-sign in authority section'));
}

if (authorityParts.length > 1) {
const authParts = authorityParts.shift().split(':');
if (authParts.length > 2) {
return callback(new Error('Unescaped colon in authority section'));
}

auth.username = qs.unescape(authParts[0]);
auth.password = authParts[1] ? qs.unescape(authParts[1]) : null;
}

let hostParsingError = null;
const hosts = authorityParts
.shift()
.split(',')
.map(host => {
let parsedHost = URL.parse(`mongodb://${host}`);
if (parsedHost.path === '/:') {
hostParsingError = new Error('Double colon in host identifier');
return null;
}

// heuristically determine if we're working with a domain socket
if (host.match(/\.sock/)) {
parsedHost.hostname = qs.unescape(host);
parsedHost.port = null;
}

if (Number.isNaN(parsedHost.port)) {
hostParsingError = new Error('Invalid port (non-numeric string)');
return;
}

const result = {
host: punycode.toUnicode(parsedHost.hostname),
port: parsedHost.port ? parseInt(parsedHost.port) : null
};

if (result.port === 0) {
hostParsingError = new Error('Invalid port (zero) with hostname');
return;
}

if (result.port > 65535) {
hostParsingError = new Error('Invalid port (larger than 65535) with hostname');
return;
}

if (result.port < 0) {
hostParsingError = new Error('Invalid port (negative number)');
return;
}

return result;
})
.filter(host => !!host);

if (hostParsingError) {
return callback(hostParsingError);
}

if (hosts.length === 0 || hosts[0].host === '' || hosts[0].host === null) {
return callback(new Error('No hostname or hostnames provided in connection string'));
}

callback(null, { hosts: hosts, auth: auth.db || auth.username ? auth : null, options: options });
}

module.exports = parseConnectionString;
73 changes: 73 additions & 0 deletions test/tests/unit/connection_string_spec_tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use strict';

const parseConnectionString = require('../../../lib/uri_parser'),
fs = require('fs'),
f = require('util').format,
expect = require('chai').expect;

// NOTE: These are cases we could never check for unless we write out own
// url parser. The node parser simply won't let these through, so we
// are safe skipping them.
const skipTests = [
'Invalid port (negative number) with hostname',
'Invalid port (non-numeric string) with hostname',
'Missing delimiting slash between hosts and options',

// These tests are only relevant to the native driver which
// cares about specific keys, and validating their values
'Unrecognized option keys are ignored',
'Unsupported option values are ignored'
];

describe('Connection String (spec)', function() {
const testFiles = fs
.readdirSync(f('%s/../spec/connection-string', __dirname))
.filter(x => x.indexOf('.json') !== -1)
.map(x => JSON.parse(fs.readFileSync(f('%s/../spec/connection-string/%s', __dirname, x))));

// Execute the tests
for (let i = 0; i < testFiles.length; i++) {
const testFile = testFiles[i];

// Get each test
for (let j = 0; j < testFile.tests.length; j++) {
const test = testFile.tests[j];
if (skipTests.indexOf(test.description) !== -1) {
continue;
}

it(test.description, {
metadata: { requires: { topology: 'single' } },
test: function(done) {
const valid = test.valid;

parseConnectionString(test.uri, function(err, result) {
if (valid === false) {
expect(err).to.exist;
expect(result).to.not.exist;
} else {
expect(err).to.not.exist;
expect(result).to.exist;

// remove data we don't track
if (test.auth && test.auth.password === '') {
test.auth.password = null;
}

test.hosts = test.hosts.map(host => {
delete host.type;
return host;
});

expect(result.hosts).to.eql(test.hosts);
expect(result.auth).to.eql(test.auth);
expect(result.options).to.eql(test.options);
}

done();
});
}
});
}
}
});

0 comments on commit 8f797a7

Please sign in to comment.