diff --git a/langchain4j-easy-rag-spring-boot-starter/pom.xml b/langchain4j-easy-rag-spring-boot-starter/pom.xml index 0b71a52c..78879508 100644 --- a/langchain4j-easy-rag-spring-boot-starter/pom.xml +++ b/langchain4j-easy-rag-spring-boot-starter/pom.xml @@ -53,7 +53,7 @@ org.testcontainers - junit-jupiter + testcontainers-junit-jupiter test diff --git a/langchain4j-elasticsearch-spring-boot-starter/pom.xml b/langchain4j-elasticsearch-spring-boot-starter/pom.xml index 0bff17f0..d3197b92 100644 --- a/langchain4j-elasticsearch-spring-boot-starter/pom.xml +++ b/langchain4j-elasticsearch-spring-boot-starter/pom.xml @@ -15,7 +15,6 @@ jar - 8.19.2 + ${elastic.version} + + org.springframework.boot spring-boot-starter @@ -82,7 +88,7 @@ org.testcontainers - elasticsearch + testcontainers-elasticsearch test diff --git a/langchain4j-http-client-spring-restclient/pom.xml b/langchain4j-http-client-spring-restclient/pom.xml index 7f139c34..f1d99489 100644 --- a/langchain4j-http-client-spring-restclient/pom.xml +++ b/langchain4j-http-client-spring-restclient/pom.xml @@ -28,7 +28,7 @@ org.springframework.boot - spring-boot + spring-boot-starter-restclient @@ -58,17 +58,22 @@ test + + org.springframework.boot + spring-boot-starter-restclient-test + test + + io.projectreactor.netty reactor-netty test - - org.wiremock - wiremock-jetty12 - 3.10.0 + org.wiremock.integrations + wiremock-spring-boot + 4.1.0 test diff --git a/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/HttpClientSettingsStrategy.java b/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/HttpClientSettingsStrategy.java new file mode 100644 index 00000000..543418c6 --- /dev/null +++ b/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/HttpClientSettingsStrategy.java @@ -0,0 +1,17 @@ +package dev.langchain4j.http.client.spring.restclient; + +import org.springframework.http.client.ClientHttpRequestFactory; + +import java.time.Duration; + +/** + * Strategy interface for creating a {@link ClientHttpRequestFactory} with the appropriate + * Spring Boot API, depending on the version available on the classpath. + * + * @see SpringBoot4HttpClientSettings + * @see SpringBoot3HttpClientSettings + */ +interface HttpClientSettingsStrategy { + + ClientHttpRequestFactory createRequestFactory(Duration connectTimeout, Duration readTimeout); +} diff --git a/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringBoot3HttpClientSettings.java b/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringBoot3HttpClientSettings.java new file mode 100644 index 00000000..2498a153 --- /dev/null +++ b/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringBoot3HttpClientSettings.java @@ -0,0 +1,74 @@ +package dev.langchain4j.http.client.spring.restclient; + +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.http.client.ClientHttpRequestFactory; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.time.Duration; + +/** + * The Spring Boot 3.5+ implementation uses reflection because Spring Boot 4 is no longer included in the langchain4J-spring classpath. + */ +class SpringBoot3HttpClientSettings implements HttpClientSettingsStrategy { + + private static final Class DEPRECATED_SETTINGS_CLASS; + private static final Class NEW_SETTINGS_CLASS; + private static final Field DEFAULTS_FIELD; + private static final Method WITH_CONNECT_TIMEOUT_METHOD; + private static final Method WITH_READ_TIMEOUT_METHOD; + private static final Method ADAPT_METHOD; + private static final Method BUILD_METHOD; + + static { + try { + DEPRECATED_SETTINGS_CLASS = Class.forName("org.springframework.boot.web.client.ClientHttpRequestFactorySettings"); + NEW_SETTINGS_CLASS = Class.forName("org.springframework.boot.http.client.ClientHttpRequestFactorySettings"); + + DEFAULTS_FIELD = DEPRECATED_SETTINGS_CLASS.getField("DEFAULTS"); + WITH_CONNECT_TIMEOUT_METHOD = DEPRECATED_SETTINGS_CLASS.getMethod("withConnectTimeout", Duration.class); + WITH_READ_TIMEOUT_METHOD = DEPRECATED_SETTINGS_CLASS.getMethod("withReadTimeout", Duration.class); + ADAPT_METHOD = DEPRECATED_SETTINGS_CLASS.getDeclaredMethod("adapt"); + ADAPT_METHOD.setAccessible(true); + + ClientHttpRequestFactoryBuilder builder = ClientHttpRequestFactoryBuilder.detect(); + BUILD_METHOD = findBuildMethod(builder.getClass(), NEW_SETTINGS_CLASS); + BUILD_METHOD.setAccessible(true); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to initialize SpringBoot3HttpClientSettings", e); + } + } + + @Override + public ClientHttpRequestFactory createRequestFactory(Duration connectTimeout, Duration readTimeout) { + try { + Object deprecatedSettings = DEFAULTS_FIELD.get(null); + + if (connectTimeout != null) { + deprecatedSettings = WITH_CONNECT_TIMEOUT_METHOD.invoke(deprecatedSettings, connectTimeout); + } + if (readTimeout != null) { + deprecatedSettings = WITH_READ_TIMEOUT_METHOD.invoke(deprecatedSettings, readTimeout); + } + + Object newSettings = ADAPT_METHOD.invoke(deprecatedSettings); + return (ClientHttpRequestFactory) BUILD_METHOD.invoke(ClientHttpRequestFactoryBuilder.detect(), newSettings); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to create ClientHttpRequestFactory for Spring Boot 3.x", e); + } + } + + private static Method findBuildMethod(Class clazz, Class newSettingsClass) throws NoSuchMethodException { + for (Method method : clazz.getMethods()) { + if ("build".equals(method.getName()) + && method.getParameterCount() == 1 + && method.getParameterTypes()[0].isAssignableFrom(newSettingsClass)) { + return method; + } + } + if (clazz.getSuperclass() != null) { + return findBuildMethod(clazz.getSuperclass(), newSettingsClass); + } + throw new NoSuchMethodException("No build method accepting " + newSettingsClass.getName()); + } +} diff --git a/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringBoot4HttpClientSettings.java b/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringBoot4HttpClientSettings.java new file mode 100644 index 00000000..f6dcc4f4 --- /dev/null +++ b/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringBoot4HttpClientSettings.java @@ -0,0 +1,23 @@ +package dev.langchain4j.http.client.spring.restclient; + +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.http.client.ClientHttpRequestFactory; + +import java.time.Duration; + +class SpringBoot4HttpClientSettings implements HttpClientSettingsStrategy { + + @Override + public ClientHttpRequestFactory createRequestFactory(Duration connectTimeout, Duration readTimeout) { + HttpClientSettings settings = HttpClientSettings.defaults(); + if (connectTimeout != null) { + settings = settings.withConnectTimeout(connectTimeout); + } + if (readTimeout != null) { + settings = settings.withReadTimeout(readTimeout); + } + return ClientHttpRequestFactoryBuilder.detect() + .build(settings); + } +} diff --git a/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringBootHttpClientSettingsHelper.java b/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringBootHttpClientSettingsHelper.java new file mode 100644 index 00000000..5db1a89d --- /dev/null +++ b/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringBootHttpClientSettingsHelper.java @@ -0,0 +1,40 @@ +package dev.langchain4j.http.client.spring.restclient; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringBootVersion; +import org.springframework.http.client.ClientHttpRequestFactory; + +import java.time.Duration; + +/** + * Factory that detects the Spring Boot version on the classpath and delegates + * {@link ClientHttpRequestFactory} creation to the appropriate strategy. + */ +public final class SpringBootHttpClientSettingsHelper { + + private static final Logger log = LoggerFactory.getLogger(SpringBootHttpClientSettingsHelper.class); + + private static final HttpClientSettingsStrategy STRATEGY; + + static { + String version = SpringBootVersion.getVersion(); + int majorVersion = Integer.parseInt(version.split("\\.")[0]); + log.debug("Detected Spring Boot major version {}", majorVersion); + + HttpClientSettingsStrategy detected; + if (majorVersion >= 4) { + detected = new SpringBoot4HttpClientSettings(); + } else { + detected = new SpringBoot3HttpClientSettings(); + } + STRATEGY = detected; + } + + private SpringBootHttpClientSettingsHelper() { + } + + public static ClientHttpRequestFactory createClientHttpRequestFactory(Duration connectTimeout, Duration readTimeout) { + return STRATEGY.createRequestFactory(connectTimeout, readTimeout); + } +} diff --git a/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringRestClient.java b/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringRestClient.java index df79dba6..44629c43 100644 --- a/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringRestClient.java +++ b/langchain4j-http-client-spring-restclient/src/main/java/dev/langchain4j/http/client/spring/restclient/SpringRestClient.java @@ -8,10 +8,9 @@ import dev.langchain4j.http.client.SuccessfulHttpResponse; import dev.langchain4j.http.client.sse.ServerSentEventListener; import dev.langchain4j.http.client.sse.ServerSentEventParser; -import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; -import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @@ -22,8 +21,11 @@ import java.io.InputStream; import java.net.SocketTimeoutException; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import static dev.langchain4j.http.client.spring.restclient.SpringBootHttpClientSettingsHelper.createClientHttpRequestFactory; import static dev.langchain4j.http.client.sse.ServerSentEventListenerUtils.ignoringExceptions; import static dev.langchain4j.internal.Utils.getOrDefault; @@ -36,21 +38,17 @@ public SpringRestClient(SpringRestClientBuilder builder) { RestClient.Builder restClientBuilder = getOrDefault(builder.restClientBuilder(), RestClient::builder); - ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.defaults(); - if (builder.connectTimeout() != null) { - settings = settings.withConnectTimeout(builder.connectTimeout()); - } - if (builder.readTimeout() != null) { - settings = settings.withReadTimeout(builder.readTimeout()); - } - ClientHttpRequestFactory clientHttpRequestFactory = ClientHttpRequestFactoryBuilder.detect().build(settings); + ClientHttpRequestFactory clientHttpRequestFactory = createClientHttpRequestFactory( + builder.connectTimeout(), + builder.readTimeout() + ); this.delegate = restClientBuilder .requestFactory(clientHttpRequestFactory) .build(); this.streamingRequestExecutor = getOrDefault(builder.streamingRequestExecutor(), () -> { - if (builder.createDefaultStreamingRequestExecutor()) { + if (Boolean.TRUE.equals(builder.createDefaultStreamingRequestExecutor())) { return createDefaultStreamingRequestExecutor(); } else { return null; @@ -78,7 +76,7 @@ public SuccessfulHttpResponse execute(HttpRequest request) throws HttpException return SuccessfulHttpResponse.builder() .statusCode(responseEntity.getStatusCode().value()) - .headers(responseEntity.getHeaders()) + .headers(toHeadersMap(responseEntity.getHeaders())) .body(responseEntity.getBody()) .build(); } catch (RestClientResponseException e) { @@ -111,7 +109,7 @@ public void execute(HttpRequest request, ServerSentEventParser parser, ServerSen SuccessfulHttpResponse response = SuccessfulHttpResponse.builder() .statusCode(statusCode) - .headers(springResponse.getHeaders()) + .headers(toHeadersMap(springResponse.getHeaders())) .build(); ignoringExceptions(() -> listener.onOpen(response)); @@ -161,4 +159,12 @@ private static MultiValueMap toMultiValueMap(Map } return multipart; } + + private static Map> toHeadersMap(HttpHeaders httpHeaders) { + return httpHeaders.headerSet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue + )); + } } diff --git a/langchain4j-milvus-spring-boot-starter/pom.xml b/langchain4j-milvus-spring-boot-starter/pom.xml index 3fe31cf1..ddf6e4a3 100644 --- a/langchain4j-milvus-spring-boot-starter/pom.xml +++ b/langchain4j-milvus-spring-boot-starter/pom.xml @@ -62,7 +62,7 @@ org.testcontainers - milvus + testcontainers-milvus test diff --git a/langchain4j-ollama-spring-boot-starter/pom.xml b/langchain4j-ollama-spring-boot-starter/pom.xml index e07c14ec..753eab7d 100644 --- a/langchain4j-ollama-spring-boot-starter/pom.xml +++ b/langchain4j-ollama-spring-boot-starter/pom.xml @@ -71,7 +71,7 @@ org.testcontainers - junit-jupiter + testcontainers-junit-jupiter test diff --git a/langchain4j-ollama-spring-boot-starter/src/main/java/dev/langchain4j/ollama/spring/AutoConfig.java b/langchain4j-ollama-spring-boot-starter/src/main/java/dev/langchain4j/ollama/spring/AutoConfig.java index f8325a2b..054c120d 100644 --- a/langchain4j-ollama-spring-boot-starter/src/main/java/dev/langchain4j/ollama/spring/AutoConfig.java +++ b/langchain4j-ollama-spring-boot-starter/src/main/java/dev/langchain4j/ollama/spring/AutoConfig.java @@ -11,7 +11,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.core.task.AsyncTaskExecutor; @@ -21,7 +20,11 @@ import static dev.langchain4j.ollama.spring.Properties.PREFIX; -@AutoConfiguration(after = RestClientAutoConfiguration.class) +@AutoConfiguration(afterName = { + "org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration", // Spring Boot 4 support + "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration" // Spring Boot 3 support + } +) @EnableConfigurationProperties(Properties.class) public class AutoConfig { diff --git a/langchain4j-open-ai-spring-boot-starter/src/main/java/dev/langchain4j/openai/spring/AutoConfig.java b/langchain4j-open-ai-spring-boot-starter/src/main/java/dev/langchain4j/openai/spring/AutoConfig.java index 292f17ec..4dedeb7e 100644 --- a/langchain4j-open-ai-spring-boot-starter/src/main/java/dev/langchain4j/openai/spring/AutoConfig.java +++ b/langchain4j-open-ai-spring-boot-starter/src/main/java/dev/langchain4j/openai/spring/AutoConfig.java @@ -11,7 +11,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.core.task.AsyncTaskExecutor; @@ -21,7 +20,11 @@ import static dev.langchain4j.openai.spring.Properties.PREFIX; -@AutoConfiguration(after = RestClientAutoConfiguration.class) +@AutoConfiguration(afterName = { + "org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration", // Spring Boot 4 support + "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration" // Spring Boot 3 support + } +) @EnableConfigurationProperties(Properties.class) public class AutoConfig { diff --git a/langchain4j-spring-boot-starter/pom.xml b/langchain4j-spring-boot-starter/pom.xml index 422af47e..c99ddc78 100644 --- a/langchain4j-spring-boot-starter/pom.xml +++ b/langchain4j-spring-boot-starter/pom.xml @@ -54,7 +54,7 @@ org.springframework.boot - spring-boot-starter-aop + spring-boot-starter-aspectj test diff --git a/langchain4j-voyage-ai-spring-boot-starter/pom.xml b/langchain4j-voyage-ai-spring-boot-starter/pom.xml index d0fa17da..ba2bc942 100644 --- a/langchain4j-voyage-ai-spring-boot-starter/pom.xml +++ b/langchain4j-voyage-ai-spring-boot-starter/pom.xml @@ -51,7 +51,7 @@ org.testcontainers - junit-jupiter + testcontainers-junit-jupiter test diff --git a/pom.xml b/pom.xml index 3fff4dc4..3b02792a 100644 --- a/pom.xml +++ b/pom.xml @@ -42,7 +42,7 @@ 17 17 UTF-8 - 3.5.9 + 4.0.2 2.7.0