diff --git a/docs/modules/ROOT/pages/client.adoc b/docs/modules/ROOT/pages/client.adoc index c31dd19669..2a08898aaf 100644 --- a/docs/modules/ROOT/pages/client.adoc +++ b/docs/modules/ROOT/pages/client.adoc @@ -138,6 +138,25 @@ 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. +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; `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 6b14041581..c51b8be1d7 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. 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/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/ConfigClientProperties.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java index 28bfb3fd3b..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,8 +31,10 @@ 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; import org.springframework.web.util.UriComponentsBuilder; /** @@ -143,6 +145,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 +292,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 +485,28 @@ 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. Zero disables the limit. + */ + private DataSize maxSize = DataSize.ofMegabytes(1); + + public DataSize getMaxSize() { + return this.maxSize; + } + + public void setMaxSize(DataSize maxSize) { + Assert.isTrue(!maxSize.isNegative(), "maxSize must not be negative"); + this.maxSize = maxSize; + } + + } + /** * Discovery properties. */ 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..7997f5f838 --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfiguration.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.beans.factory.ObjectProvider; +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; + +/** + * Auto-configuration for a {@link ConfigResourceClient} that reads plain text and binary + * 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 + */ +@AutoConfiguration(after = ConfigClientAutoConfiguration.class) +@ConditionalOnProperty(name = ConfigClientProperties.PREFIX + ".enabled", matchIfMissing = true) +public class ConfigResourceClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(ConfigResourceClient.class) + @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(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 e5bde63796..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 @@ -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,13 @@ public class ConfigServerConfigDataLocationResolver * Prefix for Config Server imports. */ 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"; + static final boolean RSA_IS_PRESENT = ClassUtils .isPresent("org.springframework.security.crypto.encrypt.RsaSecretEncryptor", null); @@ -241,8 +251,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, @@ -256,6 +265,7 @@ public List resolveProfileSpecific( ConfigClientRequestTemplateFactory factory = context.get(ConfigClientRequestTemplateFactory.class); return factory.create(); }); + bootstrapContext.addCloseListener(this::registerRestClientHolder); bootstrapContext.registerIfAbsent(PropertyResolver.class, context -> new PropertyResolver(resolverContext.getBinder(), getBindHandler(resolverContext))); @@ -320,10 +330,36 @@ public List getConfigServerInstances(String serviceId) { List locations = new ArrayList<>(); locations.add(resource); - return locations; } + /** + * 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 registerRestClientHolder(BootstrapContextClosedEvent event) { + ConfigurableListableBeanFactory beanFactory = event.getApplicationContext().getBeanFactory(); + // 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; + } + 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 { private final Binder binder; 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 new file mode 100644 index 0000000000..f6147502ce --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/DefaultConfigResourceClient.java @@ -0,0 +1,281 @@ +/* + * 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.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.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; +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 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 + * @author Sharang Gupta + * @since 5.1.0 + */ +class DefaultConfigResourceClient implements ConfigResourceClient { + + /** + * Request parameter that asks the config server to use its default label. + */ + static final String USE_DEFAULT_LABEL = "useDefaultLabel"; + + 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; + + 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; + } + } + 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) { + 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); + } + + /** + * 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(); + } + List labels = Arrays.stream(StringUtils.commaDelimitedListToStringArray(label)) + .map(String::trim) + .filter(StringUtils::hasText) + .toList(); + return labels.isEmpty() ? Collections.singletonList(null) : labels; + } + + /** + * 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; + } + logSkippedUri(i, label, path, ex.getStatusCode().toString()); + } + catch (ResourceAccessException ex) { + 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); + 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)); + } + else { + builder.queryParam(USE_DEFAULT_LABEL); + } + builder.pathSegment(StringUtils.tokenizeToStringArray(path, "/")); + 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); + } + 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 (maxSize == 0) { + return new ConfigServerResource(response.getBody().readAllBytes(), path); + } + 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/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..b19dfc6963 --- /dev/null +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigResourceClientAutoConfigurationTests.java @@ -0,0 +1,100 @@ +/* + * 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.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}. + * + * @author Sharang Gupta + */ +public class ConfigResourceClientAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(ConfigClientAutoConfiguration.class, ConfigResourceClientAutoConfiguration.class)); + + @Test + void noClientWithoutTheConfigServerConnection() { + this.contextRunner.run(context -> assertThat(context).doesNotHaveBean(ConfigResourceClient.class)); + } + + @Test + void clientUsesTheConfigServerConnection() { + this.contextRunner + .withBean(ConfigServerRestClientHolder.class, () -> new ConfigServerRestClientHolder(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 MockEnvironment())); + this.contextRunner + .withBean(ConfigServerRestClientHolder.class, () -> new ConfigServerRestClientHolder(RestClient.create())) + .withBean(ConfigResourceClient.class, () -> custom) + .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(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")) + .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 + .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 new file mode 100644 index 0000000000..f9bbab0264 --- /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 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 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(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(); + } + } + + @Test + void noResourceClientWithoutTheConfigServerImport() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder(TestConfig.class) + .web(WebApplicationType.NONE) + .run("--spring.cloud.config.import-check.enabled=false")) { + assertThat(context.getBeanProvider(ConfigServerRestClientHolder.class).getIfAvailable()).isNull(); + 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..3d160a203e --- /dev/null +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/DefaultConfigResourceClientTests.java @@ -0,0 +1,424 @@ +/* + * 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.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; +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.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; + +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.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; + +/** + * Tests for {@link DefaultConfigResourceClient}. + * + * @author Sharang Gupta + */ +@ExtendWith(OutputCaptureExtension.class) +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 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 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)); + 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")) + .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"); + } + +}