From bfa0cca18ad3a409b47fd7788c0eade7abdf9692 Mon Sep 17 00:00:00 2001 From: Sharang Gupta Date: Thu, 22 Oct 2026 16:29:03 +0100 Subject: [PATCH 1/6] Add a ConfigResourceClient for plain text and binary files Port the ConfigResourceClient of the commercial config client into the client module, so that an application can read the plain text and binary files the config server serves with the same settings that loaded its configuration: the config data resolver exposes the RestTemplate that loaded the configuration and a RestClient built from it as beans, and the auto-configuration creates the client from them. Without a label the request asks the server for its default label, a slash in a label is escaped as the config data loader does, and a nested path is sent segment by segment. Fixes gh-789 Signed-off-by: Sharang Gupta --- docs/modules/ROOT/pages/client.adoc | 18 ++ .../client/BinaryResourceConfigClient.java | 56 ++++++ .../config/client/ConfigResourceClient.java | 32 +++ ...ConfigResourceClientAutoConfiguration.java | 52 +++++ ...onfigServerConfigDataLocationResolver.java | 43 +++- .../client/DefaultConfigResourceClient.java | 109 +++++++++++ .../config/client/PlainTextConfigClient.java | 55 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...gResourceClientAutoConfigurationTests.java | 72 +++++++ .../ConfigResourceClientIntegrationTests.java | 69 +++++++ .../DefaultConfigResourceClientTests.java | 184 ++++++++++++++++++ 11 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/BinaryResourceConfigClient.java create mode 100644 spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClient.java create mode 100644 spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java create mode 100644 spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java create mode 100644 spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/PlainTextConfigClient.java create mode 100644 spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java create mode 100644 spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientIntegrationTests.java create mode 100644 spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java diff --git a/docs/modules/ROOT/pages/client.adoc b/docs/modules/ROOT/pages/client.adoc index c31dd19669..22ed3b42da 100644 --- a/docs/modules/ROOT/pages/client.adoc +++ b/docs/modules/ROOT/pages/client.adoc @@ -138,6 +138,24 @@ Label can also be provided as a comma-separated list. This behavior can be useful when working on a feature branch. For instance, you might want to align the config label with your branch but make it optional (in that case, use `spring.cloud.config.label=myfeature,develop`). +[[reading-plain-text-and-binary-files]] +== Reading Plain Text and Binary Files + +The Config Server can also serve plain text and binary files from the configuration repository (see xref:server/serving-plain-text.adoc[Serving Plain Text] and xref:server/serving-binary-files.adoc[Serving Binary Files]). +When the configuration is imported with `spring.config.import=configserver:`, the client registers a `ConfigResourceClient` bean that reads such files with the same settings that loaded the configuration (URI, credentials, headers, TLS settings, timeouts and token), as the following example shows: + +[source,java] +---- +@Autowired +private ConfigResourceClient configResourceClient; + +Resource nginx = configResourceClient.getPlainTextResource("nginx.conf"); +Resource logo = configResourceClient.getBinaryResource("dev", "main", "images/logo.png"); +---- + +Without a profile or label, the client uses `spring.cloud.config.profile` and `spring.cloud.config.label`; without a label, the server's default label is used. +The `RestClient` that backs it is also available as a bean named `configClientRestClient`, and the `RestTemplate` that loaded the configuration as `configClientRestTemplate`, for other components that need to call the Config Server. + [[sharing-configuration-across-applications]] == Sharing Configuration Across Applications diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/BinaryResourceConfigClient.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/BinaryResourceConfigClient.java new file mode 100644 index 0000000000..6a4f7bb4b1 --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/BinaryResourceConfigClient.java @@ -0,0 +1,56 @@ +/* + * Copyright 2013-present 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.cloud.config.client; + +import org.springframework.core.io.Resource; +import org.springframework.web.client.HttpClientErrorException; + +/** + * Provides access to the binary files served by a Spring Cloud Config Server. + * + * @author Craig Walls + * @author Anshul Mehra + * @author Sharang Gupta + * @since 5.1.0 + * @see ConfigResourceClient + */ +public interface BinaryResourceConfigClient { + + /** + * Retrieves a binary file using the configured profile and label. + * @param path the path of the file, relative to the application's folder + * @return the file served by the config server + * @throws IllegalArgumentException when the application name or the config server URI + * is undefined + * @throws HttpClientErrorException when the file is not found + */ + Resource getBinaryResource(String path); + + /** + * Retrieves a binary file. + * @param profile the profile, or {@code null} for the configured one; may be a + * comma-separated list + * @param label the label, or {@code null} for the configured one + * @param path the path of the file, relative to the application's folder + * @return the file served by the config server + * @throws IllegalArgumentException when the application name or the config server URI + * is undefined + * @throws HttpClientErrorException when the file is not found + */ + Resource getBinaryResource(String profile, String label, String path); + +} diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClient.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClient.java new file mode 100644 index 0000000000..efbc1a2945 --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClient.java @@ -0,0 +1,32 @@ +/* + * Copyright 2013-present 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.cloud.config.client; + +/** + * Provides access to the plain text and binary files served by a Spring Cloud Config + * Server. When the configuration is imported with {@code spring.config.import}, a + * {@link ConfigResourceClient} that uses the same connection settings as the import is + * available as a bean. + * + * @author Daniel Lavoie + * @author Sharang Gupta + * @since 5.1.0 + * @see ConfigResourceClientAutoConfiguration + */ +public interface ConfigResourceClient extends PlainTextConfigClient, BinaryResourceConfigClient { + +} diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java new file mode 100644 index 0000000000..141398b90b --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright 2013-present 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.cloud.config.client; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; + +/** + * Auto-configuration for a {@link ConfigResourceClient} that reads plain text and binary + * files from the config server with the {@link RestClient} that the + * {@code spring.config.import} of the config server registers as a bean. + * + * @author Daniel Lavoie + * @author Roy Clarkson + * @author Dylan Roberts + * @author Sharang Gupta + * @since 5.1.0 + * @see ConfigServerConfigDataLocationResolver#REST_CLIENT_BEAN_NAME + */ +@AutoConfiguration(after = ConfigClientAutoConfiguration.class) +@ConditionalOnProperty(name = ConfigClientProperties.PREFIX + ".enabled", matchIfMissing = true) +public class ConfigResourceClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(ConfigResourceClient.class) + @ConditionalOnBean(name = ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME) + public ConfigResourceClient configResourceClient( + @Qualifier(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME) RestClient restClient, + ConfigClientProperties properties) { + return new DefaultConfigResourceClient(restClient, properties); + } + +} diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java index e5bde63796..21fe36add4 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java @@ -23,6 +23,8 @@ import org.apache.commons.logging.Log; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.bootstrap.BootstrapContextClosedEvent; import org.springframework.boot.bootstrap.BootstrapRegistry; import org.springframework.boot.bootstrap.BootstrapRegistry.InstanceSupplier; import org.springframework.boot.bootstrap.ConfigurableBootstrapContext; @@ -51,6 +53,7 @@ import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestTemplate; import static org.springframework.cloud.config.client.ConfigClientProperties.CONFIG_DISCOVERY_ENABLED; @@ -62,6 +65,18 @@ public class ConfigServerConfigDataLocationResolver * Prefix for Config Server imports. */ public static final String PREFIX = "configserver:"; + + /** + * Name of the {@link RestTemplate} bean that loaded the configuration from the config + * server. + */ + public static final String REST_TEMPLATE_BEAN_NAME = "configClientRestTemplate"; + + /** + * Name of the {@link RestClient} bean built from the {@link RestTemplate} that loaded + * the configuration from the config server. + */ + public static final String REST_CLIENT_BEAN_NAME = "configClientRestClient"; static final boolean RSA_IS_PRESENT = ClassUtils .isPresent("org.springframework.security.crypto.encrypt.RsaSecretEncryptor", null); @@ -256,6 +271,7 @@ public List resolveProfileSpecific( ConfigClientRequestTemplateFactory factory = context.get(ConfigClientRequestTemplateFactory.class); return factory.create(); }); + bootstrapContext.addCloseListener(this::registerRestClient); bootstrapContext.registerIfAbsent(PropertyResolver.class, context -> new PropertyResolver(resolverContext.getBinder(), getBindHandler(resolverContext))); @@ -320,10 +336,35 @@ public List getConfigServerInstances(String serviceId) { List locations = new ArrayList<>(); locations.add(resource); - return locations; } + /** + * Expose the {@link RestTemplate} that loaded the configuration, which carries the + * URI, credentials, headers, TLS settings, timeouts and token of the client, and a + * {@link RestClient} built from it, as beans for components that call the config + * server at runtime, such as the {@link ConfigResourceClient}. + */ + private void registerRestClient(BootstrapContextClosedEvent event) { + ConfigurableListableBeanFactory beanFactory = event.getApplicationContext().getBeanFactory(); + if (beanFactory.containsBean(REST_CLIENT_BEAN_NAME) + || !event.getBootstrapContext().isRegistered(RestTemplate.class)) { + return; + } + RestTemplate restTemplate = event.getBootstrapContext().get(RestTemplate.class); + if (!beanFactory.containsBean(REST_TEMPLATE_BEAN_NAME)) { + beanFactory.registerSingleton(REST_TEMPLATE_BEAN_NAME, restTemplate); + } + try { + beanFactory.registerSingleton(REST_CLIENT_BEAN_NAME, RestClient.create(restTemplate)); + } + catch (RuntimeException ex) { + // A RestTemplate that cannot back a RestClient, such as a test double + this.log.debug("Could not build a RestClient from the config client RestTemplate; " + + "the ConfigResourceClient will not be available", ex); + } + } + public static class PropertyResolver { private final Binder binder; diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java new file mode 100644 index 0000000000..68a0ad30ee --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java @@ -0,0 +1,109 @@ +/* + * Copyright 2013-present 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.cloud.config.client; + +import org.springframework.cloud.config.environment.Environment; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * {@link RestClient} based implementation of {@link ConfigResourceClient}. The config + * server URI, the application name and the default profile and label are provided by + * {@link ConfigClientProperties}. + * + * @author Daniel Lavoie + * @author Anshul Mehra + * @author Sharang Gupta + * @since 5.1.0 + */ +public class DefaultConfigResourceClient implements ConfigResourceClient { + + /** + * Request parameter that asks the config server to use its default label. + */ + static final String USE_DEFAULT_LABEL = "useDefaultLabel"; + + private final RestClient restClient; + + private final ConfigClientProperties properties; + + public DefaultConfigResourceClient(RestClient restClient, ConfigClientProperties properties) { + Assert.notNull(restClient, "RestClient must not be null"); + Assert.notNull(properties, "ConfigClientProperties must not be null"); + this.restClient = restClient; + this.properties = properties; + } + + @Override + public Resource getPlainTextResource(String path) { + return getPlainTextResource(null, null, path); + } + + @Override + public Resource getPlainTextResource(String profile, String label, String path) { + return getResource(profile, label, path, false); + } + + @Override + public Resource getBinaryResource(String path) { + return getBinaryResource(null, null, path); + } + + @Override + public Resource getBinaryResource(String profile, String label, String path) { + return getResource(profile, label, path, true); + } + + private Resource getResource(String profile, String label, String path, boolean binary) { + String name = this.properties.getName(); + Assert.hasText(name, "Spring application name is undefined"); + String[] uris = this.properties.getUri(); + Assert.isTrue(!ObjectUtils.isEmpty(uris) && StringUtils.hasText(uris[0]), "Config server URI is undefined"); + Assert.hasText(path, "path is undefined"); + if (!StringUtils.hasText(profile)) { + profile = this.properties.getProfile(); + if (!StringUtils.hasText(profile)) { + profile = ConfigClientProperties.DEFAULT_PROFILE; + } + } + if (label == null) { + label = this.properties.getLabel(); + } + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(uris[0]).pathSegment(name, profile); + if (StringUtils.hasText(label)) { + builder.pathSegment(Environment.denormalize(label)); + } + else { + builder.queryParam(USE_DEFAULT_LABEL); + } + builder.pathSegment(StringUtils.tokenizeToStringArray(path, "/")); + RestClient.RequestHeadersSpec request = this.restClient.get().uri(builder.build().toUri()); + if (StringUtils.hasText(this.properties.getToken())) { + request = request.header(ConfigClientProperties.TOKEN_HEADER, this.properties.getToken()); + } + if (binary) { + request = request.accept(MediaType.APPLICATION_OCTET_STREAM); + } + return request.retrieve().body(Resource.class); + } + +} diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/PlainTextConfigClient.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/PlainTextConfigClient.java new file mode 100644 index 0000000000..96354a6fb1 --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/PlainTextConfigClient.java @@ -0,0 +1,55 @@ +/* + * Copyright 2013-present 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.cloud.config.client; + +import org.springframework.core.io.Resource; +import org.springframework.web.client.HttpClientErrorException; + +/** + * Provides access to the plain text files served by a Spring Cloud Config Server. + * + * @author Daniel Lavoie + * @author Sharang Gupta + * @since 5.1.0 + * @see ConfigResourceClient + */ +public interface PlainTextConfigClient { + + /** + * Retrieves a plain text file using the configured profile and label. + * @param path the path of the file, relative to the application's folder + * @return the file served by the config server + * @throws IllegalArgumentException when the application name or the config server URI + * is undefined + * @throws HttpClientErrorException when the file is not found + */ + Resource getPlainTextResource(String path); + + /** + * Retrieves a plain text file. + * @param profile the profile, or {@code null} for the configured one; may be a + * comma-separated list + * @param label the label, or {@code null} for the configured one + * @param path the path of the file, relative to the application's folder + * @return the file served by the config server + * @throws IllegalArgumentException when the application name or the config server URI + * is undefined + * @throws HttpClientErrorException when the file is not found + */ + Resource getPlainTextResource(String profile, String label, String path); + +} diff --git a/spring-cloud-config-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-config-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index e96d07d506..ad95542bd2 100644 --- a/spring-cloud-config-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-config-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ org.springframework.cloud.config.client.ConfigClientAutoConfiguration +org.springframework.cloud.config.client.ConfigResourceClientAutoConfiguration diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java new file mode 100644 index 0000000000..36f7e2bc24 --- /dev/null +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2013-present 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.cloud.config.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ConfigResourceClientAutoConfiguration}. + * + * @author Sharang Gupta + */ +public class ConfigResourceClientAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(ConfigClientAutoConfiguration.class, ConfigResourceClientAutoConfiguration.class)); + + @Test + void noClientWithoutTheConfigClientRestClient() { + this.contextRunner.run(context -> assertThat(context).doesNotHaveBean(ConfigResourceClient.class)); + } + + @Test + void clientUsesTheConfigClientRestClient() { + this.contextRunner + .withBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME, RestClient.class, + RestClient::create) + .run(context -> assertThat(context).hasSingleBean(ConfigResourceClient.class) + .getBean(ConfigResourceClient.class) + .isInstanceOf(DefaultConfigResourceClient.class)); + } + + @Test + void userDefinedClientIsKept() { + ConfigResourceClient custom = new DefaultConfigResourceClient(RestClient.create(), + new ConfigClientProperties(new org.springframework.mock.env.MockEnvironment())); + this.contextRunner + .withBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME, RestClient.class, + RestClient::create) + .withBean(ConfigResourceClient.class, () -> custom) + .run(context -> assertThat(context).getBean(ConfigResourceClient.class).isSameAs(custom)); + } + + @Test + void noClientWhenTheConfigClientIsDisabled() { + this.contextRunner + .withBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME, RestClient.class, + RestClient::create) + .withPropertyValues("spring.cloud.config.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(ConfigResourceClient.class)); + } + +} diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientIntegrationTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientIntegrationTests.java new file mode 100644 index 0000000000..8e86b838c3 --- /dev/null +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientIntegrationTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2013-present 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.cloud.config.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the config server import exposes its {@link RestTemplate} and a + * {@link RestClient} as beans, and that a {@link ConfigResourceClient} is built from + * them. + * + * @author Sharang Gupta + */ +public class ConfigResourceClientIntegrationTests { + + @Test + void configDataImportExposesTheRestClientAndTheResourceClient() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder(TestConfig.class) + .web(WebApplicationType.NONE) + .run("--spring.config.import=optional:configserver:http://localhost:1", "--spring.cloud.config.name=app")) { + assertThat(context.getBean(ConfigServerConfigDataLocationResolver.REST_TEMPLATE_BEAN_NAME)) + .isInstanceOf(RestTemplate.class); + assertThat(context.getBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME)) + .isInstanceOf(RestClient.class); + assertThat(context.getBean(ConfigResourceClient.class)).isInstanceOf(DefaultConfigResourceClient.class); + } + } + + @Test + void noResourceClientWithoutTheConfigServerImport() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder(TestConfig.class) + .web(WebApplicationType.NONE) + .run("--spring.cloud.config.import-check.enabled=false")) { + assertThat(context.containsBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME)).isFalse(); + assertThat(context.getBeanProvider(ConfigResourceClient.class).getIfAvailable()).isNull(); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestConfig { + + } + +} diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java new file mode 100644 index 0000000000..09fe811a4c --- /dev/null +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java @@ -0,0 +1,184 @@ +/* + * Copyright 2013-present 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.cloud.config.client; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for {@link DefaultConfigResourceClient}. + * + * @author Sharang Gupta + */ +public class DefaultConfigResourceClientTests { + + private final RestTemplate restTemplate = new RestTemplate(); + + private final MockRestServiceServer server = MockRestServiceServer.bindTo(this.restTemplate).build(); + + private final ConfigClientProperties properties = new ConfigClientProperties(new MockEnvironment()); + + private final ConfigResourceClient client = new DefaultConfigResourceClient(RestClient.create(this.restTemplate), + this.properties); + + @BeforeEach + void setUp() { + this.properties.setName("app"); + this.properties.setProfile("dev"); + this.properties.setLabel("main"); + this.properties.setUri(new String[] { "http://configserver:8888" }); + } + + @Test + void plainTextResourceUsesTheConfiguredProfileAndLabel() throws IOException { + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andExpect(method(HttpMethod.GET)) + .andExpect(request -> assertThat(request.getHeaders().getAccept()) + .doesNotContain(MediaType.APPLICATION_OCTET_STREAM)) + .andRespond(withSuccess("server { }", MediaType.TEXT_PLAIN)); + + Resource resource = this.client.getPlainTextResource("nginx.conf"); + + assertThat(resource.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("server { }"); + this.server.verify(); + } + + @Test + void binaryResourceAcceptsOctetStream() throws IOException { + this.server.expect(requestTo("http://configserver:8888/app/dev/main/images/logo.png")) + .andExpect(header(HttpHeaders.ACCEPT, MediaType.APPLICATION_OCTET_STREAM_VALUE)) + .andRespond(withSuccess(new byte[] { 1, 2, 3 }, MediaType.APPLICATION_OCTET_STREAM)); + + Resource resource = this.client.getBinaryResource("images/logo.png"); + + assertThat(resource.getContentAsByteArray()).containsExactly(1, 2, 3); + this.server.verify(); + } + + @Test + void profileAndLabelOverrideTheConfiguredOnes() throws IOException { + this.server.expect(requestTo("http://configserver:8888/app/test,cloud/release/nginx.conf")) + .andRespond(withSuccess("release", MediaType.TEXT_PLAIN)); + + Resource resource = this.client.getPlainTextResource("test,cloud", "release", "nginx.conf"); + + assertThat(resource.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("release"); + this.server.verify(); + } + + @Test + void defaultProfileIsUsedWhenNoneIsConfigured() throws IOException { + this.properties.setProfile(""); + this.server.expect(requestTo("http://configserver:8888/app/default/main/nginx.conf")) + .andRespond(withSuccess("default", MediaType.TEXT_PLAIN)); + + Resource resource = this.client.getPlainTextResource("nginx.conf"); + + assertThat(resource.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("default"); + this.server.verify(); + } + + @Test + void serverDefaultLabelIsUsedWhenNoneIsConfigured() throws IOException { + this.properties.setLabel(null); + this.server.expect(requestTo("http://configserver:8888/app/dev/nginx.conf?useDefaultLabel")) + .andRespond(withSuccess("default label", MediaType.TEXT_PLAIN)); + + Resource resource = this.client.getPlainTextResource("nginx.conf"); + + assertThat(resource.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("default label"); + this.server.verify(); + } + + @Test + void slashInLabelIsEscaped() throws IOException { + this.server.expect(requestTo("http://configserver:8888/app/dev/feature(_)x/nginx.conf")) + .andRespond(withSuccess("feature", MediaType.TEXT_PLAIN)); + + Resource resource = this.client.getPlainTextResource(null, "feature/x", "nginx.conf"); + + assertThat(resource.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("feature"); + this.server.verify(); + } + + @Test + void tokenIsSent() throws IOException { + this.properties.setToken("secret"); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andExpect(header(ConfigClientProperties.TOKEN_HEADER, "secret")) + .andRespond(withSuccess("server { }", MediaType.TEXT_PLAIN)); + + this.client.getPlainTextResource("nginx.conf"); + + this.server.verify(); + } + + @Test + void missingFileIsReportedAsClientError() { + this.server.expect(requestTo("http://configserver:8888/app/dev/main/missing.conf")) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + + assertThatThrownBy(() -> this.client.getPlainTextResource("missing.conf")) + .isInstanceOf(HttpClientErrorException.class); + } + + @Test + void missingApplicationNameIsRejected() { + this.properties.setName(""); + + assertThatIllegalArgumentException().isThrownBy(() -> this.client.getPlainTextResource("nginx.conf")) + .withMessageContaining("application name"); + } + + @Test + void missingConfigServerUriIsRejected() { + this.properties.setUri(new String[] { "" }); + + assertThatIllegalArgumentException().isThrownBy(() -> this.client.getBinaryResource("nginx.conf")) + .withMessageContaining("URI"); + } + + @Test + void missingPathIsRejected() { + assertThatIllegalArgumentException().isThrownBy(() -> this.client.getPlainTextResource("")) + .withMessageContaining("path"); + } + +} From e6ce44f23585d21073e308efdbdb19cb6c40c609 Mon Sep 17 00:00:00 2001 From: Sharang Gupta Date: Thu, 22 Oct 2026 17:14:53 +0100 Subject: [PATCH 2/6] Use the config server import's properties for the ConfigResourceClient A context can hold two ConfigClientProperties beans, the one resolved by the config server import and the one bound by ConfigClientAutoConfiguration, which made the configResourceClient bean fail to resolve its dependency in the sample application. Prefer the imported properties, which describe the server the RestClient talks to. Signed-off-by: Sharang Gupta --- ...ConfigResourceClientAutoConfiguration.java | 9 +++-- ...onfigServerConfigDataLocationResolver.java | 9 +++-- ...gResourceClientAutoConfigurationTests.java | 33 ++++++++++++++++++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java index 141398b90b..5bd7376e7c 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java @@ -16,6 +16,7 @@ package org.springframework.cloud.config.client; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -45,8 +46,12 @@ public class ConfigResourceClientAutoConfiguration { @ConditionalOnBean(name = ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME) public ConfigResourceClient configResourceClient( @Qualifier(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME) RestClient restClient, - ConfigClientProperties properties) { - return new DefaultConfigResourceClient(restClient, properties); + @Qualifier(ConfigServerConfigDataLocationResolver.PROPERTIES_BEAN_NAME) ObjectProvider importedProperties, + ObjectProvider properties) { + // The properties resolved by the config server import describe the server the + // RestClient talks to; a context may also hold the ones bound by + // ConfigClientAutoConfiguration. + return new DefaultConfigResourceClient(restClient, importedProperties.getIfAvailable(properties::getObject)); } } diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java index 21fe36add4..0a29eb5a87 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java @@ -66,6 +66,12 @@ public class ConfigServerConfigDataLocationResolver */ public static final String PREFIX = "configserver:"; + /** + * Name of the {@link ConfigClientProperties} bean resolved by the config server + * import. + */ + public static final String PROPERTIES_BEAN_NAME = "configDataConfigClientProperties"; + /** * Name of the {@link RestTemplate} bean that loaded the configuration from the config * server. @@ -256,8 +262,7 @@ public List resolveProfileSpecific( InstanceSupplier.of(properties).withScope(BootstrapRegistry.Scope.PROTOTYPE)); bootstrapContext.addCloseListener(event -> event.getApplicationContext() .getBeanFactory() - .registerSingleton("configDataConfigClientProperties", - event.getBootstrapContext().get(ConfigClientProperties.class))); + .registerSingleton(PROPERTIES_BEAN_NAME, event.getBootstrapContext().get(ConfigClientProperties.class))); bootstrapContext.registerIfAbsent(ConfigClientRequestTemplateFactory.class, context -> { ConfigClientRequestTemplateFactory factory = new ConfigClientRequestTemplateFactory(log, diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java index 36f7e2bc24..a079bebc34 100644 --- a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java @@ -20,9 +20,15 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.http.MediaType; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; /** * Tests for {@link ConfigResourceClientAutoConfiguration}. @@ -52,7 +58,7 @@ void clientUsesTheConfigClientRestClient() { @Test void userDefinedClientIsKept() { ConfigResourceClient custom = new DefaultConfigResourceClient(RestClient.create(), - new ConfigClientProperties(new org.springframework.mock.env.MockEnvironment())); + new ConfigClientProperties(new MockEnvironment())); this.contextRunner .withBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME, RestClient.class, RestClient::create) @@ -60,6 +66,31 @@ void userDefinedClientIsKept() { .run(context -> assertThat(context).getBean(ConfigResourceClient.class).isSameAs(custom)); } + @Test + void propertiesOfTheConfigServerImportTakePrecedence() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + server.expect(requestTo("http://imported:8888/application/default/nginx.conf?useDefaultLabel")) + .andRespond(withSuccess("server { }", MediaType.TEXT_PLAIN)); + this.contextRunner + .withBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME, RestClient.class, + () -> RestClient.create(restTemplate)) + .withBean(ConfigServerConfigDataLocationResolver.PROPERTIES_BEAN_NAME, ConfigClientProperties.class, + () -> properties("http://imported:8888")) + .withBean("configClientProperties", ConfigClientProperties.class, () -> properties("http://bound:8888")) + .run(context -> { + assertThat(context).getBeans(ConfigClientProperties.class).hasSize(2); + context.getBean(ConfigResourceClient.class).getPlainTextResource("nginx.conf"); + server.verify(); + }); + } + + private static ConfigClientProperties properties(String uri) { + ConfigClientProperties properties = new ConfigClientProperties(new MockEnvironment()); + properties.setUri(new String[] { uri }); + return properties; + } + @Test void noClientWhenTheConfigClientIsDisabled() { this.contextRunner From 2b446c347fbbb575932f2bc3d88a639472407c58 Mon Sep 17 00:00:00 2001 From: Sharang Gupta Date: Fri, 23 Oct 2026 03:45:27 +0100 Subject: [PATCH 3/6] Use the import's credentials, URIs, labels and a size limit in the ConfigResourceClient The client now behaves like the config server import when it reads a file: the credentials of each URI (explicit, from the URI's user info, or the configured authorization header) are sent, every URI is tried according to the multiple-uri-strategy, and a comma-separated label is tried one label at a time, a 404 moving on to the next. A file is read into memory up to spring.cloud.config.resource-client.max-size (1MB by default) and rejected beyond it, so a large file cannot exhaust the heap. The RestTemplate and RestClient of the import are no longer registered as beans, which would clash with an application's own by-type injection and expose the config server's headers and interceptors. The resolver registers a package-private ConfigServerRestClientHolder instead, which only the auto-configuration consumes. See gh-789 Signed-off-by: Sharang Gupta --- docs/modules/ROOT/pages/client.adoc | 3 +- docs/modules/ROOT/partials/_configprops.adoc | 1 + .../config/client/ConfigClientProperties.java | 36 ++++ ...ConfigResourceClientAutoConfiguration.java | 14 +- ...onfigServerConfigDataLocationResolver.java | 36 ++-- .../client/ConfigServerRestClientHolder.java | 51 ++++++ .../client/DefaultConfigResourceClient.java | 172 +++++++++++++++++- ...gResourceClientAutoConfigurationTests.java | 17 +- .../ConfigResourceClientIntegrationTests.java | 18 +- .../DefaultConfigResourceClientTests.java | 168 +++++++++++++++++ 10 files changed, 457 insertions(+), 59 deletions(-) create mode 100644 spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerRestClientHolder.java diff --git a/docs/modules/ROOT/pages/client.adoc b/docs/modules/ROOT/pages/client.adoc index 22ed3b42da..6aff1957f1 100644 --- a/docs/modules/ROOT/pages/client.adoc +++ b/docs/modules/ROOT/pages/client.adoc @@ -154,7 +154,8 @@ Resource logo = configResourceClient.getBinaryResource("dev", "main", "images/lo ---- Without a profile or label, the client uses `spring.cloud.config.profile` and `spring.cloud.config.label`; without a label, the server's default label is used. -The `RestClient` that backs it is also available as a bean named `configClientRestClient`, and the `RestTemplate` that loaded the configuration as `configClientRestTemplate`, for other components that need to call the Config Server. +Like the import, it tries each configured URI according to `spring.cloud.config.multiple-uri-strategy`, with the credentials of that URI, and tries a comma-separated label one label at a time. +A file is read into memory, so a file larger than `spring.cloud.config.resource-client.max-size` (1MB by default) is rejected. [[sharing-configuration-across-applications]] == Sharing Configuration Across Applications diff --git a/docs/modules/ROOT/partials/_configprops.adoc b/docs/modules/ROOT/partials/_configprops.adoc index 6b14041581..1f29bd5644 100644 --- a/docs/modules/ROOT/partials/_configprops.adoc +++ b/docs/modules/ROOT/partials/_configprops.adoc @@ -19,6 +19,7 @@ |spring.cloud.config.profile | `+++default+++` | The default profile to use when fetching remote configuration (comma-separated). Default is "default". |spring.cloud.config.request-connect-timeout | | timeout on waiting to connect to the Config Server. |spring.cloud.config.request-read-timeout | | timeout on waiting to read data from the Config Server. +|spring.cloud.config.resource-client.max-size | `+++1MB+++` | Maximum size of a file read from the config server; a larger file is rejected instead of being loaded into memory. |spring.cloud.config.retry.enabled | `+++false+++` | Whether to retry failed requests to the Config Server. Only has an effect when spring.cloud.config.fail-fast is enabled. |spring.cloud.config.retry.initial-interval | `+++1000+++` | Initial retry interval in milliseconds. |spring.cloud.config.retry.max-attempts | `+++6+++` | Maximum number of attempts. diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java index 28bfb3fd3b..4d07191ecb 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java @@ -33,6 +33,7 @@ import org.springframework.core.env.Environment; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; +import org.springframework.util.unit.DataSize; import org.springframework.web.util.UriComponentsBuilder; /** @@ -143,6 +144,12 @@ public class ConfigClientProperties { */ private Discovery discovery = new Discovery(); + /** + * Settings of the {@link ConfigResourceClient} that reads plain text and binary files + * from the config server. + */ + private ResourceClient resourceClient = new ResourceClient(); + /** * TLS properties. */ @@ -284,6 +291,14 @@ public Discovery getDiscovery() { return this.discovery; } + public ResourceClient getResourceClient() { + return this.resourceClient; + } + + public void setResourceClient(ResourceClient resourceClient) { + this.resourceClient = resourceClient; + } + public void setDiscovery(Discovery discovery) { this.discovery = discovery; } @@ -469,6 +484,27 @@ public String getUri() { } + /** + * Settings of the {@link ConfigResourceClient}. + */ + public static class ResourceClient { + + /** + * Maximum size of a file read from the config server; a larger file is rejected + * instead of being loaded into memory. + */ + private DataSize maxSize = DataSize.ofMegabytes(1); + + public DataSize getMaxSize() { + return this.maxSize; + } + + public void setMaxSize(DataSize maxSize) { + this.maxSize = maxSize; + } + + } + /** * Discovery properties. */ diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java index 5bd7376e7c..7997f5f838 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.java @@ -23,19 +23,17 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; -import org.springframework.web.client.RestClient; /** * Auto-configuration for a {@link ConfigResourceClient} that reads plain text and binary - * files from the config server with the {@link RestClient} that the - * {@code spring.config.import} of the config server registers as a bean. + * files from the config server with the connection that loaded the configuration, when it + * was imported with {@code spring.config.import}. * * @author Daniel Lavoie * @author Roy Clarkson * @author Dylan Roberts * @author Sharang Gupta * @since 5.1.0 - * @see ConfigServerConfigDataLocationResolver#REST_CLIENT_BEAN_NAME */ @AutoConfiguration(after = ConfigClientAutoConfiguration.class) @ConditionalOnProperty(name = ConfigClientProperties.PREFIX + ".enabled", matchIfMissing = true) @@ -43,15 +41,15 @@ public class ConfigResourceClientAutoConfiguration { @Bean @ConditionalOnMissingBean(ConfigResourceClient.class) - @ConditionalOnBean(name = ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME) - public ConfigResourceClient configResourceClient( - @Qualifier(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME) RestClient restClient, + @ConditionalOnBean(ConfigServerRestClientHolder.class) + public ConfigResourceClient configResourceClient(ConfigServerRestClientHolder restClientHolder, @Qualifier(ConfigServerConfigDataLocationResolver.PROPERTIES_BEAN_NAME) ObjectProvider importedProperties, ObjectProvider properties) { // The properties resolved by the config server import describe the server the // RestClient talks to; a context may also hold the ones bound by // ConfigClientAutoConfiguration. - return new DefaultConfigResourceClient(restClient, importedProperties.getIfAvailable(properties::getObject)); + return new DefaultConfigResourceClient(restClientHolder.getRestClient(), + importedProperties.getIfAvailable(properties::getObject)); } } diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java index 0a29eb5a87..5e2011d637 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java @@ -72,17 +72,6 @@ public class ConfigServerConfigDataLocationResolver */ public static final String PROPERTIES_BEAN_NAME = "configDataConfigClientProperties"; - /** - * Name of the {@link RestTemplate} bean that loaded the configuration from the config - * server. - */ - public static final String REST_TEMPLATE_BEAN_NAME = "configClientRestTemplate"; - - /** - * Name of the {@link RestClient} bean built from the {@link RestTemplate} that loaded - * the configuration from the config server. - */ - public static final String REST_CLIENT_BEAN_NAME = "configClientRestClient"; static final boolean RSA_IS_PRESENT = ClassUtils .isPresent("org.springframework.security.crypto.encrypt.RsaSecretEncryptor", null); @@ -276,7 +265,7 @@ public List resolveProfileSpecific( ConfigClientRequestTemplateFactory factory = context.get(ConfigClientRequestTemplateFactory.class); return factory.create(); }); - bootstrapContext.addCloseListener(this::registerRestClient); + bootstrapContext.addCloseListener(this::registerRestClientHolder); bootstrapContext.registerIfAbsent(PropertyResolver.class, context -> new PropertyResolver(resolverContext.getBinder(), getBindHandler(resolverContext))); @@ -345,23 +334,24 @@ public List getConfigServerInstances(String serviceId) { } /** - * Expose the {@link RestTemplate} that loaded the configuration, which carries the - * URI, credentials, headers, TLS settings, timeouts and token of the client, and a - * {@link RestClient} built from it, as beans for components that call the config - * server at runtime, such as the {@link ConfigResourceClient}. + * Keep the {@link RestTemplate} that loaded the configuration, which carries the URI, + * credentials, headers, TLS settings, timeouts and interceptors of the client, + * available as a {@link RestClient} to the {@link ConfigResourceClient}, without + * exposing it as a {@link RestClient} bean. */ - private void registerRestClient(BootstrapContextClosedEvent event) { + private void registerRestClientHolder(BootstrapContextClosedEvent event) { ConfigurableListableBeanFactory beanFactory = event.getApplicationContext().getBeanFactory(); - if (beanFactory.containsBean(REST_CLIENT_BEAN_NAME) + // The listener is added on every resolution (one per import location and + // profile pass) and runs that many times when the bootstrap context closes, + // while a singleton can only be registered once. + if (beanFactory.containsBean(ConfigServerRestClientHolder.BEAN_NAME) || !event.getBootstrapContext().isRegistered(RestTemplate.class)) { return; } - RestTemplate restTemplate = event.getBootstrapContext().get(RestTemplate.class); - if (!beanFactory.containsBean(REST_TEMPLATE_BEAN_NAME)) { - beanFactory.registerSingleton(REST_TEMPLATE_BEAN_NAME, restTemplate); - } try { - beanFactory.registerSingleton(REST_CLIENT_BEAN_NAME, RestClient.create(restTemplate)); + RestClient restClient = RestClient.create(event.getBootstrapContext().get(RestTemplate.class)); + beanFactory.registerSingleton(ConfigServerRestClientHolder.BEAN_NAME, + new ConfigServerRestClientHolder(restClient)); } catch (RuntimeException ex) { // A RestTemplate that cannot back a RestClient, such as a test double diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerRestClientHolder.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerRestClientHolder.java new file mode 100644 index 0000000000..254c421dbc --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerRestClientHolder.java @@ -0,0 +1,51 @@ +/* + * Copyright 2013-present 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.cloud.config.client; + +import org.springframework.util.Assert; +import org.springframework.web.client.RestClient; + +/** + * Holds the {@link RestClient} built from the {@code RestTemplate} that loaded the + * configuration from the config server, which carries the client's headers, credentials, + * TLS settings, timeouts and interceptors. It is registered as a bean of its own type, + * not as a {@link RestClient}, so that it is neither ambiguous with the application's own + * {@link RestClient} nor injected into it by accident. + * + * @author Sharang Gupta + * @since 5.1.0 + */ +final class ConfigServerRestClientHolder { + + /** + * Name of the bean registered when the configuration is imported from the config + * server. + */ + static final String BEAN_NAME = "configServerRestClientHolder"; + + private final RestClient restClient; + + ConfigServerRestClientHolder(RestClient restClient) { + Assert.notNull(restClient, "RestClient must not be null"); + this.restClient = restClient; + } + + RestClient getRestClient() { + return this.restClient; + } + +} diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java index 68a0ad30ee..f298382462 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java @@ -16,19 +16,40 @@ package org.springframework.cloud.config.client; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.springframework.cloud.config.client.ConfigClientProperties.Credentials; +import org.springframework.cloud.config.client.ConfigClientProperties.MultipleUriStrategy; import org.springframework.cloud.config.environment.Environment; +import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; import org.springframework.web.util.UriComponentsBuilder; /** * {@link RestClient} based implementation of {@link ConfigResourceClient}. The config - * server URI, the application name and the default profile and label are provided by - * {@link ConfigClientProperties}. + * server URIs and their credentials, the application name and the default profile and + * label are provided by {@link ConfigClientProperties}, and are used the way the config + * server import uses them: every URI is tried according to the + * {@link MultipleUriStrategy}, a comma-separated label is tried label by label, and a + * file larger than {@link ConfigClientProperties.ResourceClient#getMaxSize()} is + * rejected. * * @author Daniel Lavoie * @author Anshul Mehra @@ -42,6 +63,8 @@ public class DefaultConfigResourceClient implements ConfigResourceClient { */ static final String USE_DEFAULT_LABEL = "useDefaultLabel"; + private static final int MAX_ERROR_BODY = 8 * 1024; + private final RestClient restClient; private final ConfigClientProperties properties; @@ -85,10 +108,63 @@ private Resource getResource(String profile, String label, String path, boolean profile = ConfigClientProperties.DEFAULT_PROFILE; } } + List labels = labels(label); + for (int i = 0; i < labels.size() - 1; i++) { + try { + return getResource(name, profile, labels.get(i), path, binary); + } + catch (HttpClientErrorException.NotFound ex) { + // not in this label, try the next one + } + } + return getResource(name, profile, labels.get(labels.size() - 1), path, binary); + } + + /** + * The labels to try in order: the given one, else the configured one, which may be a + * comma-separated list; {@code null} stands for the server's default label. + */ + private List labels(String label) { if (label == null) { label = this.properties.getLabel(); } - UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(uris[0]).pathSegment(name, profile); + if (!StringUtils.hasText(label)) { + return Collections.singletonList(null); + } + return Arrays.stream(StringUtils.commaDelimitedListToStringArray(label)).map(String::trim).toList(); + } + + /** + * Try the config server URIs in order, the way the config server import does: an + * unreachable server is skipped, and an error response moves on to the next URI only + * with {@link MultipleUriStrategy#ALWAYS}. + */ + private Resource getResource(String name, String profile, String label, String path, boolean binary) { + int last = this.properties.getUri().length - 1; + for (int i = 0; i < last; i++) { + try { + return getResource(i, name, profile, label, path, binary); + } + catch (HttpClientErrorException | HttpServerErrorException ex) { + if (this.properties.getMultipleUriStrategy() != MultipleUriStrategy.ALWAYS) { + throw ex; + } + } + catch (ResourceAccessException ex) { + // cannot reach this server, try the next one + } + } + return getResource(last, name, profile, label, path, binary); + } + + private Resource getResource(int uriIndex, String name, String profile, String label, String path, boolean binary) { + Credentials credentials = this.properties.getCredentials(uriIndex); + URI uri = buildUri(credentials.getUri(), name, profile, label, path); + return retrieve(uri, credentials, path, binary); + } + + private static URI buildUri(String baseUri, String name, String profile, String label, String path) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(baseUri).pathSegment(name, profile); if (StringUtils.hasText(label)) { builder.pathSegment(Environment.denormalize(label)); } @@ -96,14 +172,94 @@ private Resource getResource(String profile, String label, String path, boolean builder.queryParam(USE_DEFAULT_LABEL); } builder.pathSegment(StringUtils.tokenizeToStringArray(path, "/")); - RestClient.RequestHeadersSpec request = this.restClient.get().uri(builder.build().toUri()); - if (StringUtils.hasText(this.properties.getToken())) { - request = request.header(ConfigClientProperties.TOKEN_HEADER, this.properties.getToken()); - } + return builder.build().toUri(); + } + + private Resource retrieve(URI uri, Credentials credentials, String path, boolean binary) { + RestClient.RequestHeadersSpec request = this.restClient.get().uri(uri).headers(headers -> { + addAuthorization(headers, credentials); + if (StringUtils.hasText(this.properties.getToken())) { + headers.add(ConfigClientProperties.TOKEN_HEADER, this.properties.getToken()); + } + }); if (binary) { request = request.accept(MediaType.APPLICATION_OCTET_STREAM); } - return request.retrieve().body(Resource.class); + long maxSize = this.properties.getResourceClient().getMaxSize().toBytes(); + return request.exchange((clientRequest, response) -> readResource(response, path, maxSize)); + } + + /** + * Add the credentials of the config server URI the way the config server import does: + * basic authentication from the username and password, else the configured + * {@code authorization} header. + */ + private void addAuthorization(HttpHeaders headers, Credentials credentials) { + String authorization = this.properties.getHeaders().get(ConfigClientProperties.AUTHORIZATION); + if (credentials.getPassword() != null && authorization != null) { + throw new IllegalStateException("You must set either 'password' or 'authorization'"); + } + if (credentials.getPassword() != null) { + headers.setBasicAuth(credentials.getUsername(), credentials.getPassword()); + } + else if (authorization != null) { + headers.set(HttpHeaders.AUTHORIZATION, authorization); + } + } + + private static Resource readResource(ClientHttpResponse response, String path, long maxSize) throws IOException { + HttpStatusCode status = response.getStatusCode(); + if (status.isError()) { + byte[] body = response.getBody().readNBytes(MAX_ERROR_BODY); + Charset charset = charset(response.getHeaders()); + throw status.is4xxClientError() + ? HttpClientErrorException.create(status, response.getStatusText(), response.getHeaders(), body, + charset) + : HttpServerErrorException.create(status, response.getStatusText(), response.getHeaders(), body, + charset); + } + if (response.getHeaders().getContentLength() > maxSize) { + throw tooLarge(path, maxSize); + } + byte[] content = readAtMost(response.getBody(), maxSize); + if (content.length > maxSize) { + throw tooLarge(path, maxSize); + } + return new ConfigServerResource(content, path); + } + + private static byte[] readAtMost(InputStream body, long maxSize) throws IOException { + int limit = (maxSize >= Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int) maxSize + 1; + return body.readNBytes(limit); + } + + private static IllegalStateException tooLarge(String path, long maxSize) { + return new IllegalStateException("The file [" + path + "] exceeds the maximum size of " + maxSize + + " bytes; raise spring.cloud.config.resource-client.max-size to read it"); + } + + private static Charset charset(HttpHeaders headers) { + MediaType contentType = headers.getContentType(); + return (contentType != null) ? contentType.getCharset() : null; + } + + /** + * A file read from the config server, named after the last segment of its path. + */ + private static final class ConfigServerResource extends ByteArrayResource { + + private final String path; + + ConfigServerResource(byte[] content, String path) { + super(content, "config server file [" + path + "]"); + this.path = path; + } + + @Override + public String getFilename() { + return StringUtils.getFilename(this.path); + } + } } diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java index a079bebc34..b19dfc6963 100644 --- a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java @@ -41,15 +41,14 @@ public class ConfigResourceClientAutoConfigurationTests { AutoConfigurations.of(ConfigClientAutoConfiguration.class, ConfigResourceClientAutoConfiguration.class)); @Test - void noClientWithoutTheConfigClientRestClient() { + void noClientWithoutTheConfigServerConnection() { this.contextRunner.run(context -> assertThat(context).doesNotHaveBean(ConfigResourceClient.class)); } @Test - void clientUsesTheConfigClientRestClient() { + void clientUsesTheConfigServerConnection() { this.contextRunner - .withBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME, RestClient.class, - RestClient::create) + .withBean(ConfigServerRestClientHolder.class, () -> new ConfigServerRestClientHolder(RestClient.create())) .run(context -> assertThat(context).hasSingleBean(ConfigResourceClient.class) .getBean(ConfigResourceClient.class) .isInstanceOf(DefaultConfigResourceClient.class)); @@ -60,8 +59,7 @@ void userDefinedClientIsKept() { ConfigResourceClient custom = new DefaultConfigResourceClient(RestClient.create(), new ConfigClientProperties(new MockEnvironment())); this.contextRunner - .withBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME, RestClient.class, - RestClient::create) + .withBean(ConfigServerRestClientHolder.class, () -> new ConfigServerRestClientHolder(RestClient.create())) .withBean(ConfigResourceClient.class, () -> custom) .run(context -> assertThat(context).getBean(ConfigResourceClient.class).isSameAs(custom)); } @@ -73,8 +71,8 @@ void propertiesOfTheConfigServerImportTakePrecedence() { server.expect(requestTo("http://imported:8888/application/default/nginx.conf?useDefaultLabel")) .andRespond(withSuccess("server { }", MediaType.TEXT_PLAIN)); this.contextRunner - .withBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME, RestClient.class, - () -> RestClient.create(restTemplate)) + .withBean(ConfigServerRestClientHolder.class, + () -> new ConfigServerRestClientHolder(RestClient.create(restTemplate))) .withBean(ConfigServerConfigDataLocationResolver.PROPERTIES_BEAN_NAME, ConfigClientProperties.class, () -> properties("http://imported:8888")) .withBean("configClientProperties", ConfigClientProperties.class, () -> properties("http://bound:8888")) @@ -94,8 +92,7 @@ private static ConfigClientProperties properties(String uri) { @Test void noClientWhenTheConfigClientIsDisabled() { this.contextRunner - .withBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME, RestClient.class, - RestClient::create) + .withBean(ConfigServerRestClientHolder.class, () -> new ConfigServerRestClientHolder(RestClient.create())) .withPropertyValues("spring.cloud.config.enabled=false") .run(context -> assertThat(context).doesNotHaveBean(ConfigResourceClient.class)); } diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientIntegrationTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientIntegrationTests.java index 8e86b838c3..f9bbab0264 100644 --- a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientIntegrationTests.java +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientIntegrationTests.java @@ -29,24 +29,24 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Verifies that the config server import exposes its {@link RestTemplate} and a - * {@link RestClient} as beans, and that a {@link ConfigResourceClient} is built from - * them. + * Verifies that the config server import keeps its connection for a + * {@link ConfigResourceClient} without exposing a {@link RestTemplate} or a + * {@link RestClient} bean. * * @author Sharang Gupta */ public class ConfigResourceClientIntegrationTests { @Test - void configDataImportExposesTheRestClientAndTheResourceClient() { + void configDataImportProvidesTheResourceClient() { try (ConfigurableApplicationContext context = new SpringApplicationBuilder(TestConfig.class) .web(WebApplicationType.NONE) .run("--spring.config.import=optional:configserver:http://localhost:1", "--spring.cloud.config.name=app")) { - assertThat(context.getBean(ConfigServerConfigDataLocationResolver.REST_TEMPLATE_BEAN_NAME)) - .isInstanceOf(RestTemplate.class); - assertThat(context.getBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME)) - .isInstanceOf(RestClient.class); + assertThat(context.getBean(ConfigServerRestClientHolder.class).getRestClient()).isNotNull(); assertThat(context.getBean(ConfigResourceClient.class)).isInstanceOf(DefaultConfigResourceClient.class); + // The connection is not exposed as a RestClient or RestTemplate bean + assertThat(context.getBeanNamesForType(RestClient.class)).isEmpty(); + assertThat(context.getBeanNamesForType(RestTemplate.class)).isEmpty(); } } @@ -55,7 +55,7 @@ void noResourceClientWithoutTheConfigServerImport() { try (ConfigurableApplicationContext context = new SpringApplicationBuilder(TestConfig.class) .web(WebApplicationType.NONE) .run("--spring.cloud.config.import-check.enabled=false")) { - assertThat(context.containsBean(ConfigServerConfigDataLocationResolver.REST_CLIENT_BEAN_NAME)).isFalse(); + assertThat(context.getBeanProvider(ConfigServerRestClientHolder.class).getIfAvailable()).isNull(); assertThat(context.getBeanProvider(ConfigResourceClient.class).getIfAvailable()).isNull(); } } diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java index 09fe811a4c..0ecb6fc545 100644 --- a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java @@ -18,10 +18,12 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.cloud.config.client.ConfigClientProperties.MultipleUriStrategy; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -29,7 +31,10 @@ import org.springframework.http.MediaType; import org.springframework.mock.env.MockEnvironment; import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.util.unit.DataSize; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestTemplate; @@ -39,6 +44,8 @@ import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withException; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @@ -150,6 +157,167 @@ void tokenIsSent() throws IOException { this.server.verify(); } + @Test + void basicAuthenticationComesFromTheUsernameAndPassword() { + this.properties.setUsername("alice"); + this.properties.setPassword("secret"); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Basic YWxpY2U6c2VjcmV0")) + .andRespond(withSuccess("server { }", MediaType.TEXT_PLAIN)); + + this.client.getPlainTextResource("nginx.conf"); + + this.server.verify(); + } + + @Test + void credentialsInTheUriAreUsed() { + this.properties.setUri(new String[] { "http://bob:pw@configserver:8888" }); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Basic Ym9iOnB3")) + .andRespond(withSuccess("server { }", MediaType.TEXT_PLAIN)); + + this.client.getPlainTextResource("nginx.conf"); + + this.server.verify(); + } + + @Test + void configuredAuthorizationHeaderIsUsed() { + this.properties.setHeaders(Map.of(ConfigClientProperties.AUTHORIZATION, "Bearer token")); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andRespond(withSuccess("server { }", MediaType.TEXT_PLAIN)); + + this.client.getPlainTextResource("nginx.conf"); + + this.server.verify(); + } + + @Test + void passwordAndAuthorizationHeaderTogetherAreRejected() { + this.properties.setUsername("alice"); + this.properties.setPassword("secret"); + this.properties.setHeaders(Map.of(ConfigClientProperties.AUTHORIZATION, "Bearer token")); + + assertThatThrownBy(() -> this.client.getPlainTextResource("nginx.conf")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("'password' or 'authorization'"); + } + + @Test + void nextUriIsTriedWhenOneCannotBeReached() throws IOException { + this.properties.setUri(new String[] { "http://first:8888", "http://second:8888" }); + this.server.expect(requestTo("http://first:8888/app/dev/main/nginx.conf")) + .andRespond(withException(new IOException("connection refused"))); + this.server.expect(requestTo("http://second:8888/app/dev/main/nginx.conf")) + .andRespond(withSuccess("second", MediaType.TEXT_PLAIN)); + + Resource resource = this.client.getPlainTextResource("nginx.conf"); + + assertThat(resource.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("second"); + this.server.verify(); + } + + @Test + void lastUnreachableUriFails() { + this.properties.setUri(new String[] { "http://first:8888", "http://second:8888" }); + this.server.expect(requestTo("http://first:8888/app/dev/main/nginx.conf")) + .andRespond(withException(new IOException("connection refused"))); + this.server.expect(requestTo("http://second:8888/app/dev/main/nginx.conf")) + .andRespond(withException(new IOException("connection refused"))); + + assertThatThrownBy(() -> this.client.getPlainTextResource("nginx.conf")) + .isInstanceOf(ResourceAccessException.class); + this.server.verify(); + } + + @Test + void nextUriIsTriedOnAnErrorWithTheAlwaysStrategy() throws IOException { + this.properties.setUri(new String[] { "http://first:8888", "http://second:8888" }); + this.server.expect(requestTo("http://first:8888/app/dev/main/nginx.conf")).andRespond(withServerError()); + this.server.expect(requestTo("http://second:8888/app/dev/main/nginx.conf")) + .andRespond(withSuccess("second", MediaType.TEXT_PLAIN)); + + Resource resource = this.client.getPlainTextResource("nginx.conf"); + + assertThat(resource.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("second"); + this.server.verify(); + } + + @Test + void errorIsReportedWithTheConnectionTimeoutOnlyStrategy() { + this.properties.setUri(new String[] { "http://first:8888", "http://second:8888" }); + this.properties.setMultipleUriStrategy(MultipleUriStrategy.CONNECTION_TIMEOUT_ONLY); + this.server.expect(requestTo("http://first:8888/app/dev/main/nginx.conf")).andRespond(withServerError()); + + assertThatThrownBy(() -> this.client.getPlainTextResource("nginx.conf")) + .isInstanceOf(HttpServerErrorException.class); + this.server.verify(); + } + + @Test + void labelsAreTriedInOrder() throws IOException { + this.properties.setLabel("feature, main"); + this.server.expect(requestTo("http://configserver:8888/app/dev/feature/nginx.conf")) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andRespond(withSuccess("main", MediaType.TEXT_PLAIN)); + + Resource resource = this.client.getPlainTextResource("nginx.conf"); + + assertThat(resource.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("main"); + this.server.verify(); + } + + @Test + void fileMissingInEveryLabelIsReportedAsNotFound() { + this.server.expect(requestTo("http://configserver:8888/app/dev/feature/nginx.conf")) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + + assertThatThrownBy(() -> this.client.getPlainTextResource(null, "feature,main", "nginx.conf")) + .isInstanceOf(HttpClientErrorException.NotFound.class); + this.server.verify(); + } + + @Test + void fileLargerThanTheLimitIsRejected() { + this.properties.getResourceClient().setMaxSize(DataSize.ofBytes(4)); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andRespond(withSuccess("12345", MediaType.TEXT_PLAIN)); + + assertThatThrownBy(() -> this.client.getPlainTextResource("nginx.conf")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("exceeds the maximum size of 4 bytes"); + } + + @Test + void declaredContentLengthAboveTheLimitIsRejected() { + this.properties.getResourceClient().setMaxSize(DataSize.ofBytes(4)); + HttpHeaders headers = new HttpHeaders(); + headers.setContentLength(10); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andRespond(withSuccess("12", MediaType.TEXT_PLAIN).headers(headers)); + + assertThatThrownBy(() -> this.client.getPlainTextResource("nginx.conf")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("exceeds the maximum size"); + } + + @Test + void fileWithinTheLimitIsReadAndNamedAfterItsPath() throws IOException { + this.properties.getResourceClient().setMaxSize(DataSize.ofBytes(5)); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/conf/nginx.conf")) + .andRespond(withSuccess("12345", MediaType.TEXT_PLAIN)); + + Resource resource = this.client.getPlainTextResource("conf/nginx.conf"); + + assertThat(resource.getContentAsString(StandardCharsets.UTF_8)).isEqualTo("12345"); + assertThat(resource.getFilename()).isEqualTo("nginx.conf"); + } + @Test void missingFileIsReportedAsClientError() { this.server.expect(requestTo("http://configserver:8888/app/dev/main/missing.conf")) From 4f5fa6737fe6299f4d9d50b8aef3d086547b1407 Mon Sep 17 00:00:00 2001 From: Sharang Gupta Date: Fri, 30 Oct 2026 17:56:53 +0000 Subject: [PATCH 4/6] Validate max size, log skips, skip empty labels A failed read now shows which servers and labels were tried, and a RestClient that cannot be built fails instead of hiding the client bean. Signed-off-by: Sharang Gupta --- docs/modules/ROOT/pages/client.adoc | 2 +- docs/modules/ROOT/partials/_configprops.adoc | 2 +- .../config/client/ConfigClientProperties.java | 4 +- ...onfigServerConfigDataLocationResolver.java | 13 +--- .../client/DefaultConfigResourceClient.java | 28 ++++++-- .../ConfigClientConfigDataLoaderTest.java | 2 + .../DefaultConfigResourceClientTests.java | 72 +++++++++++++++++++ 7 files changed, 104 insertions(+), 19 deletions(-) diff --git a/docs/modules/ROOT/pages/client.adoc b/docs/modules/ROOT/pages/client.adoc index 6aff1957f1..2a08898aaf 100644 --- a/docs/modules/ROOT/pages/client.adoc +++ b/docs/modules/ROOT/pages/client.adoc @@ -155,7 +155,7 @@ Resource logo = configResourceClient.getBinaryResource("dev", "main", "images/lo Without a profile or label, the client uses `spring.cloud.config.profile` and `spring.cloud.config.label`; without a label, the server's default label is used. Like the import, it tries each configured URI according to `spring.cloud.config.multiple-uri-strategy`, with the credentials of that URI, and tries a comma-separated label one label at a time. -A file is read into memory, so a file larger than `spring.cloud.config.resource-client.max-size` (1MB by default) is rejected. +A file is read into memory, so a file larger than `spring.cloud.config.resource-client.max-size` (1MB by default) is rejected; `0` disables the limit. [[sharing-configuration-across-applications]] == Sharing Configuration Across Applications diff --git a/docs/modules/ROOT/partials/_configprops.adoc b/docs/modules/ROOT/partials/_configprops.adoc index 1f29bd5644..c51b8be1d7 100644 --- a/docs/modules/ROOT/partials/_configprops.adoc +++ b/docs/modules/ROOT/partials/_configprops.adoc @@ -19,7 +19,7 @@ |spring.cloud.config.profile | `+++default+++` | The default profile to use when fetching remote configuration (comma-separated). Default is "default". |spring.cloud.config.request-connect-timeout | | timeout on waiting to connect to the Config Server. |spring.cloud.config.request-read-timeout | | timeout on waiting to read data from the Config Server. -|spring.cloud.config.resource-client.max-size | `+++1MB+++` | Maximum size of a file read from the config server; a larger file is rejected instead of being loaded into memory. +|spring.cloud.config.resource-client.max-size | `+++1MB+++` | Maximum size of a file read from the config server; a larger file is rejected instead of being loaded into memory. Zero disables the limit. |spring.cloud.config.retry.enabled | `+++false+++` | Whether to retry failed requests to the Config Server. Only has an effect when spring.cloud.config.fail-fast is enabled. |spring.cloud.config.retry.initial-interval | `+++1000+++` | Initial retry interval in milliseconds. |spring.cloud.config.retry.max-attempts | `+++6+++` | Maximum number of attempts. diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java index 4d07191ecb..2008d64eb8 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java @@ -31,6 +31,7 @@ import org.springframework.cloud.config.environment.EnvironmentMediaType; import org.springframework.cloud.configuration.TlsProperties; import org.springframework.core.env.Environment; +import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import org.springframework.util.unit.DataSize; @@ -491,7 +492,7 @@ public static class ResourceClient { /** * Maximum size of a file read from the config server; a larger file is rejected - * instead of being loaded into memory. + * instead of being loaded into memory. Zero disables the limit. */ private DataSize maxSize = DataSize.ofMegabytes(1); @@ -500,6 +501,7 @@ public DataSize getMaxSize() { } public void setMaxSize(DataSize maxSize) { + Assert.isTrue(!maxSize.isNegative(), "maxSize must not be negative"); this.maxSize = maxSize; } diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java index 5e2011d637..273c5a4db8 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java @@ -348,16 +348,9 @@ private void registerRestClientHolder(BootstrapContextClosedEvent event) { || !event.getBootstrapContext().isRegistered(RestTemplate.class)) { return; } - try { - RestClient restClient = RestClient.create(event.getBootstrapContext().get(RestTemplate.class)); - beanFactory.registerSingleton(ConfigServerRestClientHolder.BEAN_NAME, - new ConfigServerRestClientHolder(restClient)); - } - catch (RuntimeException ex) { - // A RestTemplate that cannot back a RestClient, such as a test double - this.log.debug("Could not build a RestClient from the config client RestTemplate; " - + "the ConfigResourceClient will not be available", ex); - } + RestClient restClient = RestClient.create(event.getBootstrapContext().get(RestTemplate.class)); + beanFactory.registerSingleton(ConfigServerRestClientHolder.BEAN_NAME, + new ConfigServerRestClientHolder(restClient)); } public static class PropertyResolver { diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java index f298382462..b21bda286b 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java @@ -24,6 +24,9 @@ import java.util.Collections; import java.util.List; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + import org.springframework.cloud.config.client.ConfigClientProperties.Credentials; import org.springframework.cloud.config.client.ConfigClientProperties.MultipleUriStrategy; import org.springframework.cloud.config.environment.Environment; @@ -65,6 +68,8 @@ public class DefaultConfigResourceClient implements ConfigResourceClient { private static final int MAX_ERROR_BODY = 8 * 1024; + private static final Log logger = LogFactory.getLog(DefaultConfigResourceClient.class); + private final RestClient restClient; private final ConfigClientProperties properties; @@ -114,7 +119,7 @@ private Resource getResource(String profile, String label, String path, boolean return getResource(name, profile, labels.get(i), path, binary); } catch (HttpClientErrorException.NotFound ex) { - // not in this label, try the next one + logger.debug("File [" + path + "] not found in label [" + labels.get(i) + "], trying the next label"); } } return getResource(name, profile, labels.get(labels.size() - 1), path, binary); @@ -128,10 +133,11 @@ private List labels(String label) { if (label == null) { label = this.properties.getLabel(); } - if (!StringUtils.hasText(label)) { - return Collections.singletonList(null); - } - return Arrays.stream(StringUtils.commaDelimitedListToStringArray(label)).map(String::trim).toList(); + List labels = Arrays.stream(StringUtils.commaDelimitedListToStringArray(label)) + .map(String::trim) + .filter(StringUtils::hasText) + .toList(); + return labels.isEmpty() ? Collections.singletonList(null) : labels; } /** @@ -149,14 +155,21 @@ private Resource getResource(String name, String profile, String label, String p if (this.properties.getMultipleUriStrategy() != MultipleUriStrategy.ALWAYS) { throw ex; } + logSkippedUri(i, label, path, ex.getStatusCode().toString()); } catch (ResourceAccessException ex) { - // cannot reach this server, try the next one + logSkippedUri(i, label, path, ex.getMessage()); } } return getResource(last, name, profile, label, path, binary); } + private void logSkippedUri(int uriIndex, String label, String path, String reason) { + logger.info("Failed to read file [" + path + "] with label [" + label + "] from config server at " + + this.properties.getCredentials(uriIndex).getUri() + ": " + reason + + ". Will try the next url if available"); + } + private Resource getResource(int uriIndex, String name, String profile, String label, String path, boolean binary) { Credentials credentials = this.properties.getCredentials(uriIndex); URI uri = buildUri(credentials.getUri(), name, profile, label, path); @@ -218,6 +231,9 @@ private static Resource readResource(ClientHttpResponse response, String path, l : HttpServerErrorException.create(status, response.getStatusText(), response.getHeaders(), body, charset); } + if (maxSize == 0) { + return new ConfigServerResource(response.getBody().readAllBytes(), path); + } if (response.getHeaders().getContentLength() > maxSize) { throw tooLarge(path, maxSize); } diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientConfigDataLoaderTest.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientConfigDataLoaderTest.java index 3bf4f80aba..f1d2dd6900 100644 --- a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientConfigDataLoaderTest.java +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientConfigDataLoaderTest.java @@ -27,6 +27,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.client.DefaultResponseErrorHandler; import org.springframework.web.client.RestTemplate; import static org.mockito.ArgumentMatchers.eq; @@ -59,6 +60,7 @@ public class ConfigClientConfigDataLoaderTest { @Test void context() { RestTemplate rest = mock(RestTemplate.class); + when(rest.getErrorHandler()).thenReturn(new DefaultResponseErrorHandler()); Environment environment = new Environment("test", "default"); ResponseEntity responseEntity = mock(ResponseEntity.class); when(responseEntity.getStatusCode()).thenReturn(HttpStatus.OK); diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java index 0ecb6fc545..3d160a203e 100644 --- a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java @@ -17,12 +17,16 @@ package org.springframework.cloud.config.client; import java.io.IOException; +import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.cloud.config.client.ConfigClientProperties.MultipleUriStrategy; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; @@ -54,6 +58,7 @@ * * @author Sharang Gupta */ +@ExtendWith(OutputCaptureExtension.class) public class DefaultConfigResourceClientTests { private final RestTemplate restTemplate = new RestTemplate(); @@ -282,6 +287,73 @@ void fileMissingInEveryLabelIsReportedAsNotFound() { this.server.verify(); } + @Test + void emptyLabelsInAListAreSkipped() throws IOException { + this.server.expect(requestTo("http://configserver:8888/app/dev/feature/nginx.conf")) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andRespond(withSuccess("server {}", MediaType.TEXT_PLAIN)); + + assertThat(this.client.getPlainTextResource(null, "feature,,main", "nginx.conf") + .getContentAsString(StandardCharsets.UTF_8)).isEqualTo("server {}"); + this.server.verify(); + } + + @Test + void listOfOnlyEmptyLabelsUsesTheServerDefaultLabel() throws IOException { + this.server.expect(requestTo("http://configserver:8888/app/dev/nginx.conf?useDefaultLabel")) + .andRespond(withSuccess("server {}", MediaType.TEXT_PLAIN)); + + assertThat(this.client.getPlainTextResource(null, ",", "nginx.conf").getContentAsString(StandardCharsets.UTF_8)) + .isEqualTo("server {}"); + this.server.verify(); + } + + @Test + void skippedUriAndLabelAreLogged(CapturedOutput output) throws IOException { + this.properties.setUri(new String[] { "http://down:8888", "http://configserver:8888" }); + this.server.expect(requestTo("http://down:8888/app/dev/main/nginx.conf")) + .andRespond(withException(new SocketTimeoutException("timed out"))); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andRespond(withSuccess("server {}", MediaType.TEXT_PLAIN)); + + this.client.getPlainTextResource("nginx.conf"); + + assertThat(output).contains("with label [main] from config server at http://down:8888"); + } + + @Test + void uriSkippedOnAnErrorWithTheAlwaysStrategyIsLogged(CapturedOutput output) throws IOException { + this.properties.setUri(new String[] { "http://failing:8888", "http://configserver:8888" }); + this.properties.setMultipleUriStrategy(MultipleUriStrategy.ALWAYS); + this.server.expect(requestTo("http://failing:8888/app/dev/main/nginx.conf")).andRespond(withServerError()); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andRespond(withSuccess("server {}", MediaType.TEXT_PLAIN)); + + this.client.getPlainTextResource("nginx.conf"); + + assertThat(output).contains("with label [main] from config server at http://failing:8888") + .contains("500 INTERNAL_SERVER_ERROR"); + } + + @Test + void negativeLimitIsRejected() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.properties.getResourceClient().setMaxSize(DataSize.ofBytes(-1))) + .withMessage("maxSize must not be negative"); + } + + @Test + void zeroLimitDisablesTheLimit() throws IOException { + this.properties.getResourceClient().setMaxSize(DataSize.ofBytes(0)); + HttpHeaders headers = new HttpHeaders(); + headers.setContentLength(5); + this.server.expect(requestTo("http://configserver:8888/app/dev/main/nginx.conf")) + .andRespond(withSuccess("12345", MediaType.TEXT_PLAIN).headers(headers)); + + assertThat(this.client.getPlainTextResource("nginx.conf").contentLength()).isEqualTo(5); + } + @Test void fileLargerThanTheLimitIsRejected() { this.properties.getResourceClient().setMaxSize(DataSize.ofBytes(4)); From 88822f1821fcd4f16b1cbc3d75841fb523e8a9e6 Mon Sep 17 00:00:00 2001 From: Sharang Gupta Date: Fri, 30 Oct 2026 22:40:52 +0000 Subject: [PATCH 5/6] Keep the RestClient holder failure non-fatal The ConfigResourceClient is optional, so a RestTemplate that cannot back a RestClient must not prevent startup; log it at warn to explain the bean. Signed-off-by: Sharang Gupta --- .../ConfigServerConfigDataLocationResolver.java | 13 ++++++++++--- .../client/ConfigClientConfigDataLoaderTest.java | 2 -- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java index 273c5a4db8..8c170e1a56 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java @@ -348,9 +348,16 @@ private void registerRestClientHolder(BootstrapContextClosedEvent event) { || !event.getBootstrapContext().isRegistered(RestTemplate.class)) { return; } - RestClient restClient = RestClient.create(event.getBootstrapContext().get(RestTemplate.class)); - beanFactory.registerSingleton(ConfigServerRestClientHolder.BEAN_NAME, - new ConfigServerRestClientHolder(restClient)); + try { + RestClient restClient = RestClient.create(event.getBootstrapContext().get(RestTemplate.class)); + beanFactory.registerSingleton(ConfigServerRestClientHolder.BEAN_NAME, + new ConfigServerRestClientHolder(restClient)); + } + catch (RuntimeException ex) { + // An optional component must not prevent the application from starting + this.log.warn("Could not build a RestClient from the config client RestTemplate; " + + "the ConfigResourceClient will not be available", ex); + } } public static class PropertyResolver { diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientConfigDataLoaderTest.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientConfigDataLoaderTest.java index f1d2dd6900..3bf4f80aba 100644 --- a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientConfigDataLoaderTest.java +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientConfigDataLoaderTest.java @@ -27,7 +27,6 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.client.DefaultResponseErrorHandler; import org.springframework.web.client.RestTemplate; import static org.mockito.ArgumentMatchers.eq; @@ -60,7 +59,6 @@ public class ConfigClientConfigDataLoaderTest { @Test void context() { RestTemplate rest = mock(RestTemplate.class); - when(rest.getErrorHandler()).thenReturn(new DefaultResponseErrorHandler()); Environment environment = new Environment("test", "default"); ResponseEntity responseEntity = mock(ResponseEntity.class); when(responseEntity.getStatusCode()).thenReturn(HttpStatus.OK); From d30c6dc21b6c06659fd4667a471ee997306fa68f Mon Sep 17 00:00:00 2001 From: Sharang Gupta Date: Mon, 2 Nov 2026 20:38:06 +0000 Subject: [PATCH 6/6] Make DefaultConfigResourceClient package-private Only the auto-configuration creates it. Signed-off-by: Sharang Gupta --- .../cloud/config/client/DefaultConfigResourceClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java index b21bda286b..f6147502ce 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java @@ -59,7 +59,7 @@ * @author Sharang Gupta * @since 5.1.0 */ -public class DefaultConfigResourceClient implements ConfigResourceClient { +class DefaultConfigResourceClient implements ConfigResourceClient { /** * Request parameter that asks the config server to use its default label. @@ -74,7 +74,7 @@ public class DefaultConfigResourceClient implements ConfigResourceClient { private final ConfigClientProperties properties; - public DefaultConfigResourceClient(RestClient restClient, ConfigClientProperties properties) { + DefaultConfigResourceClient(RestClient restClient, ConfigClientProperties properties) { Assert.notNull(restClient, "RestClient must not be null"); Assert.notNull(properties, "ConfigClientProperties must not be null"); this.restClient = restClient;