From 0646a4d55e8793cf6cf987e2c8d657110cad59e9 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Fri, 20 Feb 2026 09:30:16 +0100 Subject: [PATCH] fix(java): support Cloud SQL socket DATABASE_URL and Cloud Run refresh --- .../openapitools/config/DataSourceConfig.java | 191 +++++++++++++++++- .../config/DataSourceConfigTest.java | 67 ++++++ 2 files changed, 253 insertions(+), 5 deletions(-) diff --git a/src/java/src/main/java/org/openapitools/config/DataSourceConfig.java b/src/java/src/main/java/org/openapitools/config/DataSourceConfig.java index 632339b6..e9e58862 100644 --- a/src/java/src/main/java/org/openapitools/config/DataSourceConfig.java +++ b/src/java/src/main/java/org/openapitools/config/DataSourceConfig.java @@ -2,6 +2,11 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashSet; +import java.util.Set; import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -20,6 +25,7 @@ */ @Configuration @Conditional(OnDatabaseUrlCondition.class) +@SuppressWarnings("PMD.GodClass") public class DataSourceConfig { @Value("${SPRING_DATASOURCE_URL:}") @@ -40,6 +46,12 @@ public class DataSourceConfig { @Value("${spring.datasource.driver-class-name:org.postgresql.Driver}") private String driverClassName; + @Value("${K_SERVICE:}") + private String cloudRunService; + + @Value("${K_REVISION:}") + private String cloudRunRevision; + /** * Creates a HikariConfig bean configured from application.properties. * @@ -49,12 +61,14 @@ public class DataSourceConfig { @ConfigurationProperties(prefix = "spring.datasource.hikari") public HikariConfig hikariConfig() { HikariConfig config = new HikariConfig(); + String jdbcUrl = resolveJdbcUrl(); // Set core JDBC properties - config.setJdbcUrl(resolveJdbcUrl()); + config.setJdbcUrl(jdbcUrl); config.setUsername(username); config.setPassword(password); config.setDriverClassName(driverClassName); + configureCloudSqlProperties(config, jdbcUrl); return config; } @@ -72,19 +86,186 @@ private String resolveJdbcUrl() { } private String normalizeDatabaseUrl(String url) { - if (url.startsWith("postgresql://")) { - return "jdbc:" + url; + if (url.startsWith("jdbc:postgresql://")) { + return url; } - if (url.startsWith("postgres://")) { - return "jdbc:postgresql://" + url.substring("postgres://".length()); + if (url.startsWith("postgresql://") || url.startsWith("postgres://")) { + return toJdbcPostgresUrl(url); } + return url; } + private String toJdbcPostgresUrl(String url) { + String normalized = + url.startsWith("postgres://") + ? "postgresql://" + url.substring("postgres://".length()) + : url; + URI uri = URI.create(normalized); + + String host = isNotBlank(uri.getHost()) ? uri.getHost() : "localhost"; + + StringBuilder jdbcUrl = new StringBuilder("jdbc:postgresql://"); + jdbcUrl.append(host); + + if (uri.getPort() != -1) { + jdbcUrl.append(':').append(uri.getPort()); + } + + if (isNotBlank(uri.getRawPath())) { + jdbcUrl.append(uri.getRawPath()); + } else { + jdbcUrl.append('/'); + } + + String query = + appendCredentialsIfNeeded(uri.getRawQuery(), extractRawUserInfo(uri, normalized)); + if (isNotBlank(query)) { + jdbcUrl.append('?').append(query); + } + + return jdbcUrl.toString(); + } + + @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) + private String appendCredentialsIfNeeded(String rawQuery, String rawUserInfo) { + if (!isNotBlank(rawUserInfo)) { + return rawQuery; + } + + Set existingKeys = queryKeys(rawQuery); + String[] userInfoParts = rawUserInfo.split(":", 2); + String user = userInfoParts.length > 0 ? userInfoParts[0] : ""; + String password = userInfoParts.length > 1 ? userInfoParts[1] : ""; + + StringBuilder query = new StringBuilder(rawQuery == null ? "" : rawQuery); + if (!existingKeys.contains("user") && isNotBlank(user)) { + appendQueryParam(query, "user", user); + } + if (!existingKeys.contains("password") && isNotBlank(password)) { + appendQueryParam(query, "password", password); + } + + return query.length() == 0 ? null : query.toString(); + } + + private String extractRawUserInfo(URI uri, String rawUrl) { + if (isNotBlank(uri.getRawUserInfo())) { + return uri.getRawUserInfo(); + } + + int schemeSeparator = rawUrl.indexOf("://"); + int authorityStart = schemeSeparator >= 0 ? schemeSeparator + 3 : 0; + int pathStart = rawUrl.indexOf('/', authorityStart); + int userInfoEnd = rawUrl.indexOf('@', authorityStart); + + if (userInfoEnd > authorityStart && (pathStart == -1 || userInfoEnd < pathStart)) { + return rawUrl.substring(authorityStart, userInfoEnd); + } + + return null; + } + + private Set queryKeys(String rawQuery) { + Set keys = new LinkedHashSet<>(); + if (!isNotBlank(rawQuery)) { + return keys; + } + + for (String part : rawQuery.split("&")) { + if (!isNotBlank(part)) { + continue; + } + + int separator = part.indexOf('='); + String key = separator >= 0 ? part.substring(0, separator) : part; + if (isNotBlank(key)) { + keys.add(key); + } + } + + return keys; + } + + private void appendQueryParam(StringBuilder query, String key, String value) { + if (query.length() > 0) { + query.append('&'); + } + query.append(key).append('=').append(value); + } + private boolean isNotBlank(String value) { return value != null && !value.isBlank(); } + private void configureCloudSqlProperties(HikariConfig config, String jdbcUrl) { + String instanceUnixSocket = extractUnixSocketPath(jdbcUrl); + if (!isNotBlank(instanceUnixSocket)) { + return; + } + + config.addDataSourceProperty("socketFactory", "com.google.cloud.sql.postgres.SocketFactory"); + config.addDataSourceProperty("unixSocketPath", instanceUnixSocket); + + if (isCloudRun()) { + config.addDataSourceProperty("cloudSqlRefreshStrategy", "lazy"); + } + } + + private String extractUnixSocketPath(String jdbcUrl) { + String rawQuery = extractRawQuery(jdbcUrl); + if (!isNotBlank(rawQuery)) { + return null; + } + + String host = extractQueryParam(rawQuery, "host"); + if (isNotBlank(host) && decodeQueryValue(host).startsWith("/cloudsql/")) { + return decodeQueryValue(host); + } + + String unixSocketPath = extractQueryParam(rawQuery, "unixSocketPath"); + if (isNotBlank(unixSocketPath)) { + return decodeQueryValue(unixSocketPath); + } + + return null; + } + + private String extractRawQuery(String jdbcUrl) { + int queryStart = jdbcUrl.indexOf('?'); + if (queryStart < 0 || queryStart + 1 >= jdbcUrl.length()) { + return null; + } + return jdbcUrl.substring(queryStart + 1); + } + + private String extractQueryParam(String rawQuery, String key) { + for (String part : rawQuery.split("&")) { + if (!isNotBlank(part)) { + continue; + } + + int separator = part.indexOf('='); + if (separator < 0) { + continue; + } + + String partKey = part.substring(0, separator); + if (key.equals(partKey)) { + return part.substring(separator + 1); + } + } + return null; + } + + private String decodeQueryValue(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + private boolean isCloudRun() { + return isNotBlank(cloudRunService) || isNotBlank(cloudRunRevision); + } + /** * Creates a HikariCP DataSource bean from the configured HikariConfig. * diff --git a/src/java/src/test/java/org/openapitools/config/DataSourceConfigTest.java b/src/java/src/test/java/org/openapitools/config/DataSourceConfigTest.java index 182349be..6cef1c35 100644 --- a/src/java/src/test/java/org/openapitools/config/DataSourceConfigTest.java +++ b/src/java/src/test/java/org/openapitools/config/DataSourceConfigTest.java @@ -19,6 +19,8 @@ void hikariConfig_ShouldUseSpringDatasourceUrlAsIs() throws Exception { setField(config, "username", "user"); setField(config, "password", "pass"); setField(config, "driverClassName", "org.postgresql.Driver"); + setField(config, "cloudRunService", ""); + setField(config, "cloudRunRevision", ""); HikariConfig result = config.hikariConfig(); @@ -38,6 +40,8 @@ void hikariConfig_ShouldNormalizeDatabaseUrlPostgresqlScheme() throws Exception setField(config, "username", "user"); setField(config, "password", "pass"); setField(config, "driverClassName", "org.postgresql.Driver"); + setField(config, "cloudRunService", ""); + setField(config, "cloudRunRevision", ""); HikariConfig result = config.hikariConfig(); @@ -54,12 +58,71 @@ void hikariConfig_ShouldNormalizeDatabaseUrlPostgresScheme() throws Exception { setField(config, "username", "user"); setField(config, "password", "pass"); setField(config, "driverClassName", "org.postgresql.Driver"); + setField(config, "cloudRunService", ""); + setField(config, "cloudRunRevision", ""); HikariConfig result = config.hikariConfig(); assertThat(result.getJdbcUrl()).isEqualTo("jdbc:postgresql://localhost:5432/lamp"); } + @Test + void hikariConfig_ShouldNormalizeCloudSqlSocketStyleDatabaseUrl() throws Exception { + DataSourceConfig config = new DataSourceConfig(); + + setField(config, "springDatasourceUrl", ""); + setField( + config, + "databaseUrl", + "postgresql://postgres:redacted-password@/lamp-control?" + + "host=/cloudsql/project-id:region:instance-id&connect_timeout=5"); + setField(config, "fallbackJdbcUrl", "jdbc:postgresql://localhost:5432/fallback"); + setField(config, "username", "ignored"); + setField(config, "password", "ignored"); + setField(config, "driverClassName", "org.postgresql.Driver"); + setField(config, "cloudRunService", ""); + setField(config, "cloudRunRevision", ""); + + HikariConfig result = config.hikariConfig(); + + assertThat(result.getJdbcUrl()) + .isEqualTo( + "jdbc:postgresql://localhost/lamp-control" + + "?host=/cloudsql/project-id:region:instance-id" + + "&connect_timeout=5" + + "&user=postgres" + + "&password=redacted-password"); + assertThat(result.getDataSourceProperties()) + .containsEntry("socketFactory", "com.google.cloud.sql.postgres.SocketFactory") + .containsEntry("unixSocketPath", "/cloudsql/project-id:region:instance-id") + .doesNotContainKey("cloudSqlRefreshStrategy"); + } + + @Test + void hikariConfig_ShouldSetCloudSqlRefreshStrategyOnCloudRunInSocketMode() throws Exception { + DataSourceConfig config = new DataSourceConfig(); + + setField(config, "springDatasourceUrl", ""); + setField( + config, + "databaseUrl", + "postgresql://postgres:redacted-password@/lamp-control?" + + "host=/cloudsql/project-id:region:instance-id&connect_timeout=5"); + setField(config, "fallbackJdbcUrl", "jdbc:postgresql://localhost:5432/fallback"); + setField(config, "username", "ignored"); + setField(config, "password", "ignored"); + setField(config, "driverClassName", "org.postgresql.Driver"); + setField(config, "cloudRunService", "lamp-control-service"); + setField(config, "cloudRunRevision", ""); + + HikariConfig result = config.hikariConfig(); + + assertThat(result.getDataSourceProperties()) + .containsEntry("socketFactory", "com.google.cloud.sql.postgres.SocketFactory") + .containsEntry("unixSocketPath", "/cloudsql/project-id:region:instance-id") + .containsEntry("cloudSqlRefreshStrategy", "lazy"); + } + @Test void hikariConfig_ShouldKeepJdbcDatabaseUrlUnchanged() throws Exception { DataSourceConfig config = new DataSourceConfig(); @@ -70,6 +133,8 @@ void hikariConfig_ShouldKeepJdbcDatabaseUrlUnchanged() throws Exception { setField(config, "username", "user"); setField(config, "password", "pass"); setField(config, "driverClassName", "org.postgresql.Driver"); + setField(config, "cloudRunService", ""); + setField(config, "cloudRunRevision", ""); HikariConfig result = config.hikariConfig(); @@ -86,6 +151,8 @@ void hikariConfig_ShouldFallbackToSpringDatasourceProperty() throws Exception { setField(config, "username", "user"); setField(config, "password", "pass"); setField(config, "driverClassName", "org.postgresql.Driver"); + setField(config, "cloudRunService", ""); + setField(config, "cloudRunRevision", ""); HikariConfig result = config.hikariConfig();