Skip to content

Commit 728ef0c

Browse files
authored
Merge pull request #1735 from marklogic/feature/connection-string
MLE-19240 Added marklogic.client.connectionString
2 parents 0968329 + 919284a commit 728ef0c

File tree

6 files changed

+334
-22
lines changed

6 files changed

+334
-22
lines changed

marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
package com.marklogic.client;
55

6+
import com.marklogic.client.impl.ConnectionString;
67
import com.marklogic.client.impl.DatabaseClientPropertySource;
78

89
import javax.net.ssl.SSLContext;
@@ -84,6 +85,24 @@ public DatabaseClientBuilder withPort(int port) {
8485
return this;
8586
}
8687

88+
/**
89+
* @param connectionString of the form "username:password@host:port/optionalDatabaseName".
90+
* @since 7.1.0
91+
*/
92+
public DatabaseClientBuilder withConnectionString(String connectionString) {
93+
ConnectionString cs = new ConnectionString(connectionString, "connection string");
94+
if (!props.containsKey(PREFIX + "authType")) {
95+
withAuthType("digest");
96+
}
97+
if (cs.getDatabase() != null && cs.getDatabase().trim().length() > 0) {
98+
withDatabase(cs.getDatabase());
99+
}
100+
return withHost(cs.getHost())
101+
.withPort(cs.getPort())
102+
.withUsername(cs.getUsername())
103+
.withPassword(cs.getPassword());
104+
}
105+
87106
public DatabaseClientBuilder withBasePath(String basePath) {
88107
props.put(PREFIX + "basePath", basePath);
89108
return this;

marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientFactory.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,7 @@ public String getCertificatePassword() {
10881088
* "kerberos", "certificate", or "saml"</li>
10891089
* <li>marklogic.client.username = must be a String; required for basic and digest authentication</li>
10901090
* <li>marklogic.client.password = must be a String; required for basic and digest authentication</li>
1091+
* <li>marklogic.client.connectionString = must be a String; must fit format of "username:password@host:port/optionalDatabaseName". Defaults the authentication type to "digest"; since 7.1.0.</li>
10911092
* <li>marklogic.client.certificate.file = must be a String; optional for certificate authentication</li>
10921093
* <li>marklogic.client.certificate.password = must be a String; optional for certificate authentication</li>
10931094
* <li>marklogic.client.cloud.apiKey = must be a String; required for cloud authentication</li>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright © 2024 MarkLogic Corporation. All Rights Reserved.
3+
*/
4+
package com.marklogic.client.impl;
5+
6+
import java.io.UnsupportedEncodingException;
7+
import java.net.URLDecoder;
8+
9+
/**
10+
* @since 7.1.0; copied from marklogic-spark-connector repository.
11+
*/
12+
public class ConnectionString {
13+
14+
private final String host;
15+
private final int port;
16+
private final String username;
17+
private final String password;
18+
private final String database;
19+
20+
public ConnectionString(String connectionString, String optionNameForErrorMessage) {
21+
final String errorMessage = String.format(
22+
"Invalid value for %s; must be username:password@host:port/optionalDatabaseName",
23+
optionNameForErrorMessage
24+
);
25+
26+
String[] parts = connectionString.split("@");
27+
if (parts.length != 2) {
28+
throw new IllegalArgumentException(errorMessage);
29+
}
30+
String[] tokens = parts[0].split(":");
31+
if (tokens.length != 2) {
32+
throw new IllegalArgumentException(errorMessage);
33+
}
34+
this.username = decodeValue(tokens[0], "username");
35+
this.password = decodeValue(tokens[1], "password");
36+
37+
tokens = parts[1].split(":");
38+
if (tokens.length != 2) {
39+
throw new IllegalArgumentException(errorMessage);
40+
}
41+
this.host = tokens[0];
42+
if (tokens[1].contains("/")) {
43+
tokens = tokens[1].split("/");
44+
this.port = parsePort(tokens[0], optionNameForErrorMessage);
45+
this.database = tokens[1];
46+
} else {
47+
this.port = parsePort(tokens[1], optionNameForErrorMessage);
48+
this.database = null;
49+
}
50+
}
51+
52+
private int parsePort(String value, String optionNameForErrorMessage) {
53+
try {
54+
return Integer.parseInt(value);
55+
} catch (NumberFormatException e) {
56+
throw new IllegalArgumentException(String.format(
57+
"Invalid value for %s; port must be numeric, but was '%s'", optionNameForErrorMessage, value
58+
));
59+
}
60+
}
61+
62+
private String decodeValue(String value, String label) {
63+
try {
64+
return URLDecoder.decode(value, "UTF-8");
65+
} catch (UnsupportedEncodingException e) {
66+
throw new IllegalArgumentException(String.format("Unable to decode '%s'; cause: %s", label, e.getMessage()));
67+
}
68+
}
69+
70+
public String getHost() {
71+
return host;
72+
}
73+
74+
public int getPort() {
75+
return port;
76+
}
77+
78+
public String getUsername() {
79+
return username;
80+
}
81+
82+
public String getPassword() {
83+
return password;
84+
}
85+
86+
public String getDatabase() {
87+
return database;
88+
}
89+
}

marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ public class DatabaseClientPropertySource {
5959
throw new IllegalArgumentException("Database must be of type String");
6060
}
6161
});
62+
connectionPropertyHandlers.put(PREFIX + "connectionString", (bean, value) -> {
63+
if (value instanceof String) {
64+
ConnectionString cs = new ConnectionString((String) value, "connection string");
65+
bean.setHost(cs.getHost());
66+
bean.setPort(cs.getPort());
67+
if (cs.getDatabase() != null && cs.getDatabase().trim().length() > 0) {
68+
bean.setDatabase(cs.getDatabase());
69+
}
70+
} else {
71+
throw new IllegalArgumentException("Connection string must be of type String");
72+
}
73+
});
6274
connectionPropertyHandlers.put(PREFIX + "basePath", (bean, value) -> {
6375
if (value instanceof String) {
6476
bean.setBasePath((String) value);
@@ -125,6 +137,11 @@ public DatabaseClientFactory.Bean newClientBean() {
125137
return bean;
126138
}
127139

140+
private ConnectionString makeConnectionString() {
141+
String value = (String) propertySource.apply(PREFIX + "connectionString");
142+
return value != null && value.trim().length() > 0 ? new ConnectionString(value, "connection string") : null;
143+
}
144+
128145
private DatabaseClientFactory.SecurityContext newSecurityContext() {
129146
Object securityContextValue = propertySource.apply(PREFIX + "securityContext");
130147
if (securityContextValue != null) {
@@ -134,27 +151,35 @@ private DatabaseClientFactory.SecurityContext newSecurityContext() {
134151
throw new IllegalArgumentException("Security context must be of type " + DatabaseClientFactory.SecurityContext.class.getName());
135152
}
136153

137-
Object typeValue = propertySource.apply(PREFIX + "authType");
138-
if (typeValue == null || !(typeValue instanceof String)) {
139-
throw new IllegalArgumentException("Security context should be set, or auth type must be of type String");
140-
}
141-
final String authType = (String) typeValue;
154+
ConnectionString connectionString = makeConnectionString();
155+
final String authType = determineAuthType(connectionString);
142156

143157
final SSLUtil.SSLInputs sslInputs = buildSSLInputs(authType);
144-
DatabaseClientFactory.SecurityContext securityContext = newSecurityContext(authType, sslInputs);
158+
DatabaseClientFactory.SecurityContext securityContext = newSecurityContext(authType, connectionString, sslInputs);
145159
if (sslInputs.getSslContext() != null) {
146160
securityContext.withSSLContext(sslInputs.getSslContext(), sslInputs.getTrustManager());
147161
}
148162
securityContext.withSSLHostnameVerifier(determineHostnameVerifier());
149163
return securityContext;
150164
}
151165

152-
private DatabaseClientFactory.SecurityContext newSecurityContext(String type, SSLUtil.SSLInputs sslInputs) {
166+
private String determineAuthType(ConnectionString connectionString) {
167+
Object value = propertySource.apply(PREFIX + "authType");
168+
if (value == null && connectionString != null) {
169+
return "digest";
170+
}
171+
if (value == null || !(value instanceof String)) {
172+
throw new IllegalArgumentException("Security context should be set, or auth type must be of type String");
173+
}
174+
return (String) value;
175+
}
176+
177+
private DatabaseClientFactory.SecurityContext newSecurityContext(String type, ConnectionString connectionString, SSLUtil.SSLInputs sslInputs) {
153178
switch (type.toLowerCase()) {
154179
case DatabaseClientBuilder.AUTH_TYPE_BASIC:
155-
return newBasicAuthContext();
180+
return newBasicAuthContext(connectionString);
156181
case DatabaseClientBuilder.AUTH_TYPE_DIGEST:
157-
return newDigestAuthContext();
182+
return newDigestAuthContext(connectionString);
158183
case DatabaseClientBuilder.AUTH_TYPE_MARKLOGIC_CLOUD:
159184
return newCloudAuthContext();
160185
case DatabaseClientBuilder.AUTH_TYPE_KERBEROS:
@@ -194,14 +219,24 @@ private String getNullableStringValue(String propertyName, String defaultValue)
194219
return value != null ? (String) value : defaultValue;
195220
}
196221

197-
private DatabaseClientFactory.SecurityContext newBasicAuthContext() {
222+
private DatabaseClientFactory.SecurityContext newBasicAuthContext(ConnectionString connectionString) {
223+
if (connectionString != null) {
224+
return new DatabaseClientFactory.BasicAuthContext(
225+
connectionString.getUsername(), connectionString.getPassword()
226+
);
227+
}
198228
return new DatabaseClientFactory.BasicAuthContext(
199229
getRequiredStringValue("username", "Must specify a username when using basic authentication."),
200230
getRequiredStringValue("password", "Must specify a password when using basic authentication.")
201231
);
202232
}
203233

204-
private DatabaseClientFactory.SecurityContext newDigestAuthContext() {
234+
private DatabaseClientFactory.SecurityContext newDigestAuthContext(ConnectionString connectionString) {
235+
if (connectionString != null) {
236+
return new DatabaseClientFactory.DigestAuthContext(
237+
connectionString.getUsername(), connectionString.getPassword()
238+
);
239+
}
205240
return new DatabaseClientFactory.DigestAuthContext(
206241
getRequiredStringValue("username", "Must specify a username when using digest authentication."),
207242
getRequiredStringValue("password", "Must specify a password when using digest authentication.")

marklogic-client-api/src/test/java/com/marklogic/client/impl/DatabaseClientPropertySourceTest.java

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@
55
import com.marklogic.client.DatabaseClientFactory;
66
import org.junit.jupiter.api.BeforeEach;
77
import org.junit.jupiter.api.Test;
8+
import org.junit.jupiter.params.ParameterizedTest;
9+
import org.junit.jupiter.params.provider.ValueSource;
810

911
import java.util.HashMap;
1012
import java.util.Map;
1113

12-
import static org.junit.jupiter.api.Assertions.assertEquals;
13-
import static org.junit.jupiter.api.Assertions.assertNotNull;
14-
import static org.junit.jupiter.api.Assertions.assertThrows;
15-
import static org.junit.jupiter.api.Assertions.assertTrue;
14+
import static org.junit.jupiter.api.Assertions.*;
1615

1716
/**
1817
* Intent of this test is to cover code that cannot be covered by DatabaseClientBuilderTest.
@@ -123,6 +122,86 @@ void disableGzippedResponses() {
123122
buildBean();
124123
}
125124

125+
@Test
126+
void connectionString() {
127+
useConnectionString("user:password@localhost:8000");
128+
DatabaseClientFactory.Bean bean = buildBean();
129+
130+
assertEquals("localhost", bean.getHost());
131+
assertEquals(8000, bean.getPort());
132+
assertNull(bean.getDatabase());
133+
DatabaseClientFactory.DigestAuthContext context = (DatabaseClientFactory.DigestAuthContext) bean.getSecurityContext();
134+
assertEquals("user", context.getUser());
135+
assertEquals("password", context.getPassword());
136+
}
137+
138+
@Test
139+
void connectionStringWithDatabase() {
140+
useConnectionString("user:password@localhost:8000/Documents");
141+
DatabaseClientFactory.Bean bean = buildBean();
142+
143+
assertEquals("localhost", bean.getHost());
144+
assertEquals(8000, bean.getPort());
145+
assertEquals("Documents", bean.getDatabase());
146+
DatabaseClientFactory.DigestAuthContext context = (DatabaseClientFactory.DigestAuthContext) bean.getSecurityContext();
147+
assertEquals("user", context.getUser());
148+
assertEquals("password", context.getPassword());
149+
}
150+
151+
@Test
152+
void connectionStringWithSeparateDatabase() {
153+
useConnectionString("user:password@localhost:8000");
154+
props.put(PREFIX + "database", "SomeDatabase");
155+
DatabaseClientFactory.Bean bean = buildBean();
156+
157+
assertEquals("localhost", bean.getHost());
158+
assertEquals(8000, bean.getPort());
159+
assertEquals("SomeDatabase", bean.getDatabase());
160+
}
161+
162+
@Test
163+
void usernameAndPasswordBothRequireDecoding() {
164+
useConnectionString("test-user%40:sp%40r%3Ak@localhost:8000/Documents");
165+
DatabaseClientFactory.Bean bean = buildBean();
166+
167+
assertEquals("localhost", bean.getHost());
168+
assertEquals(8000, bean.getPort());
169+
assertEquals("Documents", bean.getDatabase());
170+
171+
DatabaseClientFactory.DigestAuthContext context = (DatabaseClientFactory.DigestAuthContext) bean.getSecurityContext();
172+
assertEquals("test-user@", context.getUser());
173+
assertEquals("sp@r:k", context.getPassword(), "Verifies that the user must encode username and password " +
174+
"values that contain ':' or '@'. The builder is then expected to decode them into the correct values.");
175+
}
176+
177+
@ParameterizedTest
178+
@ValueSource(strings = {
179+
"user@host@port",
180+
"user@host:port",
181+
"user:password@host",
182+
"user:password:something@host:port",
183+
"user:password@host:port:something"
184+
})
185+
void invalidConnectionString(String connectionString) {
186+
useConnectionString(connectionString);
187+
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> buildBean());
188+
assertEquals("Invalid value for connection string; must be username:password@host:port/optionalDatabaseName",
189+
ex.getMessage());
190+
}
191+
192+
@Test
193+
void nonNumericPortInConnectionString() {
194+
useConnectionString("user:password@host:nonNumericPort");
195+
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> buildBean());
196+
assertEquals("Invalid value for connection string; port must be numeric, but was 'nonNumericPort'", ex.getMessage());
197+
}
198+
199+
private void useConnectionString(String connectionString) {
200+
props = new HashMap() {{
201+
put(PREFIX + "connectionString", connectionString);
202+
}};
203+
}
204+
126205
private DatabaseClientFactory.Bean buildBean() {
127206
DatabaseClientPropertySource source = new DatabaseClientPropertySource(propertyName -> props.get(propertyName));
128207
return source.newClientBean();

0 commit comments

Comments
 (0)