using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; using FluentResults; using Npgsql; namespace pgLabII.PgUtils.ConnectionStrings; /// /// Codec for JDBC PostgreSQL URLs: jdbc:postgresql://host1[:port1][,hostN[:portN]]/[database]?param=value&... /// - Supports multiple hosts with optional per-host ports, IPv6 bracketed literals. /// - Percent-decodes database and query param values on parse; encodes on format. /// - Recognizes sslmode/ssl, applicationName, loginTimeout/connectTimeout and maps to descriptor. /// - Preserves unrecognized parameters in Properties. /// public sealed class JdbcCodec : IConnectionStringCodec { public ConnStringFormat Format => ConnStringFormat.Jdbc; public string FormatName => "JDBC"; public Result TryParse(string input) { try { if (string.IsNullOrWhiteSpace(input)) return Result.Fail("Empty JDBC url"); var trimmed = input.Trim(); if (!trimmed.StartsWith("jdbc:postgresql://", StringComparison.OrdinalIgnoreCase)) return Result.Fail("JDBC url must start with jdbc:postgresql://"); // Strip scheme var rest = trimmed.Substring("jdbc:postgresql://".Length); // Split authority and path+query string authority = rest; string pathAndQuery = string.Empty; var slashIdx = rest.IndexOf('/'); if (slashIdx >= 0) { authority = rest.Substring(0, slashIdx); pathAndQuery = rest.Substring(slashIdx); // includes '/' } var builder = new ConnectionDescriptorBuilder(); // Parse hosts (comma separated) foreach (var part in SplitHosts(authority)) { if (string.IsNullOrWhiteSpace(part)) continue; ParseHostPort(part, out var host, out ushort? port); if (!string.IsNullOrEmpty(host)) builder.AddHost(host!, port); } // Parse database and query string? database = null; string query = string.Empty; if (!string.IsNullOrEmpty(pathAndQuery)) { int qIdx = pathAndQuery.IndexOf('?'); var path = qIdx >= 0 ? pathAndQuery.Substring(0, qIdx) : pathAndQuery; query = qIdx >= 0 ? pathAndQuery.Substring(qIdx + 1) : string.Empty; if (path.Length > 0) { if (path[0] == '/') path = path.Substring(1); if (path.Length > 0) database = Uri.UnescapeDataString(path); } } if (!string.IsNullOrEmpty(database)) builder.Database = database; var queryDict = ParseQuery(query); // Map known properties if (TryFirst(queryDict, out var ssl, "sslmode", "ssl")) builder.SslMode = ParseSslMode(ssl); if (TryFirst(queryDict, out var app, "applicationName", "application_name")) builder.ApplicationName = app; if (TryFirst(queryDict, out var tout, "loginTimeout", "connectTimeout", "connect_timeout")) { if (int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out var t)) builder.TimeoutSeconds = t; } // Preserve extras var mapped = new HashSet(new[] { "sslmode", "ssl", "applicationName", "application_name", "loginTimeout", "connectTimeout", "connect_timeout" }, StringComparer.OrdinalIgnoreCase); foreach (var kv in queryDict) { if (!mapped.Contains(kv.Key)) builder.Properties[kv.Key] = kv.Value; } return Result.Ok(builder.Build()); } catch (Exception ex) { return Result.Fail(ex.Message); } } public Result TryFormat(ConnectionDescriptor descriptor) { try { var sb = new StringBuilder(); sb.Append("jdbc:postgresql://"); if (descriptor.Hosts != null && descriptor.Hosts.Count > 0) { sb.Append(string.Join(',', descriptor.Hosts.Select(FormatHost))); } // Path with database if (!string.IsNullOrEmpty(descriptor.Database)) { sb.Append('/'); sb.Append(Uri.EscapeDataString(descriptor.Database)); } // Query parameters var qp = new List<(string k, string v)>(); if (descriptor.SslMode.HasValue) { qp.Add(("sslmode", FormatSslMode(descriptor.SslMode.Value))); } if (!string.IsNullOrEmpty(descriptor.ApplicationName)) { qp.Add(("applicationName", descriptor.ApplicationName)); } if (descriptor.TimeoutSeconds.HasValue) { qp.Add(("connectTimeout", descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture))); } // Add extras not already present var emitted = new HashSet(qp.Select(x => x.k), StringComparer.OrdinalIgnoreCase); foreach (var kv in descriptor.Properties) { if (!emitted.Contains(kv.Key)) qp.Add((kv.Key, kv.Value)); } if (qp.Count > 0) { sb.Append('?'); sb.Append(string.Join('&', qp.Select(p => Uri.EscapeDataString(p.k) + "=" + Uri.EscapeDataString(p.v ?? string.Empty)))); } return Result.Ok(sb.ToString()); } catch (Exception ex) { return Result.Fail(ex.Message); } } private static IEnumerable SplitHosts(string authority) { return authority.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } private static string FormatHost(HostEndpoint h) { var host = h.Host; if (host.Contains(':') && !host.StartsWith("[")) { // IPv6 literal must be bracketed host = "[" + host + "]"; } return h.Port.HasValue ? host + ":" + h.Port.Value.ToString(CultureInfo.InvariantCulture) : host; } private static void ParseHostPort(string hostPart, out string host, out ushort? port) { host = hostPart; port = null; if (string.IsNullOrWhiteSpace(hostPart)) return; if (hostPart[0] == '[') { int end = hostPart.IndexOf(']'); if (end > 0) { host = hostPart.Substring(1, end - 1); if (end + 1 < hostPart.Length && hostPart[end + 1] == ':') { var ps = hostPart.Substring(end + 2); if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) port = p; } } return; } int colon = hostPart.LastIndexOf(':'); if (colon > 0 && colon < hostPart.Length - 1) { var ps = hostPart.Substring(colon + 1); if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) { host = hostPart.Substring(0, colon); port = p; } } } private static Dictionary ParseQuery(string query) { var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrEmpty(query)) return dict; foreach (var kv in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) { var parts = kv.Split('=', 2); var key = Uri.UnescapeDataString(parts[0]); var val = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty; dict[key] = val; } return dict; } private static bool TryFirst(Dictionary dict, out string value, params string[] keys) { foreach (var k in keys) { if (dict.TryGetValue(k, out value)) return true; } value = string.Empty; return false; } private static SslMode ParseSslMode(string s) { switch (s.Trim().ToLowerInvariant()) { case "disable": return SslMode.Disable; case "allow": return SslMode.Allow; case "prefer": return SslMode.Prefer; case "require": return SslMode.Require; case "verify-ca": case "verifyca": return SslMode.VerifyCA; case "verify-full": case "verifyfull": return SslMode.VerifyFull; default: throw new ArgumentException($"Not a valid SSL Mode: {s}"); } } private static string FormatSslMode(SslMode mode) { return mode switch { SslMode.Disable => "disable", SslMode.Allow => "allow", SslMode.Prefer => "prefer", SslMode.Require => "require", SslMode.VerifyCA => "verify-ca", SslMode.VerifyFull => "verify-full", _ => "prefer" }; } private sealed class ConnectionDescriptorBuilder { public List Hosts { get; } = new(); public string? Database { get; set; } public string? Username { get; set; } public string? Password { get; set; } public SslMode? SslMode { get; set; } public string? ApplicationName { get; set; } public int? TimeoutSeconds { get; set; } public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase); public void AddHost(string host, ushort? port) { if (string.IsNullOrWhiteSpace(host)) return; Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port }); } public ConnectionDescriptor Build() { return new ConnectionDescriptor { Hosts = Hosts, Database = Database, Username = Username, Password = Password, SslMode = SslMode, ApplicationName = ApplicationName, TimeoutSeconds = TimeoutSeconds, Properties = Properties }; } } }