Add ConnectionDetail support to Elasticsearch auto-configuration

Update Elasticsearch auto-configuration so that
`ElasticsearchConnectionDetails` beans may be optionally used to
provide connection details.

See gh-34657

Co-Authored-By: Mortitz Halbritter <mkammerer@vmware.com>
Co-Authored-By: Phillip Webb <pwebb@vmware.com>
pull/34759/head
Andy Wilkinson 2 years ago
parent 9f187bb13a
commit 4cc7958c0b

@ -0,0 +1,135 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.elasticsearch;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
/**
* Details required to establish a connection to an Elasticsearch service.
*
* @author Moritz Halbritter
* @author Andy Wilkinson
* @author Phillip Webb
* @since 3.1.0
*/
public interface ElasticsearchConnectionDetails extends ConnectionDetails {
/**
* List of the Elasticsearch nodes to use.
* @return list of the Elasticsearch nodes to use
*/
List<Node> getNodes();
/**
* Username for authentication with Elasticsearch.
* @return username for authentication with Elasticsearch or {@code null}
*/
default String getUsername() {
return null;
}
/**
* Password for authentication with Elasticsearch.
* @return password for authentication with Elasticsearch or {@code null}
*/
default String getPassword() {
return null;
}
/**
* Prefix added to the path of every request sent to Elasticsearch.
* @return prefix added to the path of every request sent to Elasticsearch or
* {@code null}
*/
default String getPathPrefix() {
return null;
}
/**
* An Elasticsearch node.
*
* @param hostname the hostname
* @param port the port
* @param protocol the protocol
* @param username the username or {@code null}
* @param password the password or {@code null}
*/
record Node(String hostname, int port, Node.Protocol protocol, String username, String password) {
public Node(String host, int port, Node.Protocol protocol) {
this(host, port, protocol, null, null);
}
URI toUri() {
try {
return new URI(this.protocol.getScheme(), userInfo(), this.hostname, this.port, null, null, null);
}
catch (URISyntaxException ex) {
throw new IllegalStateException("Can't construct URI", ex);
}
}
private String userInfo() {
if (this.username == null) {
return null;
}
return (this.password != null) ? (this.username + ":" + this.password) : this.username;
}
/**
* Connection protocol.
*/
public enum Protocol {
/**
* HTTP.
*/
HTTP("http"),
/**
* HTTPS.
*/
HTTPS("https");
private final String scheme;
Protocol(String scheme) {
this.scheme = scheme;
}
String getScheme() {
return this.scheme;
}
static Protocol forScheme(String scheme) {
for (Protocol protocol : values()) {
if (protocol.scheme.equals(scheme)) {
return protocol;
}
}
throw new IllegalArgumentException("Unknown scheme '" + scheme + "'");
}
}
}
}

@ -17,8 +17,9 @@
package org.springframework.boot.autoconfigure.elasticsearch;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
import java.util.List;
import java.util.stream.Stream;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
@ -37,6 +38,8 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchConnectionDetails.Node;
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchConnectionDetails.Node.Protocol;
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -47,6 +50,9 @@ import org.springframework.util.StringUtils;
*
* @author Stephane Nicoll
* @author Filip Hrisafov
* @author Moritz Halbritter
* @author Andy Wilkinson
* @author Phillip Webb
*/
class ElasticsearchRestClientConfigurations {
@ -56,20 +62,27 @@ class ElasticsearchRestClientConfigurations {
private final ElasticsearchProperties properties;
RestClientBuilderConfiguration(ElasticsearchProperties properties) {
private final ElasticsearchConnectionDetails connectionDetails;
RestClientBuilderConfiguration(ElasticsearchProperties properties,
ObjectProvider<ElasticsearchConnectionDetails> connectionDetails) {
this.properties = properties;
this.connectionDetails = connectionDetails
.getIfAvailable(() -> new PropertiesElasticsearchConnectionDetails(properties));
}
@Bean
RestClientBuilderCustomizer defaultRestClientBuilderCustomizer() {
return new DefaultRestClientBuilderCustomizer(this.properties);
return new DefaultRestClientBuilderCustomizer(this.properties, this.connectionDetails);
}
@Bean
RestClientBuilder elasticsearchRestClientBuilder(
ObjectProvider<RestClientBuilderCustomizer> builderCustomizers) {
HttpHost[] hosts = this.properties.getUris().stream().map(this::createHttpHost).toArray(HttpHost[]::new);
RestClientBuilder builder = RestClient.builder(hosts);
RestClientBuilder builder = RestClient.builder(this.connectionDetails.getNodes()
.stream()
.map((node) -> new HttpHost(node.hostname(), node.port(), node.protocol().getScheme()))
.toArray(HttpHost[]::new));
builder.setHttpClientConfigCallback((httpClientBuilder) -> {
builderCustomizers.orderedStream().forEach((customizer) -> customizer.customize(httpClientBuilder));
return httpClientBuilder;
@ -78,36 +91,14 @@ class ElasticsearchRestClientConfigurations {
builderCustomizers.orderedStream().forEach((customizer) -> customizer.customize(requestConfigBuilder));
return requestConfigBuilder;
});
if (this.properties.getPathPrefix() != null) {
builder.setPathPrefix(this.properties.getPathPrefix());
String pathPrefix = this.connectionDetails.getPathPrefix();
if (pathPrefix != null) {
builder.setPathPrefix(pathPrefix);
}
builderCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
return builder;
}
private HttpHost createHttpHost(String uri) {
try {
return createHttpHost(URI.create(uri));
}
catch (IllegalArgumentException ex) {
return HttpHost.create(uri);
}
}
private HttpHost createHttpHost(URI uri) {
if (!StringUtils.hasLength(uri.getUserInfo())) {
return HttpHost.create(uri.toString());
}
try {
return HttpHost.create(new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), uri.getPath(),
uri.getQuery(), uri.getFragment())
.toString());
}
catch (URISyntaxException ex) {
throw new IllegalStateException(ex);
}
}
}
@Configuration(proxyBeanMethods = false)
@ -146,8 +137,12 @@ class ElasticsearchRestClientConfigurations {
private final ElasticsearchProperties properties;
DefaultRestClientBuilderCustomizer(ElasticsearchProperties properties) {
private final ElasticsearchConnectionDetails connectionDetails;
DefaultRestClientBuilderCustomizer(ElasticsearchProperties properties,
ElasticsearchConnectionDetails connectionDetails) {
this.properties = properties;
this.connectionDetails = connectionDetails;
}
@Override
@ -156,7 +151,7 @@ class ElasticsearchRestClientConfigurations {
@Override
public void customize(HttpAsyncClientBuilder builder) {
builder.setDefaultCredentialsProvider(new PropertiesCredentialsProvider(this.properties));
builder.setDefaultCredentialsProvider(new ConnectionDetailsCredentialsProvider(this.connectionDetails));
map.from(this.properties::isSocketKeepAlive)
.to((keepAlive) -> builder
.setDefaultIOReactorConfig(IOReactorConfig.custom().setSoKeepAlive(keepAlive).build()));
@ -176,28 +171,20 @@ class ElasticsearchRestClientConfigurations {
}
private static class PropertiesCredentialsProvider extends BasicCredentialsProvider {
private static class ConnectionDetailsCredentialsProvider extends BasicCredentialsProvider {
PropertiesCredentialsProvider(ElasticsearchProperties properties) {
if (StringUtils.hasText(properties.getUsername())) {
Credentials credentials = new UsernamePasswordCredentials(properties.getUsername(),
properties.getPassword());
ConnectionDetailsCredentialsProvider(ElasticsearchConnectionDetails connectionDetails) {
String username = connectionDetails.getUsername();
if (StringUtils.hasText(username)) {
Credentials credentials = new UsernamePasswordCredentials(username, connectionDetails.getPassword());
setCredentials(AuthScope.ANY, credentials);
}
properties.getUris()
.stream()
.map(this::toUri)
.filter(this::hasUserInfo)
.forEach(this::addUserInfoCredentials);
Stream<URI> uris = getUris(connectionDetails);
uris.filter(this::hasUserInfo).forEach(this::addUserInfoCredentials);
}
private URI toUri(String uri) {
try {
return URI.create(uri);
}
catch (IllegalArgumentException ex) {
return null;
}
private Stream<URI> getUris(ElasticsearchConnectionDetails connectionDetails) {
return connectionDetails.getNodes().stream().map(Node::toUri);
}
private boolean hasUserInfo(URI uri) {
@ -222,4 +209,59 @@ class ElasticsearchRestClientConfigurations {
}
/**
* Adapts {@link ElasticsearchProperties} to {@link ElasticsearchConnectionDetails}.
*/
private static class PropertiesElasticsearchConnectionDetails implements ElasticsearchConnectionDetails {
private final ElasticsearchProperties properties;
PropertiesElasticsearchConnectionDetails(ElasticsearchProperties properties) {
this.properties = properties;
}
@Override
public List<Node> getNodes() {
return this.properties.getUris().stream().map(this::createNode).toList();
}
@Override
public String getUsername() {
return this.properties.getUsername();
}
@Override
public String getPassword() {
return this.properties.getPassword();
}
@Override
public String getPathPrefix() {
return this.properties.getPathPrefix();
}
private Node createNode(String uri) {
if (!(uri.startsWith("http://") || uri.startsWith("https://"))) {
uri = "http://" + uri;
}
return createNode(URI.create(uri));
}
private Node createNode(URI uri) {
String userInfo = uri.getUserInfo();
Protocol protocol = Protocol.forScheme(uri.getScheme());
if (!StringUtils.hasLength(userInfo)) {
return new Node(uri.getHost(), uri.getPort(), protocol, null, null);
}
int separatorIndex = userInfo.indexOf(':');
if (separatorIndex == -1) {
return new Node(uri.getHost(), uri.getPort(), protocol, userInfo, null);
}
String[] components = userInfo.split(":");
return new Node(uri.getHost(), uri.getPort(), protocol, components[0],
(components.length > 1) ? components[1] : "");
}
}
}

@ -17,6 +17,7 @@
package org.springframework.boot.autoconfigure.elasticsearch;
import java.time.Duration;
import java.util.List;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
@ -32,6 +33,7 @@ import org.elasticsearch.client.sniff.Sniffer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchConnectionDetails.Node.Protocol;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
@ -49,6 +51,8 @@ import static org.mockito.Mockito.mock;
* @author Evgeniy Cheban
* @author Filip Hrisafov
* @author Andy Wilkinson
* @author Moritz Halbritter
* @author Phillip Webb
*/
class ElasticsearchRestClientAutoConfigurationTests {
@ -243,6 +247,65 @@ class ElasticsearchRestClientAutoConfigurationTests {
});
}
@Test
void connectionDetailsAreUsedIfAvailable() {
this.contextRunner.withUserConfiguration(ConnectionDetailsConfiguration.class).run((context) -> {
assertThat(context).hasSingleBean(RestClient.class);
RestClient restClient = context.getBean(RestClient.class);
assertThat(restClient).hasFieldOrPropertyWithValue("pathPrefix", "/some-path");
assertThat(restClient.getNodes().stream().map(Node::getHost).map(HttpHost::toString))
.containsExactly("http://elastic.example.com:9200");
assertThat(restClient)
.extracting("client.credentialsProvider", InstanceOfAssertFactories.type(CredentialsProvider.class))
.satisfies((credentialsProvider) -> {
Credentials uriCredentials = credentialsProvider
.getCredentials(new AuthScope("any.elastic.example.com", 80));
assertThat(uriCredentials.getUserPrincipal().getName()).isEqualTo("user-1");
assertThat(uriCredentials.getPassword()).isEqualTo("password-1");
})
.satisfies((credentialsProvider) -> {
Credentials uriCredentials = credentialsProvider
.getCredentials(new AuthScope("elastic.example.com", 9200));
assertThat(uriCredentials.getUserPrincipal().getName()).isEqualTo("node-user-1");
assertThat(uriCredentials.getPassword()).isEqualTo("node-password-1");
});
});
}
@Configuration(proxyBeanMethods = false)
static class ConnectionDetailsConfiguration {
@Bean
ElasticsearchConnectionDetails elasticsearchConnectionDetails() {
return new ElasticsearchConnectionDetails() {
@Override
public List<Node> getNodes() {
return List
.of(new Node("elastic.example.com", 9200, Protocol.HTTP, "node-user-1", "node-password-1"));
}
@Override
public String getUsername() {
return "user-1";
}
@Override
public String getPassword() {
return "password-1";
}
@Override
public String getPathPrefix() {
return "/some-path";
}
};
}
}
@Configuration(proxyBeanMethods = false)
static class BuilderCustomizerConfiguration {

Loading…
Cancel
Save