using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; using FluentResults; 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 (string part in CodecCommon.SplitHosts(authority)) { CodecCommon.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('?'); string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery; query = qIdx >= 0 ? pathAndQuery[(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 = CodecCommon.ParseQuery(query); // Map known properties if (TryFirst(queryDict, out string? ssl, "sslmode", "ssl")) builder.SslMode = CodecCommon.ParseSslModeLoose(ssl); if (TryFirst(queryDict, out string? app, "applicationName", "application_name")) builder.ApplicationName = app; if (TryFirst(queryDict, out string? tout, "loginTimeout", "connectTimeout", "connect_timeout")) { if (int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out int t)) builder.TimeoutSeconds = t; } // Preserve extras var mapped = new HashSet(["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.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", CodecCommon.FormatSslModeUrlLike(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 string FormatHost(HostEndpoint h) => CodecCommon.FormatHost(h); private static bool TryFirst( Dictionary dict, [MaybeNullWhen(false)] out string value, params string[] keys) { foreach (string k in keys) { if (dict.TryGetValue(k, out value)) return true; } value = string.Empty; return false; } }