From 6ea2547de4dc9d6bc07b321be87df485132403c2 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Fri, 21 Apr 2023 10:06:52 -0700 Subject: [PATCH] Add SSL bundle support to WebClient auto-configuration Introduce `WebClientSsl` interface and auto-configuration to allow a WebClient builder to have custom SSL configuration applied. The previous `ClientHttpConnectorConfiguration` has been been changed to now create `ClientHttpConnectorFactory` instances which can be used directly or by `AutoConfiguredWebClientSsl`. Closes gh-18556 --- .../spring-boot-autoconfigure/build.gradle | 1 + .../client/AutoConfiguredWebClientSsl.java | 55 +++++++++ .../ClientHttpConnectorAutoConfiguration.java | 22 +++- .../client/ClientHttpConnectorFactory.java | 37 +++++++ ...entHttpConnectorFactoryConfiguration.java} | 53 +++------ ...pComponentsClientHttpConnectorFactory.java | 73 ++++++++++++ .../client/JdkClientHttpConnectorFactory.java | 54 +++++++++ .../JettyClientHttpConnectorFactory.java | 65 +++++++++++ .../ReactorClientHttpConnectorFactory.java | 96 ++++++++++++++++ .../client/WebClientAutoConfiguration.java | 11 +- .../function/client/WebClientSsl.java | 66 +++++++++++ ...stractClientHttpConnectorFactoryTests.java | 104 ++++++++++++++++++ ...ntHttpConnectorAutoConfigurationTests.java | 48 ++++++-- ...tpConnectorFactoryConfigurationTests.java} | 21 ++-- ...onentsClientHttpConnectorFactoryTests.java | 31 ++++++ .../JdkClientHttpConnectorFactoryTests.java | 31 ++++++ .../JettyClientHttpConnectorFactoryTests.java | 34 ++++++ ...eactorClientHttpConnectorFactoryTests.java | 49 +++++++++ .../WebClientAutoConfigurationTests.java | 14 ++- .../src/docs/asciidoc/io/rest-client.adoc | 13 +++ .../restclient/webclient/ssl/MyService.java | 39 +++++++ .../io/restclient/webclient/ssl/MyService.kt | 41 +++++++ 22 files changed, 893 insertions(+), 65 deletions(-) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AutoConfiguredWebClientSsl.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactory.java rename spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/{ClientHttpConnectorConfiguration.java => ClientHttpConnectorFactoryConfiguration.java} (55%) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactory.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactory.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactory.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientSsl.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AbstractClientHttpConnectorFactoryTests.java rename spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/{ClientHttpConnectorConfigurationTests.java => ClientHttpConnectorFactoryConfigurationTests.java} (75%) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactoryTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactoryTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactoryTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java create mode 100644 spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/webclient/ssl/MyService.java create mode 100644 spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/webclient/ssl/MyService.kt diff --git a/spring-boot-project/spring-boot-autoconfigure/build.gradle b/spring-boot-project/spring-boot-autoconfigure/build.gradle index 5d1303089b..bd0703269b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-autoconfigure/build.gradle @@ -59,6 +59,7 @@ dependencies { exclude group: "commons-logging", module: "commons-logging" } optional("org.apache.httpcomponents.client5:httpclient5") + optional("org.apache.httpcomponents.core5:httpcore5-reactive"); optional("org.apache.kafka:kafka-streams") optional("org.apache.tomcat.embed:tomcat-embed-core") optional("org.apache.tomcat.embed:tomcat-embed-el") diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AutoConfiguredWebClientSsl.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AutoConfiguredWebClientSsl.java new file mode 100644 index 0000000000..503f42a35d --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AutoConfiguredWebClientSsl.java @@ -0,0 +1,55 @@ +/* + * 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.web.reactive.function.client; + +import java.util.function.Consumer; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * An auto-configured {@link WebClientSsl} implementation. + * + * @author Phillip Webb + */ +class AutoConfiguredWebClientSsl implements WebClientSsl { + + private final ClientHttpConnectorFactory clientHttpConnectorFactory; + + private final SslBundles sslBundles; + + AutoConfiguredWebClientSsl(ClientHttpConnectorFactory clientHttpConnectorFactory, SslBundles sslBundles) { + this.clientHttpConnectorFactory = clientHttpConnectorFactory; + this.sslBundles = sslBundles; + } + + @Override + public Consumer fromBundle(String bundleName) { + return fromBundle(this.sslBundles.getBundle(bundleName)); + } + + @Override + public Consumer fromBundle(SslBundle bundle) { + return (builder) -> { + ClientHttpConnector connector = this.clientHttpConnectorFactory.createClientHttpConnector(bundle); + builder.clientConnector(connector); + }; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java index c84d217f40..34bf67e605 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * 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. @@ -17,9 +17,12 @@ package org.springframework.boot.autoconfigure.web.reactive.function.client; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; @@ -36,19 +39,30 @@ import org.springframework.web.reactive.function.client.WebClient; * HTTP client library. * * @author Brian Clozel + * @author Phillip Webb * @since 2.1.0 */ @AutoConfiguration @ConditionalOnClass(WebClient.class) -@Import({ ClientHttpConnectorConfiguration.ReactorNetty.class, ClientHttpConnectorConfiguration.JettyClient.class, - ClientHttpConnectorConfiguration.HttpClient5.class, ClientHttpConnectorConfiguration.JdkClient.class }) +@AutoConfigureAfter(SslAutoConfiguration.class) +@Import({ ClientHttpConnectorFactoryConfiguration.ReactorNetty.class, + ClientHttpConnectorFactoryConfiguration.JettyClient.class, + ClientHttpConnectorFactoryConfiguration.HttpClient5.class, + ClientHttpConnectorFactoryConfiguration.JdkClient.class }) public class ClientHttpConnectorAutoConfiguration { + @Bean + @Lazy + @ConditionalOnMissingBean(ClientHttpConnector.class) + ClientHttpConnector webClientHttpConnector(ClientHttpConnectorFactory clientHttpConnectorFactory) { + return clientHttpConnectorFactory.createClientHttpConnector(); + } + @Bean @Lazy @Order(0) @ConditionalOnBean(ClientHttpConnector.class) - public WebClientCustomizer clientConnectorCustomizer(ClientHttpConnector clientHttpConnector) { + public WebClientCustomizer webClientHttpConnectorCustomizer(ClientHttpConnector clientHttpConnector) { return (builder) -> builder.clientConnector(clientHttpConnector); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactory.java new file mode 100644 index 0000000000..fa3601a8c2 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactory.java @@ -0,0 +1,37 @@ +/* + * 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.web.reactive.function.client; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.http.client.reactive.ClientHttpConnector; + +/** + * Internal factory used to create {@link ClientHttpConnector} instances. + * + * @param the {@link ClientHttpConnector} type + * @author Phillip Webb + */ +@FunctionalInterface +interface ClientHttpConnectorFactory { + + default T createClientHttpConnector() { + return createClientHttpConnector(null); + } + + T createClientHttpConnector(SslBundle sslBundle); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java similarity index 55% rename from spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorConfiguration.java rename to spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java index ade9dac75b..cf0769bc0c 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java @@ -18,10 +18,7 @@ package org.springframework.boot.autoconfigure.web.reactive.function.client; import org.apache.hc.client5.http.impl.async.HttpAsyncClients; import org.apache.hc.core5.http.nio.AsyncRequestProducer; -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.http.HttpClientTransportOverHTTP; -import org.eclipse.jetty.io.ClientConnector; -import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.apache.hc.core5.reactive.ReactiveResponseConsumer; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -30,13 +27,7 @@ import org.springframework.boot.autoconfigure.reactor.netty.ReactorNettyConfigur import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -import org.springframework.context.annotation.Lazy; -import org.springframework.http.client.reactive.ClientHttpConnector; -import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector; -import org.springframework.http.client.reactive.JdkClientHttpConnector; -import org.springframework.http.client.reactive.JettyClientHttpConnector; import org.springframework.http.client.reactive.JettyResourceFactory; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.http.client.reactive.ReactorResourceFactory; /** @@ -47,30 +38,26 @@ import org.springframework.http.client.reactive.ReactorResourceFactory; * * @author Brian Clozel */ -@Configuration(proxyBeanMethods = false) -class ClientHttpConnectorConfiguration { +class ClientHttpConnectorFactoryConfiguration { @Configuration(proxyBeanMethods = false) @ConditionalOnClass(reactor.netty.http.client.HttpClient.class) - @ConditionalOnMissingBean(ClientHttpConnector.class) + @ConditionalOnMissingBean(ClientHttpConnectorFactory.class) @Import(ReactorNettyConfigurations.ReactorResourceFactoryConfiguration.class) static class ReactorNetty { @Bean - @Lazy - ReactorClientHttpConnector reactorClientHttpConnector(ReactorResourceFactory reactorResourceFactory, + ReactorClientHttpConnectorFactory reactorClientHttpConnectorFactory( + ReactorResourceFactory reactorResourceFactory, ObjectProvider mapperProvider) { - ReactorNettyHttpClientMapper mapper = mapperProvider.orderedStream() - .reduce((before, after) -> (client) -> after.configure(before.configure(client))) - .orElse((client) -> client); - return new ReactorClientHttpConnector(reactorResourceFactory, mapper::configure); + return new ReactorClientHttpConnectorFactory(reactorResourceFactory, mapperProvider::orderedStream); } } @Configuration(proxyBeanMethods = false) @ConditionalOnClass(org.eclipse.jetty.reactive.client.ReactiveRequest.class) - @ConditionalOnMissingBean(ClientHttpConnector.class) + @ConditionalOnMissingBean(ClientHttpConnectorFactory.class) static class JettyClient { @Bean @@ -80,40 +67,32 @@ class ClientHttpConnectorConfiguration { } @Bean - @Lazy - JettyClientHttpConnector jettyClientHttpConnector(JettyResourceFactory jettyResourceFactory) { - SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); - ClientConnector connector = new ClientConnector(); - connector.setSslContextFactory(sslContextFactory); - HttpClientTransportOverHTTP transport = new HttpClientTransportOverHTTP(connector); - HttpClient httpClient = new HttpClient(transport); - return new JettyClientHttpConnector(httpClient, jettyResourceFactory); + JettyClientHttpConnectorFactory jettyClientHttpConnectorFactory(JettyResourceFactory jettyResourceFactory) { + return new JettyClientHttpConnectorFactory(jettyResourceFactory); } } @Configuration(proxyBeanMethods = false) - @ConditionalOnClass({ HttpAsyncClients.class, AsyncRequestProducer.class }) - @ConditionalOnMissingBean(ClientHttpConnector.class) + @ConditionalOnClass({ HttpAsyncClients.class, AsyncRequestProducer.class, ReactiveResponseConsumer.class }) + @ConditionalOnMissingBean(ClientHttpConnectorFactory.class) static class HttpClient5 { @Bean - @Lazy - HttpComponentsClientHttpConnector httpComponentsClientHttpConnector() { - return new HttpComponentsClientHttpConnector(); + HttpComponentsClientHttpConnectorFactory httpComponentsClientHttpConnectorFactory() { + return new HttpComponentsClientHttpConnectorFactory(); } } @Configuration(proxyBeanMethods = false) @ConditionalOnClass(java.net.http.HttpClient.class) - @ConditionalOnMissingBean(ClientHttpConnector.class) + @ConditionalOnMissingBean(ClientHttpConnectorFactory.class) static class JdkClient { @Bean - @Lazy - JdkClientHttpConnector jdkClientHttpConnector() { - return new JdkClientHttpConnector(); + JdkClientHttpConnectorFactory jdkClientHttpConnectorFactory() { + return new JdkClientHttpConnectorFactory(); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactory.java new file mode 100644 index 0000000000..f63ed8b93e --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactory.java @@ -0,0 +1,73 @@ +/* + * 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.web.reactive.function.client; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLException; + +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.client5.http.nio.AsyncClientConnectionManager; +import org.apache.hc.core5.http.nio.ssl.BasicClientTlsStrategy; +import org.apache.hc.core5.net.NamedEndpoint; +import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier; +import org.apache.hc.core5.reactor.ssl.TlsDetails; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslOptions; +import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector; + +/** + * {@link ClientHttpConnectorFactory} for {@link HttpComponentsClientHttpConnector}. + * + * @author Phillip Webb + */ +class HttpComponentsClientHttpConnectorFactory + implements ClientHttpConnectorFactory { + + @Override + public HttpComponentsClientHttpConnector createClientHttpConnector(SslBundle sslBundle) { + HttpAsyncClientBuilder builder = HttpAsyncClients.custom(); + if (sslBundle != null) { + SslOptions options = sslBundle.getOptions(); + SSLContext sslContext = sslBundle.createSslContext(); + SSLSessionVerifier sessionVerifier = new SSLSessionVerifier() { + + @Override + public TlsDetails verify(NamedEndpoint endpoint, SSLEngine sslEngine) throws SSLException { + if (options.getCiphers() != null) { + sslEngine.setEnabledCipherSuites(options.getCiphers().toArray(String[]::new)); + } + if (options.getEnabledProtocols() != null) { + sslEngine.setEnabledProtocols(options.getEnabledProtocols().toArray(String[]::new)); + } + return null; + } + + }; + BasicClientTlsStrategy tlsStrategy = new BasicClientTlsStrategy(sslContext, sessionVerifier); + AsyncClientConnectionManager connectionManager = PoolingAsyncClientConnectionManagerBuilder.create() + .setTlsStrategy(tlsStrategy) + .build(); + builder.setConnectionManager(connectionManager); + } + return new HttpComponentsClientHttpConnector(builder.build()); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactory.java new file mode 100644 index 0000000000..7582318044 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactory.java @@ -0,0 +1,54 @@ +/* + * 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.web.reactive.function.client; + +import java.net.http.HttpClient; +import java.net.http.HttpClient.Builder; +import java.util.Set; + +import javax.net.ssl.SSLParameters; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.http.client.reactive.JdkClientHttpConnector; +import org.springframework.http.client.reactive.JettyClientHttpConnector; +import org.springframework.util.CollectionUtils; + +/** + * {@link ClientHttpConnectorFactory} for {@link JettyClientHttpConnector}. + * + * @author Phillip Webb + */ +class JdkClientHttpConnectorFactory implements ClientHttpConnectorFactory { + + @Override + public JdkClientHttpConnector createClientHttpConnector(SslBundle sslBundle) { + Builder builder = HttpClient.newBuilder(); + if (sslBundle != null) { + builder.sslContext(sslBundle.createSslContext()); + SSLParameters parameters = new SSLParameters(); + parameters.setCipherSuites(asArray(sslBundle.getOptions().getCiphers())); + parameters.setProtocols(asArray(sslBundle.getOptions().getEnabledProtocols())); + builder.sslParameters(parameters); + } + return new JdkClientHttpConnector(builder.build()); + } + + private String[] asArray(Set set) { + return (CollectionUtils.isEmpty(set)) ? null : set.toArray(String[]::new); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactory.java new file mode 100644 index 0000000000..4335ccb146 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactory.java @@ -0,0 +1,65 @@ +/* + * 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.web.reactive.function.client; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.http.HttpClientTransportOverHTTP; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslOptions; +import org.springframework.http.client.reactive.JdkClientHttpConnector; +import org.springframework.http.client.reactive.JettyClientHttpConnector; +import org.springframework.http.client.reactive.JettyResourceFactory; + +/** + * {@link ClientHttpConnectorFactory} for {@link JdkClientHttpConnector}. + * + * @author Phillip Webb + */ +class JettyClientHttpConnectorFactory implements ClientHttpConnectorFactory { + + private final JettyResourceFactory jettyResourceFactory; + + JettyClientHttpConnectorFactory(JettyResourceFactory jettyResourceFactory) { + this.jettyResourceFactory = jettyResourceFactory; + } + + @Override + public JettyClientHttpConnector createClientHttpConnector(SslBundle sslBundle) { + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); + if (sslBundle != null) { + SslOptions options = sslBundle.getOptions(); + if (options.getCiphers() != null) { + sslContextFactory.setIncludeCipherSuites(options.getCiphers().toArray(String[]::new)); + sslContextFactory.setExcludeCipherSuites(); + } + if (options.getEnabledProtocols() != null) { + sslContextFactory.setIncludeProtocols(options.getEnabledProtocols().toArray(String[]::new)); + sslContextFactory.setExcludeProtocols(); + } + sslContextFactory.setSslContext(sslBundle.createSslContext()); + } + ClientConnector connector = new ClientConnector(); + connector.setSslContextFactory(sslContextFactory); + HttpClientTransportOverHTTP transport = new HttpClientTransportOverHTTP(connector); + HttpClient httpClient = new HttpClient(transport); + return new JettyClientHttpConnector(httpClient, this.jettyResourceFactory); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java new file mode 100644 index 0000000000..2b8a0297cc --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java @@ -0,0 +1,96 @@ +/* + * 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.web.reactive.function.client; + +import java.util.function.Supplier; +import java.util.stream.Stream; + +import javax.net.ssl.SSLException; + +import io.netty.handler.ssl.SslContextBuilder; +import reactor.netty.http.client.HttpClient; +import reactor.netty.tcp.SslProvider.SslContextSpec; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslManagerBundle; +import org.springframework.boot.ssl.SslOptions; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.http.client.reactive.ReactorResourceFactory; +import org.springframework.util.function.ThrowingConsumer; + +/** + * {@link ClientHttpConnectorFactory} for {@link ReactorClientHttpConnectorFactory}. + * + * @author Phillip Webb + */ +class ReactorClientHttpConnectorFactory implements ClientHttpConnectorFactory { + + private final ReactorResourceFactory reactorResourceFactory; + + private final Supplier> mappers; + + ReactorClientHttpConnectorFactory(ReactorResourceFactory reactorResourceFactory) { + this(reactorResourceFactory, Stream::empty); + } + + ReactorClientHttpConnectorFactory(ReactorResourceFactory reactorResourceFactory, + Supplier> mappers) { + this.reactorResourceFactory = reactorResourceFactory; + this.mappers = mappers; + } + + @Override + public ReactorClientHttpConnector createClientHttpConnector(SslBundle sslBundle) { + ReactorNettyHttpClientMapper mapper = this.mappers.get() + .reduce((before, after) -> (client) -> after.configure(before.configure(client))) + .orElse((client) -> client); + if (sslBundle != null) { + mapper = new SslConfigurer(sslBundle)::configure; + } + return new ReactorClientHttpConnector(this.reactorResourceFactory, mapper::configure); + + } + + /** + * Configures the Netty {@link HttpClient} with SSL. + */ + private static class SslConfigurer { + + private final SslBundle sslBundle; + + SslConfigurer(SslBundle sslBundle) { + this.sslBundle = sslBundle; + } + + HttpClient configure(HttpClient httpClient) { + return httpClient.secure(ThrowingConsumer.of(this::customizeSsl).throwing(IllegalStateException::new)); + } + + private void customizeSsl(SslContextSpec spec) throws SSLException { + SslOptions options = this.sslBundle.getOptions(); + SslManagerBundle managers = this.sslBundle.getManagers(); + SslContextBuilder builder = SslContextBuilder.forClient() + .keyManager(managers.getKeyManagerFactory()) + .trustManager(managers.getTrustManagerFactory()) + .ciphers(options.getCiphers()) + .protocols(options.getEnabledProtocols()); + spec.sslContext(builder.build()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfiguration.java index e9545c5753..91cdacf227 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * 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. @@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration; +import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.web.codec.CodecCustomizer; import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.context.annotation.Bean; @@ -55,6 +56,14 @@ public class WebClientAutoConfiguration { return builder; } + @Bean + @ConditionalOnMissingBean(WebClientSsl.class) + @ConditionalOnBean(SslBundles.class) + AutoConfiguredWebClientSsl webClientSsl(ClientHttpConnectorFactory clientHttpConnectorFactory, + SslBundles sslBundles) { + return new AutoConfiguredWebClientSsl(clientHttpConnectorFactory, sslBundles); + } + @Configuration(proxyBeanMethods = false) @ConditionalOnBean(CodecCustomizer.class) protected static class WebClientCodecsConfiguration { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientSsl.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientSsl.java new file mode 100644 index 0000000000..ba33e619fb --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientSsl.java @@ -0,0 +1,66 @@ +/* + * 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.web.reactive.function.client; + +import java.util.function.Consumer; + +import org.springframework.boot.ssl.NoSuchSslBundleException; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Interface that can be used to {@link WebClient.Builder#apply apply} SSL configuration + * to a {@link org.springframework.web.reactive.function.client.WebClient.Builder + * WebClient.Builder}. + *

+ * Typically used as follows:

+ * @Bean
+ * public MyBean myBean(WebClient.Builder webClientBuilder, WebClientSsl ssl) {
+ *     WebClient webClient = webClientBuilder.apply(ssl.forBundle("mybundle")).build();
+ *     return new MyBean(webClient);
+ * }
+ * 
NOTE: Apply SSL configuration will replace any previously + * {@link WebClient.Builder#clientConnector configured} {@link ClientHttpConnector}. + * + * @author Phillip Webb + * @since 3.1.0 + */ +public interface WebClientSsl { + + /** + * Return a {@link Consumer} that will apply SSL configuration for the named + * {@link SslBundle} to a + * {@link org.springframework.web.reactive.function.client.WebClient.Builder + * WebClient.Builder}. + * @param bundleName the name of the SSL bundle to apply + * @return a {@link Consumer} to apply the configuration + * @throws NoSuchSslBundleException if a bundle with the provided name does not exist + */ + Consumer fromBundle(String bundleName) throws NoSuchSslBundleException; + + /** + * Return a {@link Consumer} that will apply SSL configuration for the + * {@link SslBundle} to a + * {@link org.springframework.web.reactive.function.client.WebClient.Builder + * WebClient.Builder}. + * @param bundle the SSL bundle to apply + * @return a {@link Consumer} to apply the configuration + */ + Consumer fromBundle(SslBundle bundle); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AbstractClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AbstractClientHttpConnectorFactoryTests.java new file mode 100644 index 0000000000..5210a61028 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AbstractClientHttpConnectorFactoryTests.java @@ -0,0 +1,104 @@ +/* + * 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.web.reactive.function.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundleKey; +import org.springframework.boot.ssl.jks.JksSslStoreBundle; +import org.springframework.boot.ssl.jks.JksSslStoreDetails; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.Ssl; +import org.springframework.boot.web.server.Ssl.ClientAuth; +import org.springframework.boot.web.server.WebServer; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Abstract base class for {@link ClientHttpConnectorFactory} tests. + * + * @author Phillip Webb + */ +abstract class AbstractClientHttpConnectorFactoryTests { + + @Test + void insecureConnection() { + TomcatServletWebServerFactory webServerFactory = new TomcatServletWebServerFactory(0); + WebServer webServer = webServerFactory.getWebServer(); + try { + webServer.start(); + int port = webServer.getPort(); + String url = "http://localhost:%s".formatted(port); + WebClient insecureWebClient = WebClient.builder() + .clientConnector(getFactory().createClientHttpConnector()) + .build(); + String insecureBody = insecureWebClient.get() + .uri(url) + .exchangeToMono((response) -> response.bodyToMono(String.class)) + .block(); + assertThat(insecureBody).contains("HTTP Status 404 – Not Found"); + } + finally { + webServer.stop(); + } + } + + @Test + void secureConnection() throws Exception { + TomcatServletWebServerFactory webServerFactory = new TomcatServletWebServerFactory(0); + Ssl ssl = new Ssl(); + ssl.setClientAuth(ClientAuth.NEED); + ssl.setKeyPassword("password"); + ssl.setKeyStore("classpath:test.jks"); + ssl.setTrustStore("classpath:test.jks"); + webServerFactory.setSsl(ssl); + WebServer webServer = webServerFactory.getWebServer(); + try { + webServer.start(); + int port = webServer.getPort(); + String url = "https://localhost:%s".formatted(port); + WebClient insecureWebClient = WebClient.builder() + .clientConnector(getFactory().createClientHttpConnector()) + .build(); + assertThatExceptionOfType(WebClientRequestException.class).isThrownBy(() -> insecureWebClient.get() + .uri(url) + .exchangeToMono((response) -> response.bodyToMono(String.class)) + .block()); + JksSslStoreDetails storeDetails = JksSslStoreDetails.forLocation("classpath:test.jks"); + JksSslStoreBundle stores = new JksSslStoreBundle(storeDetails, storeDetails); + SslBundle sslBundle = SslBundle.of(stores, SslBundleKey.of("password")); + WebClient secureWebClient = WebClient.builder() + .clientConnector(getFactory().createClientHttpConnector(sslBundle)) + .build(); + String secureBody = secureWebClient.get() + .uri(url) + .exchangeToMono((response) -> response.bodyToMono(String.class)) + .block(); + assertThat(secureBody).contains("HTTP Status 404 – Not Found"); + } + finally { + webServer.stop(); + } + } + + protected abstract ClientHttpConnectorFactory getFactory(); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java index 271a5ad0fc..2a05496baf 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java @@ -52,11 +52,11 @@ class ClientHttpConnectorAutoConfigurationTests { void whenReactorIsAvailableThenReactorBeansAreDefined() { this.contextRunner.run((context) -> { BeanDefinition customizerDefinition = context.getBeanFactory() - .getBeanDefinition("clientConnectorCustomizer"); + .getBeanDefinition("webClientHttpConnectorCustomizer"); assertThat(customizerDefinition.isLazyInit()).isTrue(); - BeanDefinition connectorDefinition = context.getBeanFactory() - .getBeanDefinition("reactorClientHttpConnector"); + BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("webClientHttpConnector"); assertThat(connectorDefinition.isLazyInit()).isTrue(); + assertThat(context).hasBean("reactorClientHttpConnectorFactory"); assertThat(context).hasSingleBean(ReactorResourceFactory.class); }); } @@ -65,11 +65,12 @@ class ClientHttpConnectorAutoConfigurationTests { void whenReactorIsUnavailableThenJettyBeansAreDefined() { this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class)).run((context) -> { BeanDefinition customizerDefinition = context.getBeanFactory() - .getBeanDefinition("clientConnectorCustomizer"); + .getBeanDefinition("webClientHttpConnectorCustomizer"); assertThat(customizerDefinition.isLazyInit()).isTrue(); - BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("jettyClientHttpConnector"); + BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("webClientHttpConnector"); assertThat(connectorDefinition.isLazyInit()).isTrue(); assertThat(context).hasBean("jettyClientResourceFactory"); + assertThat(context).hasBean("jettyClientHttpConnectorFactory"); }); } @@ -78,11 +79,12 @@ class ClientHttpConnectorAutoConfigurationTests { this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class, ReactiveRequest.class)) .run((context) -> { BeanDefinition customizerDefinition = context.getBeanFactory() - .getBeanDefinition("clientConnectorCustomizer"); + .getBeanDefinition("webClientHttpConnectorCustomizer"); assertThat(customizerDefinition.isLazyInit()).isTrue(); BeanDefinition connectorDefinition = context.getBeanFactory() - .getBeanDefinition("httpComponentsClientHttpConnector"); + .getBeanDefinition("webClientHttpConnector"); assertThat(connectorDefinition.isLazyInit()).isTrue(); + assertThat(context).hasBean("httpComponentsClientHttpConnectorFactory"); }); } @@ -92,11 +94,12 @@ class ClientHttpConnectorAutoConfigurationTests { .withClassLoader(new FilteredClassLoader(HttpClient.class, ReactiveRequest.class, HttpAsyncClients.class)) .run((context) -> { BeanDefinition customizerDefinition = context.getBeanFactory() - .getBeanDefinition("clientConnectorCustomizer"); + .getBeanDefinition("webClientHttpConnectorCustomizer"); assertThat(customizerDefinition.isLazyInit()).isTrue(); BeanDefinition connectorDefinition = context.getBeanFactory() - .getBeanDefinition("jdkClientHttpConnector"); + .getBeanDefinition("webClientHttpConnector"); assertThat(connectorDefinition.isLazyInit()).isTrue(); + assertThat(context).hasBean("jdkClientHttpConnectorFactory"); }); } @@ -104,7 +107,7 @@ class ClientHttpConnectorAutoConfigurationTests { void shouldCreateHttpClientBeans() { this.contextRunner.run((context) -> { assertThat(context).hasSingleBean(ReactorResourceFactory.class); - assertThat(context).hasSingleBean(ReactorClientHttpConnector.class); + assertThat(context).hasSingleBean(ClientHttpConnector.class); WebClientCustomizer clientCustomizer = context.getBean(WebClientCustomizer.class); WebClient.Builder builder = mock(WebClient.Builder.class); clientCustomizer.customize(builder); @@ -115,7 +118,18 @@ class ClientHttpConnectorAutoConfigurationTests { @Test void shouldNotOverrideCustomClientConnector() { this.contextRunner.withUserConfiguration(CustomClientHttpConnectorConfig.class).run((context) -> { - assertThat(context).hasSingleBean(ClientHttpConnector.class) + assertThat(context).hasSingleBean(ClientHttpConnector.class).hasBean("customConnector"); + WebClientCustomizer clientCustomizer = context.getBean(WebClientCustomizer.class); + WebClient.Builder builder = mock(WebClient.Builder.class); + clientCustomizer.customize(builder); + then(builder).should().clientConnector(any(ClientHttpConnector.class)); + }); + } + + @Test + void shouldNotOverrideCustomClientConnectorFactory() { + this.contextRunner.withUserConfiguration(CustomClientHttpConnectorFactoryConfig.class).run((context) -> { + assertThat(context).hasSingleBean(ClientHttpConnectorFactory.class) .hasBean("customConnector") .doesNotHaveBean(ReactorResourceFactory.class); WebClientCustomizer clientCustomizer = context.getBean(WebClientCustomizer.class); @@ -128,7 +142,7 @@ class ClientHttpConnectorAutoConfigurationTests { @Test void shouldUseCustomReactorResourceFactory() { this.contextRunner.withUserConfiguration(CustomReactorResourceConfig.class) - .run((context) -> assertThat(context).hasSingleBean(ReactorClientHttpConnector.class) + .run((context) -> assertThat(context).hasSingleBean(ClientHttpConnector.class) .hasSingleBean(ReactorResourceFactory.class) .hasBean("customReactorResourceFactory")); } @@ -143,6 +157,16 @@ class ClientHttpConnectorAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class CustomClientHttpConnectorFactoryConfig { + + @Bean + ClientHttpConnectorFactory customConnector() { + return (sslBundle) -> mock(ClientHttpConnector.class); + } + + } + @Configuration(proxyBeanMethods = false) static class CustomReactorResourceConfig { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java similarity index 75% rename from spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorConfigurationTests.java rename to spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java index 5e25714e18..1f71a5e878 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java @@ -34,12 +34,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; /** - * Tests for {@link ClientHttpConnectorConfiguration}. + * Tests for {@link ClientHttpConnectorFactoryConfiguration}. * * @author Phillip Webb * @author Brian Clozel */ -class ClientHttpConnectorConfigurationTests { +class ClientHttpConnectorFactoryConfigurationTests { @Test void jettyClientHttpConnectorAppliesJettyResourceFactory() { @@ -50,7 +50,8 @@ class ClientHttpConnectorConfigurationTests { jettyResourceFactory.setExecutor(executor); jettyResourceFactory.setByteBufferPool(byteBufferPool); jettyResourceFactory.setScheduler(scheduler); - JettyClientHttpConnector connector = getClientHttpConnector(jettyResourceFactory); + JettyClientHttpConnectorFactory connectorFactory = getJettyClientHttpConnectorFactory(jettyResourceFactory); + JettyClientHttpConnector connector = connectorFactory.createClientHttpConnector(); HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); assertThat(httpClient.getExecutor()).isSameAs(executor); assertThat(httpClient.getByteBufferPool()).isSameAs(byteBufferPool); @@ -61,24 +62,26 @@ class ClientHttpConnectorConfigurationTests { void JettyResourceFactoryHasSslContextFactory() { // gh-16810 JettyResourceFactory jettyResourceFactory = new JettyResourceFactory(); - JettyClientHttpConnector connector = getClientHttpConnector(jettyResourceFactory); + JettyClientHttpConnectorFactory connectorFactory = getJettyClientHttpConnectorFactory(jettyResourceFactory); + JettyClientHttpConnector connector = connectorFactory.createClientHttpConnector(); HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); assertThat(httpClient.getSslContextFactory()).isNotNull(); } - private JettyClientHttpConnector getClientHttpConnector(JettyResourceFactory jettyResourceFactory) { - ClientHttpConnectorConfiguration.JettyClient jettyClient = new ClientHttpConnectorConfiguration.JettyClient(); + private JettyClientHttpConnectorFactory getJettyClientHttpConnectorFactory( + JettyResourceFactory jettyResourceFactory) { + ClientHttpConnectorFactoryConfiguration.JettyClient jettyClient = new ClientHttpConnectorFactoryConfiguration.JettyClient(); // We shouldn't usually call this method directly since it's on a non-proxy config - return ReflectionTestUtils.invokeMethod(jettyClient, "jettyClientHttpConnector", jettyResourceFactory); + return ReflectionTestUtils.invokeMethod(jettyClient, "jettyClientHttpConnectorFactory", jettyResourceFactory); } @Test void shouldApplyHttpClientMapper() { new ReactiveWebApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(ClientHttpConnectorConfiguration.ReactorNetty.class)) + .withConfiguration(AutoConfigurations.of(ClientHttpConnectorFactoryConfiguration.ReactorNetty.class)) .withUserConfiguration(CustomHttpClientMapper.class) .run((context) -> { - context.getBean("reactorClientHttpConnector"); + context.getBean(ReactorClientHttpConnectorFactory.class).createClientHttpConnector(); assertThat(CustomHttpClientMapper.called).isTrue(); }); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactoryTests.java new file mode 100644 index 0000000000..6fb7e80f38 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactoryTests.java @@ -0,0 +1,31 @@ +/* + * 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.web.reactive.function.client; + +/** + * Tests for {@link HttpComponentsClientHttpConnectorFactory}. + * + * @author Phillip Webb + */ +class HttpComponentsClientHttpConnectorFactoryTests extends AbstractClientHttpConnectorFactoryTests { + + @Override + protected ClientHttpConnectorFactory getFactory() { + return new HttpComponentsClientHttpConnectorFactory(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactoryTests.java new file mode 100644 index 0000000000..10914023af --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactoryTests.java @@ -0,0 +1,31 @@ +/* + * 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.web.reactive.function.client; + +/** + * Tests for {@link JdkClientHttpConnectorFactory}. + * + * @author Phillip Webb + */ +class JdkClientHttpConnectorFactoryTests extends AbstractClientHttpConnectorFactoryTests { + + @Override + protected ClientHttpConnectorFactory getFactory() { + return new JdkClientHttpConnectorFactory(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactoryTests.java new file mode 100644 index 0000000000..ad99f85a77 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JettyClientHttpConnectorFactoryTests.java @@ -0,0 +1,34 @@ +/* + * 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.web.reactive.function.client; + +import org.springframework.http.client.reactive.JettyResourceFactory; + +/** + * Tests for {@link JettyClientHttpConnectorFactory}. + * + * @author Phillip Webb + */ +class JettyClientHttpConnectorFactoryTests extends AbstractClientHttpConnectorFactoryTests { + + @Override + protected ClientHttpConnectorFactory getFactory() { + JettyResourceFactory resourceFactory = new JettyResourceFactory(); + return new JettyClientHttpConnectorFactory(resourceFactory); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java new file mode 100644 index 0000000000..632d8f7076 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java @@ -0,0 +1,49 @@ +/* + * 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.web.reactive.function.client; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import org.springframework.http.client.reactive.ReactorResourceFactory; + +/** + * Tests for {@link ReactorClientHttpConnectorFactory}. + * + * @author Phillip Webb + */ +class ReactorClientHttpConnectorFactoryTests extends AbstractClientHttpConnectorFactoryTests { + + private ReactorResourceFactory resourceFactory; + + @BeforeEach + void setup() { + this.resourceFactory = new ReactorResourceFactory(); + this.resourceFactory.afterPropertiesSet(); + } + + @AfterEach + void teardown() { + this.resourceFactory.destroy(); + } + + @Override + protected ClientHttpConnectorFactory getFactory() { + return new ReactorClientHttpConnectorFactory(this.resourceFactory); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfigurationTests.java index f1eccdd04c..853b1fb6ef 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfigurationTests.java @@ -19,6 +19,7 @@ package org.springframework.boot.autoconfigure.web.reactive.function.client; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.web.codec.CodecCustomizer; import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; @@ -39,8 +40,9 @@ import static org.mockito.Mockito.mock; */ class WebClientAutoConfigurationTests { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( - AutoConfigurations.of(ClientHttpConnectorAutoConfiguration.class, WebClientAutoConfiguration.class)); + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ClientHttpConnectorAutoConfiguration.class, + WebClientAutoConfiguration.class, SslAutoConfiguration.class)); @Test void shouldCreateBuilder() { @@ -91,6 +93,14 @@ class WebClientAutoConfigurationTests { }); } + @Test + void shouldCreateWebClientSsl() { + this.contextRunner.run((context) -> { + WebClientSsl webClientSsl = context.getBean(WebClientSsl.class); + assertThat(webClientSsl).isInstanceOf(AutoConfiguredWebClientSsl.class); + }); + } + @Configuration(proxyBeanMethods = false) static class CodecConfiguration { diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc index a0f420cb37..56e778a52e 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/io/rest-client.adoc @@ -90,3 +90,16 @@ To make an application-wide, additive customization to all `WebClient.Builder` i Finally, you can fall back to the original API and use `WebClient.create()`. In that case, no auto-configuration or `WebClientCustomizer` is applied. + + + +[[io.rest-client.webclient.ssl]] +==== WebClient SSL Support +If you need custom SSL configuration on the `ClientHttpConnector` used by the `WebClient`, you can inject a `WebClientSsl` instance that can be used with the builder's `apply` method. + +The `WebClientSsl` interface provides access to any <> that you have defined in your `application.properties` or `application.yaml` file. + +The following code shows a typical example: + +include::code:MyService[] + diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/webclient/ssl/MyService.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/webclient/ssl/MyService.java new file mode 100644 index 0000000000..ef851ecbb6 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/webclient/ssl/MyService.java @@ -0,0 +1,39 @@ +/* + * 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.docs.io.restclient.webclient.ssl; + +import org.neo4j.cypherdsl.core.Relationship.Details; +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientSsl; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +public class MyService { + + private final WebClient webClient; + + public MyService(WebClient.Builder webClientBuilder, WebClientSsl ssl) { + this.webClient = webClientBuilder.baseUrl("https://example.org").apply(ssl.fromBundle("mybundle")).build(); + } + + public Mono
someRestCall(String name) { + return this.webClient.get().uri("/{name}/details", name).retrieve().bodyToMono(Details.class); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/webclient/ssl/MyService.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/webclient/ssl/MyService.kt new file mode 100644 index 0000000000..4b2219dfe7 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/webclient/ssl/MyService.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2022 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.docs.io.restclient.webclient.ssl + +import org.neo4j.cypherdsl.core.Relationship +import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientSsl +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono + +@Service +class MyService(webClientBuilder: WebClient.Builder, ssl: WebClientSsl) { + + private val webClient: WebClient + + init { + webClient = webClientBuilder.baseUrl("https://example.org").apply(ssl.fromBundle("mybundle")).build() + } + + fun someRestCall(name: String?): Mono { + return webClient.get().uri("/{name}/details", name).retrieve().bodyToMono( + Relationship.Details::class.java + ) + } + +} +