Skip to content

MLE-19240 Added marklogic.client.connectionString #1735

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
package com.marklogic.client;

import com.marklogic.client.impl.ConnectionString;
import com.marklogic.client.impl.DatabaseClientPropertySource;

import javax.net.ssl.SSLContext;
Expand Down Expand Up @@ -84,6 +85,24 @@ public DatabaseClientBuilder withPort(int port) {
return this;
}

/**
* @param connectionString of the form "username:password@host:port/optionalDatabaseName".
* @since 7.1.0
*/
public DatabaseClientBuilder withConnectionString(String connectionString) {
ConnectionString cs = new ConnectionString(connectionString, "connection string");
if (!props.containsKey(PREFIX + "authType")) {
withAuthType("digest");
}
if (cs.getDatabase() != null && cs.getDatabase().trim().length() > 0) {
withDatabase(cs.getDatabase());
}
return withHost(cs.getHost())
.withPort(cs.getPort())
.withUsername(cs.getUsername())
.withPassword(cs.getPassword());
}

public DatabaseClientBuilder withBasePath(String basePath) {
props.put(PREFIX + "basePath", basePath);
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,7 @@ public String getCertificatePassword() {
* "kerberos", "certificate", or "saml"</li>
* <li>marklogic.client.username = must be a String; required for basic and digest authentication</li>
* <li>marklogic.client.password = must be a String; required for basic and digest authentication</li>
* <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>
* <li>marklogic.client.certificate.file = must be a String; optional for certificate authentication</li>
* <li>marklogic.client.certificate.password = must be a String; optional for certificate authentication</li>
* <li>marklogic.client.cloud.apiKey = must be a String; required for cloud authentication</li>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright © 2024 MarkLogic Corporation. All Rights Reserved.
*/
package com.marklogic.client.impl;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;

/**
* @since 7.1.0; copied from marklogic-spark-connector repository.
*/
public class ConnectionString {

private final String host;
private final int port;
private final String username;
private final String password;
private final String database;

public ConnectionString(String connectionString, String optionNameForErrorMessage) {
final String errorMessage = String.format(
"Invalid value for %s; must be username:password@host:port/optionalDatabaseName",
optionNameForErrorMessage
);

String[] parts = connectionString.split("@");
if (parts.length != 2) {
throw new IllegalArgumentException(errorMessage);
}
String[] tokens = parts[0].split(":");
if (tokens.length != 2) {
throw new IllegalArgumentException(errorMessage);
}
this.username = decodeValue(tokens[0], "username");
this.password = decodeValue(tokens[1], "password");

tokens = parts[1].split(":");
if (tokens.length != 2) {
throw new IllegalArgumentException(errorMessage);
}
this.host = tokens[0];
if (tokens[1].contains("/")) {
tokens = tokens[1].split("/");
this.port = parsePort(tokens[0], optionNameForErrorMessage);
this.database = tokens[1];
} else {
this.port = parsePort(tokens[1], optionNameForErrorMessage);
this.database = null;
}
}

private int parsePort(String value, String optionNameForErrorMessage) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(String.format(
"Invalid value for %s; port must be numeric, but was '%s'", optionNameForErrorMessage, value
));
}
}

private String decodeValue(String value, String label) {
try {
return URLDecoder.decode(value, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new IllegalArgumentException(String.format("Unable to decode '%s'; cause: %s", label, e.getMessage()));
}
}

public String getHost() {
return host;
}

public int getPort() {
return port;
}

public String getUsername() {
return username;
}

public String getPassword() {
return password;
}

public String getDatabase() {
return database;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ public class DatabaseClientPropertySource {
throw new IllegalArgumentException("Database must be of type String");
}
});
connectionPropertyHandlers.put(PREFIX + "connectionString", (bean, value) -> {
if (value instanceof String) {
ConnectionString cs = new ConnectionString((String) value, "connection string");
bean.setHost(cs.getHost());
bean.setPort(cs.getPort());
if (cs.getDatabase() != null && cs.getDatabase().trim().length() > 0) {
bean.setDatabase(cs.getDatabase());
}
} else {
throw new IllegalArgumentException("Connection string must be of type String");
}
});
connectionPropertyHandlers.put(PREFIX + "basePath", (bean, value) -> {
if (value instanceof String) {
bean.setBasePath((String) value);
Expand Down Expand Up @@ -125,6 +137,11 @@ public DatabaseClientFactory.Bean newClientBean() {
return bean;
}

private ConnectionString makeConnectionString() {
String value = (String) propertySource.apply(PREFIX + "connectionString");
return value != null && value.trim().length() > 0 ? new ConnectionString(value, "connection string") : null;
}

private DatabaseClientFactory.SecurityContext newSecurityContext() {
Object securityContextValue = propertySource.apply(PREFIX + "securityContext");
if (securityContextValue != null) {
Expand All @@ -134,27 +151,35 @@ private DatabaseClientFactory.SecurityContext newSecurityContext() {
throw new IllegalArgumentException("Security context must be of type " + DatabaseClientFactory.SecurityContext.class.getName());
}

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

final SSLUtil.SSLInputs sslInputs = buildSSLInputs(authType);
DatabaseClientFactory.SecurityContext securityContext = newSecurityContext(authType, sslInputs);
DatabaseClientFactory.SecurityContext securityContext = newSecurityContext(authType, connectionString, sslInputs);
if (sslInputs.getSslContext() != null) {
securityContext.withSSLContext(sslInputs.getSslContext(), sslInputs.getTrustManager());
}
securityContext.withSSLHostnameVerifier(determineHostnameVerifier());
return securityContext;
}

private DatabaseClientFactory.SecurityContext newSecurityContext(String type, SSLUtil.SSLInputs sslInputs) {
private String determineAuthType(ConnectionString connectionString) {
Object value = propertySource.apply(PREFIX + "authType");
if (value == null && connectionString != null) {
return "digest";
}
if (value == null || !(value instanceof String)) {
throw new IllegalArgumentException("Security context should be set, or auth type must be of type String");
}
return (String) value;
}

private DatabaseClientFactory.SecurityContext newSecurityContext(String type, ConnectionString connectionString, SSLUtil.SSLInputs sslInputs) {
switch (type.toLowerCase()) {
case DatabaseClientBuilder.AUTH_TYPE_BASIC:
return newBasicAuthContext();
return newBasicAuthContext(connectionString);
case DatabaseClientBuilder.AUTH_TYPE_DIGEST:
return newDigestAuthContext();
return newDigestAuthContext(connectionString);
case DatabaseClientBuilder.AUTH_TYPE_MARKLOGIC_CLOUD:
return newCloudAuthContext();
case DatabaseClientBuilder.AUTH_TYPE_KERBEROS:
Expand Down Expand Up @@ -194,14 +219,24 @@ private String getNullableStringValue(String propertyName, String defaultValue)
return value != null ? (String) value : defaultValue;
}

private DatabaseClientFactory.SecurityContext newBasicAuthContext() {
private DatabaseClientFactory.SecurityContext newBasicAuthContext(ConnectionString connectionString) {
if (connectionString != null) {
return new DatabaseClientFactory.BasicAuthContext(
connectionString.getUsername(), connectionString.getPassword()
);
}
return new DatabaseClientFactory.BasicAuthContext(
getRequiredStringValue("username", "Must specify a username when using basic authentication."),
getRequiredStringValue("password", "Must specify a password when using basic authentication.")
);
}

private DatabaseClientFactory.SecurityContext newDigestAuthContext() {
private DatabaseClientFactory.SecurityContext newDigestAuthContext(ConnectionString connectionString) {
if (connectionString != null) {
return new DatabaseClientFactory.DigestAuthContext(
connectionString.getUsername(), connectionString.getPassword()
);
}
return new DatabaseClientFactory.DigestAuthContext(
getRequiredStringValue("username", "Must specify a username when using digest authentication."),
getRequiredStringValue("password", "Must specify a password when using digest authentication.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@
import com.marklogic.client.DatabaseClientFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import java.util.HashMap;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.*;

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

@Test
void connectionString() {
useConnectionString("user:password@localhost:8000");
DatabaseClientFactory.Bean bean = buildBean();

assertEquals("localhost", bean.getHost());
assertEquals(8000, bean.getPort());
assertNull(bean.getDatabase());
DatabaseClientFactory.DigestAuthContext context = (DatabaseClientFactory.DigestAuthContext) bean.getSecurityContext();
assertEquals("user", context.getUser());
assertEquals("password", context.getPassword());
}

@Test
void connectionStringWithDatabase() {
useConnectionString("user:password@localhost:8000/Documents");
DatabaseClientFactory.Bean bean = buildBean();

assertEquals("localhost", bean.getHost());
assertEquals(8000, bean.getPort());
assertEquals("Documents", bean.getDatabase());
DatabaseClientFactory.DigestAuthContext context = (DatabaseClientFactory.DigestAuthContext) bean.getSecurityContext();
assertEquals("user", context.getUser());
assertEquals("password", context.getPassword());
}

@Test
void connectionStringWithSeparateDatabase() {
useConnectionString("user:password@localhost:8000");
props.put(PREFIX + "database", "SomeDatabase");
DatabaseClientFactory.Bean bean = buildBean();

assertEquals("localhost", bean.getHost());
assertEquals(8000, bean.getPort());
assertEquals("SomeDatabase", bean.getDatabase());
}

@Test
void usernameAndPasswordBothRequireDecoding() {
useConnectionString("test-user%40:sp%40r%3Ak@localhost:8000/Documents");
DatabaseClientFactory.Bean bean = buildBean();

assertEquals("localhost", bean.getHost());
assertEquals(8000, bean.getPort());
assertEquals("Documents", bean.getDatabase());

DatabaseClientFactory.DigestAuthContext context = (DatabaseClientFactory.DigestAuthContext) bean.getSecurityContext();
assertEquals("test-user@", context.getUser());
assertEquals("sp@r:k", context.getPassword(), "Verifies that the user must encode username and password " +
"values that contain ':' or '@'. The builder is then expected to decode them into the correct values.");
}

@ParameterizedTest
@ValueSource(strings = {
"user@host@port",
"user@host:port",
"user:password@host",
"user:password:something@host:port",
"user:password@host:port:something"
})
void invalidConnectionString(String connectionString) {
useConnectionString(connectionString);
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> buildBean());
assertEquals("Invalid value for connection string; must be username:password@host:port/optionalDatabaseName",
ex.getMessage());
}

@Test
void nonNumericPortInConnectionString() {
useConnectionString("user:password@host:nonNumericPort");
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> buildBean());
assertEquals("Invalid value for connection string; port must be numeric, but was 'nonNumericPort'", ex.getMessage());
}

private void useConnectionString(String connectionString) {
props = new HashMap() {{
put(PREFIX + "connectionString", connectionString);
}};
}

private DatabaseClientFactory.Bean buildBean() {
DatabaseClientPropertySource source = new DatabaseClientPropertySource(propertyName -> props.get(propertyName));
return source.newClientBean();
Expand Down
Loading