fix(mongodb): support replica set connection URL parsing and timeout

Frontend: add regex-based MongoDB URL parser to handle multi-host URIs
that the WHATWG URL parser rejects. Backend: separate
server_selection_timeout from connect_timeout for multi-host URIs to
prevent topology discovery from being cancelled by tokio timeout.
This commit is contained in:
t8y2 2026-06-09 01:02:11 +08:00
parent 0ce6fde347
commit 80895fe273
3 changed files with 147 additions and 13 deletions

View File

@ -79,20 +79,71 @@ export function normalizeMongoConnectionString(value: string): string {
const input = value.trim();
if (!input) return input;
let parsed: URL;
try {
parsed = new URL(input);
} catch {
return input;
const mongoMatch = input.match(/^(mongodb(?:\+srv)?):\/\/(?:(.+)@)?/i);
if (!mongoMatch) return input;
const userinfo = mongoMatch[2];
if (!userinfo) return input;
const [username, ...passwordParts] = userinfo.split(":");
const password = passwordParts.join(":");
const encodedUsername = encodeMongoUserInfoPart(username);
const encodedPassword = password ? `:${encodeMongoUserInfoPart(password)}` : "";
return input.replace(/^(mongodb(?:\+srv)?:\/\/)(?:(.+)@)?/i, `$1${encodedUsername}${encodedPassword}@`);
}
function parseMongoUrl(source: string): ParsedConnectionUrl | null {
const match = source.match(/^(mongodb(?:\+srv)?):\/\/(?:(.+)@)?([^/]+)(\/[^?]*)?(\?.*)?$/);
if (!match) return null;
const scheme = match[1].toLowerCase();
const userinfo = match[2] || "";
const hosts = match[3] || "";
const pathname = match[4] || "";
const search = match[5] || "";
const profile = SCHEME_PROFILES[scheme];
if (!profile) return null;
const [username, ...passwordParts] = decodeUrlPart(userinfo).split(":");
const password = passwordParts.join(":");
const firstHost = hosts.split(",")[0];
let host: string;
let port: number;
if (firstHost.startsWith("[")) {
const bracketEnd = firstHost.indexOf("]");
host = firstHost.substring(1, bracketEnd);
port = firstHost.substring(bracketEnd + 1).startsWith(":")
? Number(firstHost.substring(bracketEnd + 2)) || profile.defaultPort
: profile.defaultPort;
} else if (firstHost.includes(":")) {
const colonIdx = firstHost.lastIndexOf(":");
host = firstHost.substring(0, colonIdx);
port = Number(firstHost.substring(colonIdx + 1)) || profile.defaultPort;
} else {
host = firstHost;
port = profile.defaultPort;
}
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
if (scheme !== "mongodb" && scheme !== "mongodb+srv") return input;
if (!parsed.username && !parsed.password) return input;
const database = databaseFromPath(pathname);
const urlParams = search.replace(/^\?/, "");
const username = encodeMongoUserInfoPart(parsed.username);
const password = parsed.password ? `:${encodeMongoUserInfoPart(parsed.password)}` : "";
return `${parsed.protocol}//${username}${password}@${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
return {
dbType: profile.type,
driverProfile: profile.profile,
driverLabel: profile.label,
host,
port,
username,
password,
database,
urlParams,
ssl: scheme === "mongodb+srv",
connectionString: normalizeMongoConnectionString(source),
useMongoUrl: true,
};
}
function databaseFromPath(pathname: string): string | undefined {
@ -330,6 +381,9 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
const isJdbcUrl = /^jdbc:/i.test(input);
const source = isJdbcUrl ? input.replace(/^jdbc:/i, "") : input;
const mongoResult = parseMongoUrl(source);
if (mongoResult) return mongoResult;
let parsed: URL;
try {
parsed = new URL(source);

View File

@ -17,15 +17,35 @@ pub struct MongoDocumentResult {
}
pub async fn connect(url: &str, timeout: Duration) -> Result<Client, String> {
with_connection_timeout("MongoDB", timeout, async {
let is_multi_host = is_multi_host_mongo_uri(url);
let parse_timeout = if is_multi_host { std::cmp::max(timeout * 2, Duration::from_secs(10)) } else { timeout };
with_connection_timeout("MongoDB", parse_timeout, async {
let mut options = ClientOptions::parse(url).await.map_err(|e| format!("MongoDB connection failed: {e}"))?;
options.connect_timeout = Some(timeout);
options.server_selection_timeout = Some(timeout);
options.server_selection_timeout =
if is_multi_host { Some(std::cmp::max(timeout * 2, Duration::from_secs(10))) } else { Some(timeout) };
Client::with_options(options).map_err(|e| format!("MongoDB connection failed: {e}"))
})
.await
}
fn is_multi_host_mongo_uri(url: &str) -> bool {
let rest = match url.strip_prefix("mongodb://").or_else(|| url.strip_prefix("mongodb+srv://")) {
Some(r) => r,
None => return false,
};
let authority = match rest.split('/').next() {
Some(a) => a,
None => return false,
};
let host_section = match authority.rfind('@') {
Some(idx) => &authority[idx + 1..],
None => authority,
};
host_section.contains(',')
}
pub async fn test_connection(client: &Client, _timeout: Duration, database: Option<&str>) -> Result<(), String> {
let database = database.map(str::trim).filter(|value| !value.is_empty()).unwrap_or("admin");
client

View File

@ -284,6 +284,66 @@ test("parses HTTPS ClickHouse URLs with selected profile", () => {
assert.equal(parsed.ssl, true);
});
test("parses MongoDB multi-host replica set URL", () => {
const source =
"mongodb://test:test@1.1.1.1:27017,1.1.1.2:27017,1.1.1.3:27017/admin?authMechanism=SCRAM-SHA-256&authSource=admin&replicaSet=testRS0";
const parsed = parseConnectionUrl(source);
assert.equal(parsed.dbType, "mongodb");
assert.equal(parsed.driverProfile, "mongodb");
assert.equal(parsed.host, "1.1.1.1");
assert.equal(parsed.port, 27017);
assert.equal(parsed.username, "test");
assert.equal(parsed.password, "test");
assert.equal(parsed.database, "admin");
assert.equal(parsed.urlParams, "authMechanism=SCRAM-SHA-256&authSource=admin&replicaSet=testRS0");
assert.equal(parsed.connectionString, source);
assert.equal(parsed.useMongoUrl, true);
assert.equal(parsed.ssl, false);
});
test("parses MongoDB single-host URL with replicaSet and auth params", () => {
const source =
"mongodb://test:test@1.1.1.1:27017/?authMechanism=SCRAM-SHA-256&authSource=admin&replicaSet=testRS0";
const parsed = parseConnectionUrl(source);
assert.equal(parsed.dbType, "mongodb");
assert.equal(parsed.host, "1.1.1.1");
assert.equal(parsed.port, 27017);
assert.equal(parsed.username, "test");
assert.equal(parsed.password, "test");
assert.equal(parsed.urlParams, "authMechanism=SCRAM-SHA-256&authSource=admin&replicaSet=testRS0");
assert.equal(parsed.connectionString, source);
assert.equal(parsed.useMongoUrl, true);
});
test("parses MongoDB multi-host URL without credentials", () => {
const source = "mongodb://host1:27017,host2:27017/?replicaSet=rs0";
const parsed = parseConnectionUrl(source);
assert.equal(parsed.dbType, "mongodb");
assert.equal(parsed.host, "host1");
assert.equal(parsed.port, 27017);
assert.equal(parsed.username, "");
assert.equal(parsed.password, "");
assert.equal(parsed.urlParams, "replicaSet=rs0");
assert.equal(parsed.connectionString, source);
assert.equal(parsed.useMongoUrl, true);
});
test("parses MongoDB URL with simple authSource only", () => {
const source = "mongodb://test:test@1.1.1.1:27017/?authSource=admin";
const parsed = parseConnectionUrl(source);
assert.equal(parsed.dbType, "mongodb");
assert.equal(parsed.host, "1.1.1.1");
assert.equal(parsed.port, 27017);
assert.equal(parsed.username, "test");
assert.equal(parsed.password, "test");
assert.equal(parsed.urlParams, "authSource=admin");
assert.equal(parsed.useMongoUrl, true);
});
test("rejects unsupported URL schemes", () => {
assert.throws(() => parseConnectionUrl("ftp://example.com"), /Unsupported connection URL scheme/);
});