From f8bc0bb6564b7346fcc7dbf0dbf151c4b92ce06a Mon Sep 17 00:00:00 2001 From: David O'Sullivan Date: Wed, 12 Aug 2026 13:58:01 +0100 Subject: [PATCH 1/3] Support for multiple hosts with postgres --- .../cfenv/jdbc/PostgresqlJdbcUrlCreator.java | 4 +- .../cfenv/jdbc/PostgresqlJdbcTests.java | 25 ++++++ .../jdbc/test-postgresql-info-multi-host.json | 9 +++ .../java/io/pivotal/cfenv/core/UriInfo.java | 77 +++++++++++++++++-- .../io/pivotal/cfenv/core/UriInfoTests.java | 29 +++++++ 5 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 java-cfenv-jdbc/src/test/resources/io/pivotal/cfenv/jdbc/test-postgresql-info-multi-host.json diff --git a/java-cfenv-jdbc/src/main/java/io/pivotal/cfenv/jdbc/PostgresqlJdbcUrlCreator.java b/java-cfenv-jdbc/src/main/java/io/pivotal/cfenv/jdbc/PostgresqlJdbcUrlCreator.java index 45942d0f..7ecbbc83 100644 --- a/java-cfenv-jdbc/src/main/java/io/pivotal/cfenv/jdbc/PostgresqlJdbcUrlCreator.java +++ b/java-cfenv-jdbc/src/main/java/io/pivotal/cfenv/jdbc/PostgresqlJdbcUrlCreator.java @@ -48,8 +48,8 @@ public boolean isDatabaseService(CfService cfService) { @Override public String buildJdbcUrlFromUriField(CfCredentials cfCredentials) { UriInfo uriInfo = cfCredentials.getUriInfo(POSTGRES_JDBC_SCHEME); - return String.format("%s%s://%s%s/%s%s%s", JDBC_PREFIX, POSTGRES_JDBC_SCHEME, - uriInfo.getHost(), uriInfo.formatPort(), uriInfo.getPath(), + return String.format("%s%s://%s/%s%s%s", JDBC_PREFIX, POSTGRES_JDBC_SCHEME, + uriInfo.getHostAndPort(), uriInfo.getPath(), uriInfo.formatUserNameAndPasswordQuery(), uriInfo.formatQuery()); } diff --git a/java-cfenv-jdbc/src/test/java/io/pivotal/cfenv/jdbc/PostgresqlJdbcTests.java b/java-cfenv-jdbc/src/test/java/io/pivotal/cfenv/jdbc/PostgresqlJdbcTests.java index 829550da..4828fd41 100644 --- a/java-cfenv-jdbc/src/test/java/io/pivotal/cfenv/jdbc/PostgresqlJdbcTests.java +++ b/java-cfenv-jdbc/src/test/java/io/pivotal/cfenv/jdbc/PostgresqlJdbcTests.java @@ -126,6 +126,24 @@ public void postgresqlWithSpecialCharsServiceCreation() { passwordWithSpecialChars); } + @Test + public void postgresqlServiceCreationMultiHost() { + String name1 = "database-1"; + + mockVcapServices(getServicesPayload( + getPostgresqlServicePayloadMultiHost("postgresql-1", hostname, port, username, password, name1))); + + CfJdbcEnv cfJdbcEnv = new CfJdbcEnv(); + CfJdbcService cfJdbcService = cfJdbcEnv.findJdbcServiceByName("postgresql-1"); + + assertThat(cfJdbcService.getJdbcUrl()).isEqualTo(String.format( + "jdbc:postgresql://host1:5432,host2:5432/%s?user=%s&password=%s", name1, + UriInfo.urlEncode(username), UriInfo.urlEncode(password))); + assertThat(cfJdbcService.getUsername()).isEqualTo(username); + assertThat(cfJdbcService.getPassword()).isEqualTo(password); + assertThat(cfJdbcService.getDriverClassName()).isEqualTo("org.postgresql.Driver"); + } + @Test public void postgresqlServiceCreationNoLabelNoTags() { String name1 = "database-1"; @@ -231,6 +249,13 @@ private String getPostgresqlServicePayloadNoLabelNoTags(String serviceName, hostname, port, user, password, name); } + private String getPostgresqlServicePayloadMultiHost(String serviceName, + String hostname, int port, + String user, String password, String name) { + return getTemplatedPayload("test-postgresql-info-multi-host.json", serviceName, + hostname, port, user, password, name); + } + private String getPostgresqlServicePayloadWithJdbcUrl(String serviceName, String hostname, int port, String user, String password, String name) { diff --git a/java-cfenv-jdbc/src/test/resources/io/pivotal/cfenv/jdbc/test-postgresql-info-multi-host.json b/java-cfenv-jdbc/src/test/resources/io/pivotal/cfenv/jdbc/test-postgresql-info-multi-host.json new file mode 100644 index 00000000..68b4ab47 --- /dev/null +++ b/java-cfenv-jdbc/src/test/resources/io/pivotal/cfenv/jdbc/test-postgresql-info-multi-host.json @@ -0,0 +1,9 @@ +{ + "name": "$serviceName", + "label": "elephantsql", + "plan": "free", + "tags": [ "postgresql" ], + "credentials": { + "uri": "postgres://$user:$password@host1:5432,host2:5432/$name" + } +} diff --git a/java-cfenv/src/main/java/io/pivotal/cfenv/core/UriInfo.java b/java-cfenv/src/main/java/io/pivotal/cfenv/core/UriInfo.java index bd070bba..43a7b552 100644 --- a/java-cfenv/src/main/java/io/pivotal/cfenv/core/UriInfo.java +++ b/java-cfenv/src/main/java/io/pivotal/cfenv/core/UriInfo.java @@ -46,6 +46,8 @@ public class UriInfo { private String uriString; + private String hosts; + public UriInfo(String scheme, String host, int port, String username, String password) { this(scheme, host, port, username, password, null, null); @@ -74,14 +76,64 @@ public UriInfo(String uriString) { URI uri = getUri(); this.scheme = uri.getScheme(); - this.host = uri.getHost(); - this.port = uri.getPort(); this.path = parsePath(uri); this.query = uri.getQuery(); - String[] userinfo = parseUserinfo(uri); - this.userName = urlDecode(userinfo[0]); - this.password = urlDecode(userinfo[1]); + String authority = uri.getAuthority(); + if (uri.getHost() != null) { + this.host = uri.getHost(); + this.port = uri.getPort(); + + String[] userinfo = parseUserinfo(uri); + this.userName = urlDecode(userinfo[0]); + this.password = urlDecode(userinfo[1]); + } + else if (authority != null && authority.substring(authority.lastIndexOf('@') + 1).contains(",")) { + // A multi-host authority (e.g. postgresql's + // host1:port1,host2:port2 failover syntax) isn't valid + // server-based authority per RFC 3986, so java.net.URI parses + // it as a registry-based authority: getHost()/getPort() come + // back null/-1 even though getAuthority() has the full value. + parseAuthority(authority); + } + else { + // Some non-standard, driver-specific formats (e.g. SQL Server's + // ;property=value suffix) also fail server-based parsing but + // aren't a list of hosts either; leave host/port unset as before + // and let the caller fall back to its own parsing of uriString. + this.host = uri.getHost(); + this.port = uri.getPort(); + } + } + + private void parseAuthority(String authority) { + String hostsPart = authority; + + int at = authority.lastIndexOf('@'); + if (at != -1) { + String userInfo = authority.substring(0, at); + hostsPart = authority.substring(at + 1); + + String[] userPass = userInfo.split(":"); + if (userPass.length != 2) { + throw new IllegalArgumentException("Bad userinfo in URI: " + uriString); + } + this.userName = urlDecode(userPass[0]); + this.password = urlDecode(userPass[1]); + } + + this.hosts = hostsPart; + + String firstHost = hostsPart.split(",")[0]; + int colon = firstHost.lastIndexOf(':'); + if (colon != -1) { + this.host = firstHost.substring(0, colon); + this.port = Integer.parseInt(firstHost.substring(colon + 1)); + } + else { + this.host = firstHost; + this.port = -1; + } } public static String urlDecode(String s) { @@ -165,6 +217,21 @@ public String formatPort() { return ""; } + /** + * Returns the host(s) and port(s) as they should appear in the authority + * section of a URI. For a single-host URI this is equivalent to + * {@code getHost() + formatPort()}. For a multi-host URI (e.g. + * PostgreSQL's {@code host1:port1,host2:port2} failover syntax), the + * full, unmodified host list is returned since {@link #getHost()} and + * {@link #getPort()} only expose the first host. + */ + public String getHostAndPort() { + if (hosts != null) { + return hosts; + } + return getHost() + formatPort(); + } + public String formatQuery() { if (getQuery() != null) { if (formatUserNameAndPasswordQuery().isEmpty()) { diff --git a/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java b/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java index 9b9d1a8c..a45d5ebb 100644 --- a/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java +++ b/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java @@ -61,6 +61,35 @@ public void createWithUsernameNoPassword() { assertThatThrownBy(() -> new UriInfo(uri)).isInstanceOf(IllegalArgumentException.class); } + @Test + public void createMultiHostUri() { + String uri = "postgresql://joe:joes_password@host1:5432,host2:5432/big_db?p1=v1"; + UriInfo uriInfo = new UriInfo(uri); + + assertThat(uriInfo.getScheme()).isEqualTo("postgresql"); + assertThat(uriInfo.getHost()).isEqualTo("host1"); + assertThat(uriInfo.getPort()).isEqualTo(5432); + assertThat(uriInfo.getUsername()).isEqualTo("joe"); + assertThat(uriInfo.getPassword()).isEqualTo("joes_password"); + assertThat(uriInfo.getPath()).isEqualTo("big_db"); + assertThat(uriInfo.getQuery()).isEqualTo("p1=v1"); + assertThat(uriInfo.getHostAndPort()).isEqualTo("host1:5432,host2:5432"); + assertThat(uri).isEqualTo(uriInfo.getUriString()); + } + + @Test + public void createMultiHostUriNoUserInfo() { + String uri = "postgresql://host1:5432,host2:5432/big_db"; + UriInfo uriInfo = new UriInfo(uri); + + assertThat(uriInfo.getHostAndPort()).isEqualTo("host1:5432,host2:5432"); + assertThat(uriInfo.getHost()).isEqualTo("host1"); + assertThat(uriInfo.getPort()).isEqualTo(5432); + assertThat(uriInfo.getUsername()).isNull(); + assertThat(uriInfo.getPassword()).isNull(); + assertThat(uriInfo.getPath()).isEqualTo("big_db"); + } + @Test public void createWithExplicitParameters() { String uri = "mysql://joe:joes_password@localhost:1527/big_db"; From aced51d0128b460b029626b502335b0b97815c76 Mon Sep 17 00:00:00 2001 From: David O'Sullivan Date: Wed, 12 Aug 2026 15:23:59 +0100 Subject: [PATCH 2/3] polishing --- .../src/main/java/io/pivotal/cfenv/core/UriInfo.java | 7 +++++-- .../test/java/io/pivotal/cfenv/core/UriInfoTests.java | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/java-cfenv/src/main/java/io/pivotal/cfenv/core/UriInfo.java b/java-cfenv/src/main/java/io/pivotal/cfenv/core/UriInfo.java index 43a7b552..dbf0b027 100644 --- a/java-cfenv/src/main/java/io/pivotal/cfenv/core/UriInfo.java +++ b/java-cfenv/src/main/java/io/pivotal/cfenv/core/UriInfo.java @@ -79,7 +79,7 @@ public UriInfo(String uriString) { this.path = parsePath(uri); this.query = uri.getQuery(); - String authority = uri.getAuthority(); + String authority = uri.getRawAuthority(); if (uri.getHost() != null) { this.host = uri.getHost(); this.port = uri.getPort(); @@ -93,7 +93,10 @@ else if (authority != null && authority.substring(authority.lastIndexOf('@') + 1 // host1:port1,host2:port2 failover syntax) isn't valid // server-based authority per RFC 3986, so java.net.URI parses // it as a registry-based authority: getHost()/getPort() come - // back null/-1 even though getAuthority() has the full value. + // back null/-1 even though getRawAuthority() has the full value. + // Use the raw (not percent-decoded) form so a password + // containing an encoded ',' ':' or '@' isn't mistaken for a + // delimiter. parseAuthority(authority); } else { diff --git a/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java b/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java index a45d5ebb..1831c34a 100644 --- a/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java +++ b/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java @@ -77,6 +77,16 @@ public void createMultiHostUri() { assertThat(uri).isEqualTo(uriInfo.getUriString()); } + @Test + public void createMultiHostUriWithEncodedDelimiterInPassword() { + String uri = "postgresql://joe:pa%3Ass@host1:5432,host2:5432/big_db"; + UriInfo uriInfo = new UriInfo(uri); + + assertThat(uriInfo.getUsername()).isEqualTo("joe"); + assertThat(uriInfo.getPassword()).isEqualTo("pa:ss"); + assertThat(uriInfo.getHostAndPort()).isEqualTo("host1:5432,host2:5432"); + } + @Test public void createMultiHostUriNoUserInfo() { String uri = "postgresql://host1:5432,host2:5432/big_db"; From a6eaf8986cbb291c42fe79b7021234c9043825ce Mon Sep 17 00:00:00 2001 From: David O'Sullivan Date: Wed, 12 Aug 2026 16:29:09 +0100 Subject: [PATCH 3/3] Fix Mysql overlap case, adds support for MySql multi-host --- .../cfenv/jdbc/MySqlJdbcUrlCreator.java | 4 +-- .../io/pivotal/cfenv/jdbc/MySqlJdbcTests.java | 23 +++++++++++++++ .../jdbc/test-mysql-info-multi-host.json | 11 ++++++++ .../io/pivotal/cfenv/core/UriInfoTests.java | 28 +++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 java-cfenv-jdbc/src/test/resources/io/pivotal/cfenv/jdbc/test-mysql-info-multi-host.json diff --git a/java-cfenv-jdbc/src/main/java/io/pivotal/cfenv/jdbc/MySqlJdbcUrlCreator.java b/java-cfenv-jdbc/src/main/java/io/pivotal/cfenv/jdbc/MySqlJdbcUrlCreator.java index d0229a35..80d0b7fd 100644 --- a/java-cfenv-jdbc/src/main/java/io/pivotal/cfenv/jdbc/MySqlJdbcUrlCreator.java +++ b/java-cfenv-jdbc/src/main/java/io/pivotal/cfenv/jdbc/MySqlJdbcUrlCreator.java @@ -87,8 +87,8 @@ public String createJdbcUrl(CfService cfService) { @Override public String buildJdbcUrlFromUriField(CfCredentials cfCredentials) { UriInfo uriInfo = cfCredentials.getUriInfo(MYSQL_SCHEME); - return String.format("%s%s://%s%s/%s%s%s", JDBC_PREFIX, MYSQL_SCHEME, - uriInfo.getHost(), uriInfo.formatPort(), uriInfo.getPath(), + return String.format("%s%s://%s/%s%s%s", JDBC_PREFIX, MYSQL_SCHEME, + uriInfo.getHostAndPort(), uriInfo.getPath(), uriInfo.formatUserNameAndPasswordQuery(), uriInfo.formatQuery()); } } diff --git a/java-cfenv-jdbc/src/test/java/io/pivotal/cfenv/jdbc/MySqlJdbcTests.java b/java-cfenv-jdbc/src/test/java/io/pivotal/cfenv/jdbc/MySqlJdbcTests.java index 9c8d95ed..b25b63fb 100644 --- a/java-cfenv-jdbc/src/test/java/io/pivotal/cfenv/jdbc/MySqlJdbcTests.java +++ b/java-cfenv-jdbc/src/test/java/io/pivotal/cfenv/jdbc/MySqlJdbcTests.java @@ -56,6 +56,23 @@ public void mysqlServiceCreation() { "No unique database service matching by name [mysql.*] was found. Matching service names are [mysql-1, mysql-2]"); } + @Test + public void mysqlServiceCreationMultiHost() { + String name = "database-1"; + + mockVcapServices(getServicesPayload(getMysqlServicePayloadMultiHost("mysql-1", + hostname, port, username, password, name))); + + CfJdbcEnv cfJdbcEnv = new CfJdbcEnv(); + CfJdbcService cfJdbcService = cfJdbcEnv.findJdbcServiceByName("mysql-1"); + + assertThat(cfJdbcService.getJdbcUrl()).isEqualTo(String.format( + "jdbc:mysql://host1:3306,host2:3306/%s?user=%s&password=%s", name, + UriInfo.urlEncode(username), UriInfo.urlEncode(password))); + assertThat(cfJdbcService.getUsername()).isEqualTo(username); + assertThat(cfJdbcService.getPassword()).isEqualTo(password); + } + @Test public void mysqlServiceCreationWithLabelNoTags() { String name1 = "database-1"; @@ -343,6 +360,12 @@ private String getMysqlServicePayload(String serviceName, String hostname, int p user, password, name); } + private String getMysqlServicePayloadMultiHost(String serviceName, String hostname, + int port, String user, String password, String name) { + return getTemplatedPayload("test-mysql-info-multi-host.json", serviceName, hostname, + port, user, password, name); + } + private String getMysqlServicePayloadWithLabelNoTags(String serviceName, String hostname, int port, String user, String password, String name) { return getTemplatedPayload("test-mysql-info-with-label-no-tags.json", diff --git a/java-cfenv-jdbc/src/test/resources/io/pivotal/cfenv/jdbc/test-mysql-info-multi-host.json b/java-cfenv-jdbc/src/test/resources/io/pivotal/cfenv/jdbc/test-mysql-info-multi-host.json new file mode 100644 index 00000000..583f5ff3 --- /dev/null +++ b/java-cfenv-jdbc/src/test/resources/io/pivotal/cfenv/jdbc/test-mysql-info-multi-host.json @@ -0,0 +1,11 @@ +{ + "name": "$serviceName", + "label": "cleardb", + "tags": [ + "mysql" + ], + "plan": "free", + "credentials": { + "uri": "mysql://$user:$password@host1:3306,host2:3306/$name" + } +} diff --git a/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java b/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java index 1831c34a..bde1b5ba 100644 --- a/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java +++ b/java-cfenv/src/test/java/io/pivotal/cfenv/core/UriInfoTests.java @@ -77,6 +77,34 @@ public void createMultiHostUri() { assertThat(uri).isEqualTo(uriInfo.getUriString()); } + @Test + public void createMultiHostUriNonPostgresScheme() { + // The multi-host authority format isn't postgresql-specific; mysql and + // mariadb use the same host1:port1,host2:port2 syntax for failover. + String uri = "mysql://joe:joes_password@host1:3306,host2:3306/big_db"; + UriInfo uriInfo = new UriInfo(uri); + + assertThat(uriInfo.getScheme()).isEqualTo("mysql"); + assertThat(uriInfo.getHostAndPort()).isEqualTo("host1:3306,host2:3306"); + assertThat(uriInfo.getHost()).isEqualTo("host1"); + assertThat(uriInfo.getPort()).isEqualTo(3306); + assertThat(uriInfo.getUsername()).isEqualTo("joe"); + assertThat(uriInfo.getPassword()).isEqualTo("joes_password"); + assertThat(uriInfo.getPath()).isEqualTo("big_db"); + } + + @Test + public void createSingleHostUriIsUnaffectedByMultiHostParsing() { + // A standard single-host authority must keep using java.net.URI's + // server-based parsing, never the comma-detection fallback. + String uri = "mysql://joe:joes_password@localhost:1527/big_db"; + UriInfo uriInfo = new UriInfo(uri); + + assertUriInfoEquals(uriInfo, "localhost", 1527, "joe", "joes_password", "big_db", + null); + assertThat(uriInfo.getHostAndPort()).isEqualTo("localhost:1527"); + } + @Test public void createMultiHostUriWithEncodedDelimiterInPassword() { String uri = "postgresql://joe:pa%3Ass@host1:5432,host2:5432/big_db";