♻️ refactor(redis): 抽离 Redis 连接 URI 与拓扑装配逻辑

This commit is contained in:
Syngnat
2026-06-12 09:43:52 +08:00
parent 3d91079020
commit 8eb4bf3954
3 changed files with 556 additions and 165 deletions

View File

@@ -63,6 +63,11 @@ import { resolveConnectionSecretDraft } from "../utils/connectionSecretDraft";
import { getCustomConnectionDsnValidationMessage } from "../utils/customConnectionDsn";
import { mergeParsedUriValuesForForm } from "../utils/connectionUriMerge";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import {
buildRedisUriFromValues,
parseRedisUriToFormValues,
resolveRedisConfigDraft,
} from "../utils/redisConnectionUri";
import {
CONNECTION_TYPE_GROUPS,
getAllConnectionTypeCatalogItems,
@@ -1484,85 +1489,7 @@ const ConnectionModal: React.FC<{
}
if (type === "redis") {
const parsed =
parseMultiHostUri(trimmedUri, "redis") ||
parseMultiHostUri(trimmedUri, "rediss");
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const topologyParam = String(
parsed.params.get("topology") || "",
).toLowerCase();
const isSentinelTopology = topologyParam === "sentinel";
const redisNodeDefaultPort = isSentinelTopology ? 26379 : 6379;
const hostList = normalizeAddressList(parsed.hosts, redisNodeDefaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(
hostList[0] || `localhost:${redisNodeDefaultPort}`,
redisNodeDefaultPort,
);
const dbText = String(parsed.database || "")
.trim()
.replace(/^\//, "");
const dbIndex = Number(dbText);
const isRediss = trimmedUri.toLowerCase().startsWith("rediss://");
const skipVerifyText = String(parsed.params.get("skip_verify") || "")
.trim()
.toLowerCase();
const skipVerify =
skipVerifyText === "1" ||
skipVerifyText === "true" ||
skipVerifyText === "yes" ||
skipVerifyText === "on";
return {
host: primary?.host || "localhost",
port: primary?.port || redisNodeDefaultPort,
user: parsed.username || "",
password: parsed.password || "",
useSSL: isRediss,
sslMode: isRediss
? skipVerify
? "skip-verify"
: "required"
: "disable",
...extractSSLPathValuesFromParams(parsed.params, type),
redisTopology: isSentinelTopology
? "sentinel"
: hostList.length > 1 || topologyParam === "cluster"
? "cluster"
: "single",
redisHosts: hostList.slice(1),
redisSentinelMaster: isSentinelTopology
? String(
parsed.params.get("master") ||
parsed.params.get("master_name") ||
parsed.params.get("sentinel_master") ||
"",
).trim()
: "",
redisSentinelUser: isSentinelTopology
? String(
parsed.params.get("sentinel_user") ||
parsed.params.get("sentinel_username") ||
"",
).trim()
: "",
redisSentinelPassword: isSentinelTopology
? String(parsed.params.get("sentinel_password") || "")
: "",
redisDB:
Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15
? Math.trunc(dbIndex)
: 0,
};
return parseRedisUriToFormValues(trimmedUri);
}
if (type === "mongodb") {
@@ -1976,62 +1903,7 @@ const ConnectionModal: React.FC<{
}
if (type === "redis") {
const redisTopology = String(values.redisTopology || "single");
const redisNodeDefaultPort = redisTopology === "sentinel" ? 26379 : 6379;
const primary = toAddress(host, port, redisNodeDefaultPort);
const extraRedisHosts =
redisTopology === "cluster" || redisTopology === "sentinel"
? normalizeAddressList(values.redisHosts, redisNodeDefaultPort)
: [];
const hosts = normalizeAddressList(
[primary, ...extraRedisHosts],
redisNodeDefaultPort,
);
const params = new URLSearchParams();
if (redisTopology === "sentinel") {
params.set("topology", "sentinel");
const sentinelMaster = String(values.redisSentinelMaster || "").trim();
if (sentinelMaster) {
params.set("master", sentinelMaster);
}
const sentinelUser = String(values.redisSentinelUser || "").trim();
if (sentinelUser) {
params.set("sentinel_user", sentinelUser);
}
const sentinelPassword = String(values.redisSentinelPassword || "");
if (sentinelPassword) {
params.set("sentinel_password", sentinelPassword);
}
} else if (hosts.length > 1 || redisTopology === "cluster") {
params.set("topology", "cluster");
}
const redisUser = String(values.user || "").trim();
const redisPassword = String(values.password || "");
let redisAuth = "";
if (redisUser || redisPassword) {
const encodedPassword = redisPassword
? encodeURIComponent(redisPassword)
: "";
redisAuth = redisUser
? `${encodeURIComponent(redisUser)}${redisPassword ? `:${encodedPassword}` : ""}@`
: `:${encodedPassword}@`;
}
const redisDB = Number.isFinite(Number(values.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(values.redisDB))))
: 0;
const dbPath = `/${redisDB}`;
if (values.useSSL) {
const mode = String(values.sslMode || "preferred")
.trim()
.toLowerCase();
if (mode === "skip-verify" || mode === "preferred") {
params.set("skip_verify", "true");
}
}
appendSSLPathParamsForUri(params, type, values);
const query = params.toString();
const scheme = values.useSSL ? "rediss" : "redis";
return `${scheme}://${redisAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`;
return buildRedisUriFromValues(values);
}
if (isFileDatabaseType(type)) {
@@ -3426,37 +3298,19 @@ const ConnectionModal: React.FC<{
}
if (type === "redis") {
const redisTopology = String(mergedValues.redisTopology || "single");
const redisNodeDefaultPort = redisTopology === "sentinel" ? 26379 : defaultPort;
if (
redisTopology === "sentinel" &&
(!Number(mergedValues.port) || Number(mergedValues.port) === defaultPort)
) {
primaryPort = redisNodeDefaultPort;
}
const extraRedisNodes =
redisTopology === "cluster" || redisTopology === "sentinel"
? normalizeAddressList(mergedValues.redisHosts, redisNodeDefaultPort)
: [];
const allHosts = normalizeAddressList(
[`${primaryHost}:${primaryPort}`, ...extraRedisNodes],
redisNodeDefaultPort,
const redisDraft = resolveRedisConfigDraft(
mergedValues,
primaryHost,
primaryPort,
defaultPort,
);
if (redisTopology === "sentinel") {
hosts = allHosts;
topology = "sentinel";
redisSentinelMaster = String(mergedValues.redisSentinelMaster || "").trim();
redisSentinelUser = String(mergedValues.redisSentinelUser || "").trim();
redisSentinelPassword = String(mergedValues.redisSentinelPassword || "");
} else if (redisTopology === "cluster" || allHosts.length > 1) {
hosts = allHosts;
topology = "cluster";
} else {
topology = "single";
}
mergedValues.redisDB = Number.isFinite(Number(mergedValues.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
: 0;
primaryPort = redisDraft.primaryPort;
hosts = redisDraft.hosts;
topology = redisDraft.topology;
redisSentinelMaster = redisDraft.redisSentinelMaster;
redisSentinelUser = redisDraft.redisSentinelUser;
redisSentinelPassword = redisDraft.redisSentinelPassword;
mergedValues.redisDB = redisDraft.redisDB;
}
const sshConfig = mergedValues.useSSH

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
buildRedisUriFromValues,
parseRedisUriToFormValues,
resolveRedisConfigDraft,
} from './redisConnectionUri';
describe('redisConnectionUri', () => {
it('parses Redis Sentinel URI into form values without dropping topology fields', () => {
const result = parseRedisUriToFormValues(
'rediss://default:redis%40secret@sentinel-a.local:26379,sentinel-b.local/3?topology=sentinel&master=mymaster&sentinel_user=ops&sentinel_password=s%40p&skip_verify=true&sslCAPath=C%3A%2Fcerts%2Fca.pem',
);
expect(result).toMatchObject({
host: 'sentinel-a.local',
port: 26379,
user: 'default',
password: 'redis@secret',
useSSL: true,
sslMode: 'skip-verify',
sslCAPath: 'C:/certs/ca.pem',
redisTopology: 'sentinel',
redisHosts: ['sentinel-b.local:26379'],
redisSentinelMaster: 'mymaster',
redisSentinelUser: 'ops',
redisSentinelPassword: 's@p',
redisDB: 3,
});
});
it('builds Redis Sentinel URI with Sentinel credentials separated from Redis auth', () => {
expect(buildRedisUriFromValues({
host: 'sentinel-a.local',
port: 26379,
redisHosts: ['sentinel-b.local', 'sentinel-b.local:26379'],
redisTopology: 'sentinel',
user: 'default',
password: 'redis secret',
redisSentinelMaster: 'mymaster',
redisSentinelUser: 'sentinel-user',
redisSentinelPassword: 'sentinel secret',
redisDB: 6,
useSSL: true,
sslMode: 'required',
sslCAPath: 'C:/certs/ca.pem',
})).toBe(
'rediss://default:redis%20secret@sentinel-a.local:26379,sentinel-b.local:26379/6?topology=sentinel&master=mymaster&sentinel_user=sentinel-user&sentinel_password=sentinel+secret&sslCAPath=C%3A%2Fcerts%2Fca.pem',
);
});
it('resolves Redis config draft for cluster and Sentinel save payloads', () => {
expect(resolveRedisConfigDraft({
redisTopology: 'cluster',
redisHosts: ['redis-b.local', 'redis-c.local:6380'],
redisDB: 2,
}, 'redis-a.local', 6379, 6379)).toEqual({
primaryPort: 6379,
hosts: ['redis-a.local:6379', 'redis-b.local:6379', 'redis-c.local:6380'],
topology: 'cluster',
redisSentinelMaster: '',
redisSentinelUser: '',
redisSentinelPassword: '',
redisDB: 2,
});
expect(resolveRedisConfigDraft({
redisTopology: 'sentinel',
port: 6379,
redisHosts: ['sentinel-b.local'],
redisSentinelMaster: 'mymaster',
redisSentinelUser: 'ops',
redisSentinelPassword: 'sentinel-pass',
redisDB: 99,
}, 'sentinel-a.local', 6379, 6379)).toEqual({
primaryPort: 26379,
hosts: ['sentinel-a.local:26379', 'sentinel-b.local:26379'],
topology: 'sentinel',
redisSentinelMaster: 'mymaster',
redisSentinelUser: 'ops',
redisSentinelPassword: 'sentinel-pass',
redisDB: 0,
});
});
});

View File

@@ -0,0 +1,452 @@
import type { ConnectionConfig } from '../types';
export type RedisTopology = Extract<
NonNullable<ConnectionConfig['topology']>,
'single' | 'cluster' | 'sentinel'
>;
export interface RedisUriFormValues {
host: string;
port: number;
user: string;
password: string;
useSSL: boolean;
sslMode: 'required' | 'skip-verify' | 'disable';
sslCAPath?: string;
sslCertPath?: string;
sslKeyPath?: string;
redisTopology: RedisTopology;
redisHosts: string[];
redisSentinelMaster: string;
redisSentinelUser: string;
redisSentinelPassword: string;
redisDB: number;
}
export interface RedisConfigDraft {
primaryPort: number;
hosts: string[];
topology: RedisTopology;
redisSentinelMaster: string;
redisSentinelUser: string;
redisSentinelPassword: string;
redisDB: number;
}
const REDIS_DEFAULT_PORT = 6379;
const REDIS_SENTINEL_DEFAULT_PORT = 26379;
const MAX_URI_HOSTS = 32;
const parseHostPort = (
raw: string,
defaultPort: number,
): { host: string; port: number } | null => {
const text = String(raw || '').trim();
if (!text) {
return null;
}
if (text.startsWith('[')) {
const closingBracket = text.indexOf(']');
if (closingBracket > 0) {
const host = text.slice(1, closingBracket).trim();
const portText = text
.slice(closingBracket + 1)
.trim()
.replace(/^:/, '');
const parsedPort = Number(portText);
return {
host: host || 'localhost',
port:
Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535
? parsedPort
: defaultPort,
};
}
}
const colonCount = (text.match(/:/g) || []).length;
if (colonCount === 1) {
const splitIndex = text.lastIndexOf(':');
const host = text.slice(0, splitIndex).trim();
const portText = text.slice(splitIndex + 1).trim();
const parsedPort = Number(portText);
return {
host: host || 'localhost',
port:
Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535
? parsedPort
: defaultPort,
};
}
return { host: text, port: defaultPort };
};
const toAddress = (host: string, port: number, defaultPort: number) => {
const safeHost = String(host || '').trim() || 'localhost';
const safePort =
Number.isFinite(Number(port)) && Number(port) > 0
? Number(port)
: defaultPort;
return `${safeHost}:${safePort}`;
};
const normalizeAddressList = (
rawList: unknown,
defaultPort: number,
): string[] => {
const list = Array.isArray(rawList) ? rawList : [];
const seen = new Set<string>();
const result: string[] = [];
list.forEach((entry) => {
const parsed = parseHostPort(String(entry || ''), defaultPort);
if (!parsed) {
return;
}
const normalized = toAddress(parsed.host, parsed.port, defaultPort);
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
result.push(normalized);
});
return result;
};
const isValidUriHostEntry = (entry: string): boolean => {
const text = String(entry || '').trim();
if (!text) return false;
if (text.length > 255) return false;
return !/[()\\/\s]/.test(text);
};
const safeDecode = (text: string) => {
try {
return decodeURIComponent(text);
} catch {
return text;
}
};
const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
const prefix = `${expectedScheme}://`;
if (!uriText.toLowerCase().startsWith(prefix)) {
return null;
}
let rest = uriText.slice(prefix.length);
const hashIndex = rest.indexOf('#');
if (hashIndex >= 0) {
rest = rest.slice(0, hashIndex);
}
let queryText = '';
const queryIndex = rest.indexOf('?');
if (queryIndex >= 0) {
queryText = rest.slice(queryIndex + 1);
rest = rest.slice(0, queryIndex);
}
let pathText = '';
const slashIndex = rest.indexOf('/');
if (slashIndex >= 0) {
pathText = rest.slice(slashIndex + 1);
rest = rest.slice(0, slashIndex);
}
let hostText = rest;
let username = '';
let password = '';
const atIndex = rest.lastIndexOf('@');
if (atIndex >= 0) {
const userInfo = rest.slice(0, atIndex);
hostText = rest.slice(atIndex + 1);
const colonIndex = userInfo.indexOf(':');
if (colonIndex >= 0) {
username = safeDecode(userInfo.slice(0, colonIndex));
password = safeDecode(userInfo.slice(colonIndex + 1));
} else {
username = safeDecode(userInfo);
}
}
const hosts = hostText
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return {
username,
password,
hosts,
database: safeDecode(pathText),
params: new URLSearchParams(queryText),
};
};
const firstConnectionParamValue = (
params: URLSearchParams,
names: string[],
): string => {
for (const name of names) {
const value = String(params.get(name) || '').trim();
if (value) return value;
}
return '';
};
const extractRedisSSLPathValuesFromParams = (
params: URLSearchParams,
): Pick<RedisUriFormValues, 'sslCAPath' | 'sslCertPath' | 'sslKeyPath'> => {
const caPath = firstConnectionParamValue(params, [
'sslCAPath',
'ssl_ca_path',
'sslrootcert',
'sslRootCert',
'tlsCAFile',
'caFile',
'certificate',
'servercertificate',
'serverCertificate',
]);
const certPath = firstConnectionParamValue(params, [
'sslCertPath',
'ssl_cert_path',
'SSL_CERT_PATH',
'sslcert',
'sslCert',
'tlsCertificateFile',
]);
const keyPath = firstConnectionParamValue(params, [
'sslKeyPath',
'ssl_key_path',
'SSL_KEY_PATH',
'sslkey',
'sslKey',
'tlsKeyFile',
]);
return {
...(caPath ? { sslCAPath: caPath } : {}),
...(certPath ? { sslCertPath: certPath } : {}),
...(keyPath ? { sslKeyPath: keyPath } : {}),
};
};
const appendRedisSSLPathParamsForUri = (
params: URLSearchParams,
values: Record<string, any>,
) => {
const caPath = String(values.sslCAPath || '').trim();
const certPath = String(values.sslCertPath || '').trim();
const keyPath = String(values.sslKeyPath || '').trim();
if (caPath) {
params.set('sslCAPath', caPath);
}
if (certPath) {
params.set('sslCertPath', certPath);
}
if (keyPath) {
params.set('sslKeyPath', keyPath);
}
};
const normalizeRedisDB = (value: unknown): number => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 15
? Math.trunc(parsed)
: 0;
};
const normalizeRedisTopology = (value: unknown): RedisTopology => {
const text = String(value || '').trim().toLowerCase();
if (text === 'cluster' || text === 'sentinel') {
return text;
}
return 'single';
};
export const parseRedisUriToFormValues = (
uriText: string,
): RedisUriFormValues | null => {
const trimmedUri = String(uriText || '').trim();
const parsed =
parseMultiHostUri(trimmedUri, 'redis') ||
parseMultiHostUri(trimmedUri, 'rediss');
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const topologyParam = String(parsed.params.get('topology') || '').toLowerCase();
const isSentinelTopology = topologyParam === 'sentinel';
const redisNodeDefaultPort = isSentinelTopology
? REDIS_SENTINEL_DEFAULT_PORT
: REDIS_DEFAULT_PORT;
const hostList = normalizeAddressList(parsed.hosts, redisNodeDefaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(
hostList[0] || `localhost:${redisNodeDefaultPort}`,
redisNodeDefaultPort,
);
const dbText = String(parsed.database || '')
.trim()
.replace(/^\//, '');
const isRediss = trimmedUri.toLowerCase().startsWith('rediss://');
const skipVerifyText = String(parsed.params.get('skip_verify') || '')
.trim()
.toLowerCase();
const skipVerify =
skipVerifyText === '1' ||
skipVerifyText === 'true' ||
skipVerifyText === 'yes' ||
skipVerifyText === 'on';
return {
host: primary?.host || 'localhost',
port: primary?.port || redisNodeDefaultPort,
user: parsed.username || '',
password: parsed.password || '',
useSSL: isRediss,
sslMode: isRediss ? (skipVerify ? 'skip-verify' : 'required') : 'disable',
...extractRedisSSLPathValuesFromParams(parsed.params),
redisTopology: isSentinelTopology
? 'sentinel'
: hostList.length > 1 || topologyParam === 'cluster'
? 'cluster'
: 'single',
redisHosts: hostList.slice(1),
redisSentinelMaster: isSentinelTopology
? String(
parsed.params.get('master') ||
parsed.params.get('master_name') ||
parsed.params.get('sentinel_master') ||
'',
).trim()
: '',
redisSentinelUser: isSentinelTopology
? String(
parsed.params.get('sentinel_user') ||
parsed.params.get('sentinel_username') ||
'',
).trim()
: '',
redisSentinelPassword: isSentinelTopology
? String(parsed.params.get('sentinel_password') || '')
: '',
redisDB: normalizeRedisDB(dbText),
};
};
export const buildRedisUriFromValues = (values: Record<string, any>): string => {
const redisTopology = normalizeRedisTopology(values.redisTopology);
const redisNodeDefaultPort =
redisTopology === 'sentinel'
? REDIS_SENTINEL_DEFAULT_PORT
: REDIS_DEFAULT_PORT;
const primary = toAddress(
String(values.host || '').trim() || 'localhost',
Number(values.port || redisNodeDefaultPort),
redisNodeDefaultPort,
);
const extraRedisHosts =
redisTopology === 'cluster' || redisTopology === 'sentinel'
? normalizeAddressList(values.redisHosts, redisNodeDefaultPort)
: [];
const hosts = normalizeAddressList(
[primary, ...extraRedisHosts],
redisNodeDefaultPort,
);
const params = new URLSearchParams();
if (redisTopology === 'sentinel') {
params.set('topology', 'sentinel');
const sentinelMaster = String(values.redisSentinelMaster || '').trim();
if (sentinelMaster) {
params.set('master', sentinelMaster);
}
const sentinelUser = String(values.redisSentinelUser || '').trim();
if (sentinelUser) {
params.set('sentinel_user', sentinelUser);
}
const sentinelPassword = String(values.redisSentinelPassword || '');
if (sentinelPassword) {
params.set('sentinel_password', sentinelPassword);
}
} else if (hosts.length > 1 || redisTopology === 'cluster') {
params.set('topology', 'cluster');
}
const redisUser = String(values.user || '').trim();
const redisPassword = String(values.password || '');
let redisAuth = '';
if (redisUser || redisPassword) {
const encodedPassword = redisPassword
? encodeURIComponent(redisPassword)
: '';
redisAuth = redisUser
? `${encodeURIComponent(redisUser)}${redisPassword ? `:${encodedPassword}` : ''}@`
: `:${encodedPassword}@`;
}
const redisDB = normalizeRedisDB(values.redisDB);
if (values.useSSL) {
const mode = String(values.sslMode || 'preferred')
.trim()
.toLowerCase();
if (mode === 'skip-verify' || mode === 'preferred') {
params.set('skip_verify', 'true');
}
}
appendRedisSSLPathParamsForUri(params, values);
const query = params.toString();
const scheme = values.useSSL ? 'rediss' : 'redis';
return `${scheme}://${redisAuth}${hosts.join(',')}/${redisDB}${query ? `?${query}` : ''}`;
};
export const resolveRedisConfigDraft = (
values: Record<string, any>,
primaryHost: string,
primaryPort: number,
defaultPort: number,
): RedisConfigDraft => {
const redisTopology = normalizeRedisTopology(values.redisTopology);
const redisNodeDefaultPort =
redisTopology === 'sentinel'
? REDIS_SENTINEL_DEFAULT_PORT
: defaultPort;
const normalizedPrimaryPort =
redisTopology === 'sentinel' &&
(!Number(values.port) || Number(values.port) === defaultPort)
? redisNodeDefaultPort
: primaryPort;
const extraRedisNodes =
redisTopology === 'cluster' || redisTopology === 'sentinel'
? normalizeAddressList(values.redisHosts, redisNodeDefaultPort)
: [];
const allHosts = normalizeAddressList(
[`${primaryHost}:${normalizedPrimaryPort}`, ...extraRedisNodes],
redisNodeDefaultPort,
);
if (redisTopology === 'sentinel') {
return {
primaryPort: normalizedPrimaryPort,
hosts: allHosts,
topology: 'sentinel',
redisSentinelMaster: String(values.redisSentinelMaster || '').trim(),
redisSentinelUser: String(values.redisSentinelUser || '').trim(),
redisSentinelPassword: String(values.redisSentinelPassword || ''),
redisDB: normalizeRedisDB(values.redisDB),
};
}
return {
primaryPort: normalizedPrimaryPort,
hosts: redisTopology === 'cluster' || allHosts.length > 1 ? allHosts : [],
topology: redisTopology === 'cluster' || allHosts.length > 1 ? 'cluster' : 'single',
redisSentinelMaster: '',
redisSentinelUser: '',
redisSentinelPassword: '',
redisDB: normalizeRedisDB(values.redisDB),
};
};