From 4cc7958c0bc7445599432de2f05a54357da8f34c Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 23 Mar 2023 23:21:27 -0700 Subject: [PATCH] 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 Co-Authored-By: Phillip Webb --- .../ElasticsearchConnectionDetails.java | 135 +++++++++++++++++ ...ElasticsearchRestClientConfigurations.java | 140 ++++++++++++------ ...earchRestClientAutoConfigurationTests.java | 63 ++++++++ 3 files changed, 289 insertions(+), 49 deletions(-) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchConnectionDetails.java diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchConnectionDetails.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchConnectionDetails.java new file mode 100644 index 0000000000..33c499ebaa --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchConnectionDetails.java @@ -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 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 + "'"); + } + + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchRestClientConfigurations.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchRestClientConfigurations.java index 93ef387e35..40f945e830 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchRestClientConfigurations.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchRestClientConfigurations.java @@ -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 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 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 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 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 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] : ""); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchRestClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchRestClientAutoConfigurationTests.java index 79b9c59e90..6e57d8112b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchRestClientAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/elasticsearch/ElasticsearchRestClientAutoConfigurationTests.java @@ -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 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 {