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:
parent
0ce6fde347
commit
80895fe273
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue