Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions spring-addons-starter-rest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,41 @@ com:
client-http-request-factory-impl: jetty
```

Two extra properties tune the underlying client: `http-protocol-version` (forces the HTTP protocol version) and `use-virtual-threads` (sets the application task executor — the `applicationTaskExecutor` bean, virtual-thread when `spring.threads.virtual.enabled=true` — on the client). `use-virtual-threads` is a boolean that, when left unset, defaults to the value of `spring.threads.virtual.enabled`:
```yaml
com:
c4-soft:
springaddons:
rest:
client:
machin-client:
http:
http-protocol-version: HTTP_1_1
use-virtual-threads: true
```
Support depends on the `client-http-request-factory-impl`:
- `jdk`: both properties (`http-protocol-version` via the client version, `use-virtual-threads` via the client executor).
- `jetty`: both properties (`http-protocol-version` via the client transport — `HTTP_2` requires `org.eclipse.jetty.http2:jetty-http2-client` and `jetty-http2-client-transport` on the class-path; `use-virtual-threads` via the client executor).
- `http-components`: neither — the classic Apache client is HTTP/1.1 only and runs on the calling thread (so already on a virtual thread when the caller is). Both properties are ignored.

For anything not exposed as a property, reference an `HttpClientCustomizer` bean with `request-factory-customizer-bean`. It is applied to the implementation-specific client builder just before the request factory is built (`java.net.http.HttpClient.Builder` for `jdk`, `org.apache.hc.client5.http.impl.classic.HttpClientBuilder` for `http-components`, `org.eclipse.jetty.client.HttpClient` for `jetty`):
```yaml
com:
c4-soft:
springaddons:
rest:
client:
machin-client:
http:
request-factory-customizer-bean: machinHttpClientCustomizer
```
```java
@Bean
HttpClientCustomizer<HttpClient.Builder> machinHttpClientCustomizer() {
return builder -> builder.version(HttpClient.Version.HTTP_1_1);
}
```

### <a name="ssl-bundles" />2.6. Working with SSL bundles
`spring-addons-starter-rest` integrates with Spring Boot SSL bundles (introduced in Boot `3.1`). Lets consider the following Boot configurations for SSL with self signed certificates generated with `openssl req -x509 -newkey rsa:4096 -keyout tls.key -out tls.crt -sha256 -days 3650 -passout pass:change-me -subj "/C=PY/ST=Tahiti/L=Papeete/CN=localhost/emailAddress=ch4mp@c4-soft.com"`:
- on the consumed REST API:
Expand Down
12 changes: 12 additions & 0 deletions spring-addons-starter-rest/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@
<artifactId>jetty-client</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.http2</groupId>
<artifactId>jetty-http2-client</artifactId>
<version>${jetty.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.http2</groupId>
<artifactId>jetty-http2-client-transport</artifactId>
<version>${jetty.version}</version>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.slf4j</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,30 @@ public static class ClientHttpRequestFactoryProperties {
*/
private boolean sslCertificatesValidationEnabled = true;

/**
* HTTP protocol version to use. Honored by the JDK and JETTY implementations (ignored by
* HTTP_COMPONENTS, whose classic client is HTTP/1.1 only). For JETTY, HTTP_2 requires
* org.eclipse.jetty.http2:jetty-http2-client on the class-path. When empty, the underlying
* client default is used.
*/
private Optional<java.net.http.HttpClient.Version> httpProtocolVersion = Optional.empty();

/**
* If true, the application task executor (the {@code applicationTaskExecutor} bean, which runs
* on virtual threads when {@code spring.threads.virtual.enabled} is true) is set on the
* underlying client. Honored by the JDK and JETTY implementations (the classic Apache client
* runs on the calling thread, so HTTP_COMPONENTS ignores it). When empty, defaults to the
* value of {@code spring.threads.virtual.enabled}.
*/
private Optional<Boolean> useVirtualThreads = Optional.empty();

/**
* Name of an {@code HttpClientCustomizer} bean applied to the underlying client builder of the
* configured {@link #clientHttpRequestFactoryImpl} just before the request factory is built.
* Enables configuration that is not exposed as properties.
*/
private Optional<String> requestFactoryCustomizerBean = Optional.empty();

@Data
public static class ProxyProperties {
private boolean enabled = true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.c4_soft.springaddons.rest.synchronised;

/**
* Customizes the implementation-specific HTTP client builder just before the request factory is
* built, for whatever is not exposed as a property. {@code B} is the builder of the configured
* {@code client-http-request-factory-impl}: {@code java.net.http.HttpClient.Builder} (JDK),
* {@code org.apache.hc.client5.http.impl.classic.HttpClientBuilder} (HttpComponents) or
* {@code org.eclipse.jetty.client.HttpClient} (Jetty). Reference it per client with the
* {@code http.request-factory-customizer-bean} property.
*/
@FunctionalInterface
public interface HttpClientCustomizer<B> {
void customize(B builder);

@SuppressWarnings({"unchecked", "rawtypes"})
static void apply(Object customizer, Object builder) {
if (customizer instanceof HttpClientCustomizer c) {
c.customize(builder);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.ssl.SSLContextBuilder;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.jspecify.annotations.Nullable;
import com.c4_soft.springaddons.rest.ProxySupport;
import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;

class HttpComponentsClientHttpRequestFactoryHelper {
public static HttpComponentsClientHttpRequestFactory get(ProxySupport proxySupport,
ClientHttpRequestFactoryProperties properties)
ClientHttpRequestFactoryProperties properties, @Nullable Object requestFactoryCustomizer)
throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException {
final var httpClientBuilder = HttpClients.custom();

Expand All @@ -36,6 +37,8 @@ public static HttpComponentsClientHttpRequestFactory get(ProxySupport proxySuppo
.build());
}

HttpClientCustomizer.apply(requestFactoryCustomizer, httpClientBuilder);

final var clientHttpRequestFactory =
new HttpComponentsClientHttpRequestFactory(httpClientBuilder.build());
properties.getReadTimeoutMillis().map(Duration::ofMillis)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
package com.c4_soft.springaddons.rest.synchronised;

import java.time.Duration;
import java.util.concurrent.Executor;
import org.eclipse.jetty.client.HttpProxy;
import org.eclipse.jetty.client.Origin;
import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP;
import org.eclipse.jetty.http2.client.HTTP2Client;
import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.springframework.http.client.JettyClientHttpRequestFactory;
import org.springframework.util.StringUtils;
import org.jspecify.annotations.Nullable;
import com.c4_soft.springaddons.rest.ProxySupport;
import com.c4_soft.springaddons.rest.RestMisconfigurationException;
import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;

class JettyClientHttpRequestFactoryHelper {
public static JettyClientHttpRequestFactory get(ProxySupport proxySupport,
ClientHttpRequestFactoryProperties properties) {
final var httpClient = new org.eclipse.jetty.client.HttpClient();
ClientHttpRequestFactoryProperties properties, @Nullable Executor executor,
@Nullable Object requestFactoryCustomizer) {
final var httpClient = properties.getHttpProtocolVersion()
.map(JettyClientHttpRequestFactoryHelper::httpClientForVersion)
.orElseGet(org.eclipse.jetty.client.HttpClient::new);

if (executor != null) {
httpClient.setExecutor(executor);
}

if (proxySupport != null && proxySupport.isEnabled()) {
final var httpProxy = new HttpProxy(
Expand All @@ -25,10 +38,28 @@ public static JettyClientHttpRequestFactory get(ProxySupport proxySupport,
httpClient.setSslContextFactory(new SslContextFactory.Client(true));
}

HttpClientCustomizer.apply(requestFactoryCustomizer, httpClient);

final var clientHttpRequestFactory = new JettyClientHttpRequestFactory(httpClient);
properties.getReadTimeoutMillis().map(Duration::ofMillis)
.ifPresent(clientHttpRequestFactory::setReadTimeout);

return clientHttpRequestFactory;
}

private static org.eclipse.jetty.client.HttpClient httpClientForVersion(
java.net.http.HttpClient.Version version) {
return switch (version) {
case HTTP_1_1 -> new org.eclipse.jetty.client.HttpClient(new HttpClientTransportOverHTTP());
case HTTP_2 -> {
try {
yield new org.eclipse.jetty.client.HttpClient(
new HttpClientTransportOverHTTP2(new HTTP2Client()));
} catch (NoClassDefFoundError e) {
throw new RestMisconfigurationException(
"http-protocol-version HTTP_2 with the Jetty implementation requires org.eclipse.jetty.http2:jetty-http2-client and jetty-http2-client-transport on the class-path");
}
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

import java.net.URL;
import java.util.Optional;
import java.util.concurrent.Executor;
import org.jspecify.annotations.Nullable;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
import org.springframework.boot.restclient.autoconfigure.RestClientSsl;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestFactory;
Expand All @@ -27,7 +32,8 @@

@Data
@FieldNameConstants
public class RestClientBuilderFactoryBean implements FactoryBean<RestClient.Builder> {
public class RestClientBuilderFactoryBean
implements FactoryBean<RestClient.Builder>, ApplicationContextAware {
private String clientId;
private SystemProxyProperties systemProxyProperties = new SystemProxyProperties();
private SpringAddonsRestProperties restProperties = new SpringAddonsRestProperties();
Expand All @@ -37,6 +43,48 @@ public class RestClientBuilderFactoryBean implements FactoryBean<RestClient.Buil
private Optional<ClientHttpRequestFactory> clientHttpRequestFactory;
private RestClient.Builder restClientBuilder = RestClient.builder();
private Optional<RestClientSsl> ssl;
private @Nullable ApplicationContext applicationContext;

@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}

private @Nullable Executor virtualThreadsExecutor(
SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties http) {
final boolean useVirtualThreads =
http.getUseVirtualThreads().orElseGet(this::isSpringVirtualThreadsEnabled);
if (!useVirtualThreads) {
return null;
}
if (applicationContext == null) {
throw new RestMisconfigurationException(
"use-virtual-threads requires an ApplicationContext to resolve the '%s' bean for REST client '%s'"
.formatted(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME,
clientId));
}
return applicationContext.getBean(
TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, Executor.class);
}

private boolean isSpringVirtualThreadsEnabled() {
return applicationContext != null && Boolean.TRUE.equals(applicationContext.getEnvironment()
.getProperty("spring.threads.virtual.enabled", Boolean.class, Boolean.FALSE));
}

private @Nullable Object requestFactoryCustomizer(
SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties http) {
if (http.getRequestFactoryCustomizerBean().isEmpty()) {
return null;
}
final var beanName = http.getRequestFactoryCustomizerBean().get();
if (applicationContext == null) {
throw new RestMisconfigurationException(
"Cannot resolve request-factory-customizer-bean '%s' for REST client '%s': no ApplicationContext available"
.formatted(beanName, clientId));
}
return applicationContext.getBean(beanName);
}


@Override
Expand All @@ -49,7 +97,8 @@ public RestClient.Builder getObject() throws Exception {
// Handle HTTP or SOCK proxy and set timeouts
builder.requestFactory(clientHttpRequestFactory
.orElseGet(() -> new SpringAddonsClientHttpRequestFactory(systemProxyProperties,
clientProps.getHttp())));
clientProps.getHttp(), virtualThreadsExecutor(clientProps.getHttp()),
requestFactoryCustomizer(clientProps.getHttp()))));

clientProps.getBaseUrl().map(URL::toString).ifPresent(builder::baseUrl);

Expand Down
Loading