From 351553ae37570673232eac0eea0b25d47f0453a2 Mon Sep 17 00:00:00 2001 From: matejnedic Date: Mon, 9 Feb 2026 16:02:15 +0100 Subject: [PATCH 1/7] Initial impl --- pom.xml | 1 + spring-cloud-aws-app-config/pom.xml | 37 ++ .../appconfig/AppConfigPropertySource.java | 105 ++++ .../cloud/appconfig/RequestContext.java | 75 +++ .../AppConfigPropertySourceTest.java | 377 +++++++++++++ spring-cloud-aws-autoconfigure/pom.xml | 10 + .../appconfig/AppConfigClientCustomizer.java | 7 + .../appconfig/AppConfigConfigDataLoader.java | 44 ++ .../AppConfigDataLocationResolver.java | 108 ++++ .../appconfig/AppConfigDataResource.java | 82 +++ .../AppConfigExceptionHappenedAnalyzer.java | 31 ++ .../AppConfigKeysMissingException.java | 24 + .../AppConfigMissingKeysFailureAnalyzer.java | 14 + .../config/appconfig/AppConfigProperties.java | 55 ++ .../appconfig/AppConfigPropertySources.java | 32 ++ .../AppConfigReloadAutoConfiguration.java | 88 +++ ...ConfigPropertySourceNotFoundException.java | 7 + ...itional-spring-configuration-metadata.json | 6 + .../main/resources/META-INF/spring.factories | 6 +- ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...onfigConfigDataLoaderIntegrationTests.java | 509 ++++++++++++++++++ ...AppConfigReloadAutoConfigurationTests.java | 130 +++++ .../appconfig/test-config-updated.properties | 3 + .../config/appconfig/test-config.json | 21 + .../config/appconfig/test-config.properties | 3 + .../config/appconfig/test-config.yaml | 11 + spring-cloud-aws-dependencies/pom.xml | 7 + 27 files changed, 1793 insertions(+), 1 deletion(-) create mode 100644 spring-cloud-aws-app-config/pom.xml create mode 100644 spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java create mode 100644 spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java create mode 100644 spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java create mode 100644 spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java create mode 100644 spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java create mode 100644 spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java create mode 100644 spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties create mode 100644 spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.json create mode 100644 spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties create mode 100644 spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml diff --git a/pom.xml b/pom.xml index 890cdc7b5d..9dd8ff7d87 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,7 @@ spring-cloud-aws-sns spring-cloud-aws-sqs spring-cloud-aws-dynamodb + spring-cloud-aws-app-config spring-cloud-aws-kinesis spring-cloud-aws-kinesis-stream-binder spring-cloud-aws-starters/spring-cloud-aws-starter-integration-kinesis diff --git a/spring-cloud-aws-app-config/pom.xml b/spring-cloud-aws-app-config/pom.xml new file mode 100644 index 0000000000..0dd959c0f1 --- /dev/null +++ b/spring-cloud-aws-app-config/pom.xml @@ -0,0 +1,37 @@ + + + + io.awspring.cloud + spring-cloud-aws + 4.0.0 + + 4.0.0 + + spring-cloud-aws-app-config + Spring Cloud AWS App Config Integration + + + + org.springframework + spring-core + + + io.awspring.cloud + spring-cloud-aws-core + + + org.springframework + spring-context + + + software.amazon.awssdk + appconfigdata + + + tools.jackson.core + jackson-databind + + + diff --git a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java new file mode 100644 index 0000000000..6788c7703c --- /dev/null +++ b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java @@ -0,0 +1,105 @@ +package io.awspring.cloud.appconfig; + +import io.awspring.cloud.core.config.AwsPropertySource; +import org.jspecify.annotations.Nullable; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.InputStreamResource; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; +import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationRequest; +import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationResponse; +import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionRequest; + +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +public class AppConfigPropertySource extends AwsPropertySource { + private static final String YAML_TYPE = "application/x-yaml"; + private static final String YAML_TYPE_ALTERNATIVE = "text/yaml"; + private static final String TEXT_TYPE = "text/plain"; + private static final String JSON_TYPE = "application/json"; + + private final RequestContext context; + private final AppConfigDataClient appConfigClient; + private String sessionToken; + private final Map properties; + + public AppConfigPropertySource(RequestContext context, AppConfigDataClient appConfigClient) { + this(context, appConfigClient, null, null); + } + + public AppConfigPropertySource(RequestContext context, AppConfigDataClient appConfigClient, @Nullable String sessionToken, Map properties) { + super("aws-appconfig:" + context, appConfigClient); + Assert.notNull(context, "context is required"); + this.context = context; + this.appConfigClient = appConfigClient; + this.sessionToken = sessionToken; + this.properties = properties != null ? properties : new LinkedHashMap<>(); + } + + @Override + public void init() { + if (!StringUtils.hasText(sessionToken)) { + var request = StartConfigurationSessionRequest.builder() + .applicationIdentifier(context.getApplicationIdentifier()) + .environmentIdentifier(context.getEnvironmentIdentifier()) + .configurationProfileIdentifier(context.getConfigurationProfileIdentifier()) + .build(); + sessionToken = appConfigClient.startConfigurationSession(request).initialConfigurationToken(); + } + + GetLatestConfigurationRequest request = GetLatestConfigurationRequest.builder().configurationToken(sessionToken).build(); + GetLatestConfigurationResponse response = this.source.getLatestConfiguration(request); + if (response.configuration().asByteArray().length > 0) { + properties.clear(); + var props = switch (response.contentType()) { + case TEXT_TYPE -> readProperties(response.configuration().asInputStream()); + case YAML_TYPE, YAML_TYPE_ALTERNATIVE, JSON_TYPE -> readYaml(response.configuration().asInputStream()); + default -> throw new IllegalStateException( + "Cannot parse unknown content type: " + response.contentType()); + }; + for (Map.Entry entry : props.entrySet()) { + properties.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + sessionToken = response.nextPollConfigurationToken(); + } + + @Override + public AppConfigPropertySource copy() { + return new AppConfigPropertySource(context, appConfigClient, sessionToken, new LinkedHashMap<>(properties)); + } + + @Override + public String[] getPropertyNames() { + return properties.keySet().toArray(String[]::new); + } + + @Override + public @Nullable Object getProperty(String name) { + return this.properties.get(name); + } + + private Properties readProperties(InputStream inputStream) { + Properties properties = new Properties(); + try (InputStream in = inputStream) { + properties.load(in); + } catch (Exception e) { + throw new IllegalStateException("Cannot load environment", e); + } + return properties; + } + + private Properties readYaml(InputStream inputStream) { + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + try (InputStream in = inputStream) { + yaml.setResources(new InputStreamResource(in)); + return yaml.getObject(); + } catch (Exception e) { + throw new IllegalStateException("Cannot load environment", e); + } + } +} diff --git a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java new file mode 100644 index 0000000000..d6e7193560 --- /dev/null +++ b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java @@ -0,0 +1,75 @@ +package io.awspring.cloud.appconfig; + +import java.util.Objects; + +public class RequestContext { + /** + * ConfigurationProfileIdentifier or ConfigurationProfileName that is used to fetch SessionToken. + */ + private String configurationProfileIdentifier; + /** + * EnvironmentIdentifier or EnvironmentName that is used to fetch SessionToken. + */ + private String environmentIdentifier; + /** + * ApplicationIdentifier or ApplicationName that is used to fetch SessionToken. + */ + private String applicationIdentifier; + /** + * Full context name which was used to extract values configurationProfileIdentifier, environmentIdentifier and + * applicationIdentifier. + */ + private String context; + + public RequestContext(String configurationProfileIdentifier, String environmentIdentifier, String applicationIdentifier, String context) { + this.configurationProfileIdentifier = configurationProfileIdentifier; + this.environmentIdentifier = environmentIdentifier; + this.applicationIdentifier = applicationIdentifier; + this.context = context; + } + + public String getConfigurationProfileIdentifier() { + return configurationProfileIdentifier; + } + + public void setConfigurationProfileIdentifier(String configurationProfileIdentifier) { + this.configurationProfileIdentifier = configurationProfileIdentifier; + } + + public String getEnvironmentIdentifier() { + return environmentIdentifier; + } + + public void setEnvironmentIdentifier(String environmentIdentifier) { + this.environmentIdentifier = environmentIdentifier; + } + + public String getApplicationIdentifier() { + return applicationIdentifier; + } + + public void setApplicationIdentifier(String applicationIdentifier) { + this.applicationIdentifier = applicationIdentifier; + } + + public String getContext() { + return context; + } + + public void setContext(String context) { + this.context = context; + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + RequestContext that = (RequestContext) object; + return Objects.equals(configurationProfileIdentifier, that.configurationProfileIdentifier) && Objects.equals(environmentIdentifier, that.environmentIdentifier) && Objects.equals(applicationIdentifier, that.applicationIdentifier) && Objects.equals(context, that.context); + } + + @Override + public int hashCode() { + return Objects.hash(configurationProfileIdentifier, environmentIdentifier, applicationIdentifier, context); + } +} diff --git a/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java b/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java new file mode 100644 index 0000000000..9d3c1073e9 --- /dev/null +++ b/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java @@ -0,0 +1,377 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.appconfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; +import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationRequest; +import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationResponse; +import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionRequest; +import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionResponse; + +/** + * Tests for {@link AppConfigPropertySource}. + * + * @author Matej Nedic + */ +class AppConfigPropertySourceTest { + + private AppConfigDataClient client = mock(AppConfigDataClient.class); + + @Test + void shouldParsePropertiesContentType() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenAnswer(invocation -> { + StartConfigurationSessionRequest request = invocation.getArgument(0); + assertThat(request.applicationIdentifier()).isEqualTo("app1"); + assertThat(request.environmentIdentifier()).isEqualTo("env1"); + assertThat(request.configurationProfileIdentifier()).isEqualTo("profile1"); + return StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build(); + }); + + String propertiesContent = "key1=value1\nkey2=value2"; + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String(propertiesContent)) + .contentType("text/plain") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) + .thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("initial-token"); + return response; + }); + + propertySource.init(); + + assertThat(propertySource.getName()).isEqualTo("aws-appconfig:" + context); + assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2"); + assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); + assertThat(propertySource.getProperty("key2")).isEqualTo("value2"); + } + + @Test + void shouldParseYamlContentType() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + + String yamlContent = "key1: value1\nkey2: value2\nnested:\n key3: value3"; + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String(yamlContent)) + .contentType("application/x-yaml") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + assertThat(propertySource.getName()).isEqualTo("aws-appconfig:" + context); + assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2", "nested.key3"); + assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); + assertThat(propertySource.getProperty("key2")).isEqualTo("value2"); + assertThat(propertySource.getProperty("nested.key3")).isEqualTo("value3"); + } + + @Test + void shouldParseAlternativeYamlContentType() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + + String yamlContent = "key1: value1\nkey2: value2"; + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String(yamlContent)) + .contentType("text/yaml") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2"); + assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); + } + + @Test + void shouldParseJsonContentType() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + + String jsonContent = "{\"key1\": \"value1\", \"key2\": \"value2\"}"; + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String(jsonContent)) + .contentType("application/json") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2"); + assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); + assertThat(propertySource.getProperty("key2")).isEqualTo("value2"); + } + + @Test + void throwsExceptionForUnsupportedContentType() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String("some content")) + .contentType("application/xml") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + assertThatThrownBy(propertySource::init) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot parse unknown content type: application/xml"); + } + + @Test + void shouldInitializeSessionTokenOnFirstInit() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + + String propertiesContent = "key1=value1"; + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String(propertiesContent)) + .contentType("text/plain") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + verify(client, times(1)).startConfigurationSession(any(StartConfigurationSessionRequest.class)); + verify(client, times(1)).getLatestConfiguration(any(GetLatestConfigurationRequest.class)); + } + + @Test + void shouldReuseSessionTokenOnSubsequentInit() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + + String propertiesContent = "key1=value1"; + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String(propertiesContent)) + .contentType("text/plain") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) + .thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("existing-token"); + return response; + }); + + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client, "existing-token", null); + propertySource.init(); + + // Should NOT call startConfigurationSession since token already exists + verify(client, times(0)).startConfigurationSession(any(StartConfigurationSessionRequest.class)); + verify(client, times(1)).getLatestConfiguration(any(GetLatestConfigurationRequest.class)); + } + + @Test + void shouldHandleEmptyConfigurationResponse() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + + // Empty configuration (no changes) + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromByteArray(new byte[0])) + .contentType("text/plain") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) + .thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("initial-token"); + return response; + }); + + propertySource.init(); + + assertThat(propertySource.getPropertyNames()).isEmpty(); + } + + @Test + void shouldUpdateNextPollTokenAfterInit() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + + String propertiesContent = "key1=value1"; + GetLatestConfigurationResponse firstResponse = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String(propertiesContent)) + .contentType("text/plain") + .nextPollConfigurationToken("next-token-1") + .build(); + + GetLatestConfigurationResponse secondResponse = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String("key2=value2")) + .contentType("text/plain") + .nextPollConfigurationToken("next-token-2") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) + .thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("initial-token"); + return firstResponse; + }) + .thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("next-token-1"); + return secondResponse; + }); + + propertySource.init(); + assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); + + // Second init should use the next poll token from first response + propertySource.init(); + assertThat(propertySource.getProperty("key2")).isEqualTo("value2"); + } + + @Test + void copyShouldPreserveSessionTokenAndProperties() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + + String propertiesContent = "key1=value1\nkey2=value2"; + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String(propertiesContent)) + .contentType("text/plain") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + AppConfigPropertySource copy = propertySource.copy(); + + assertThat(copy.getName()).isEqualTo(propertySource.getName()); + assertThat(copy.getPropertyNames()).containsExactlyInAnyOrder(propertySource.getPropertyNames()); + assertThat(copy.getProperty("key1")).isEqualTo("value1"); + assertThat(copy.getProperty("key2")).isEqualTo("value2"); + } + + @Test + void copyShouldNotCallStartSessionAgain() { + RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + + String propertiesContent = "key1=value1"; + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String(propertiesContent)) + .contentType("text/plain") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + AppConfigPropertySource copy = propertySource.copy(); + + GetLatestConfigurationResponse secondResponse = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String("key2=value2")) + .contentType("text/plain") + .nextPollConfigurationToken("next-token-2") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(secondResponse); + + copy.init(); + + verify(client, times(1)).startConfigurationSession(any(StartConfigurationSessionRequest.class)); + } + + @Test + void shouldUseCorrectContextIdentifiers() { + RequestContext context = new RequestContext("myProfile", "myEnv", "myApp", "myApp#myEnv#myProfile"); + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenAnswer(invocation -> { + StartConfigurationSessionRequest request = invocation.getArgument(0); + assertThat(request.applicationIdentifier()).isEqualTo("myApp"); + assertThat(request.environmentIdentifier()).isEqualTo("myEnv"); + assertThat(request.configurationProfileIdentifier()).isEqualTo("myProfile"); + return StartConfigurationSessionResponse.builder().initialConfigurationToken("token").build(); + }); + + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromByteArray(new byte[0])) + .contentType("text/plain") + .nextPollConfigurationToken("next-token") + .build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + propertySource.init(); + + verify(client, times(1)).startConfigurationSession(any(StartConfigurationSessionRequest.class)); + } +} diff --git a/spring-cloud-aws-autoconfigure/pom.xml b/spring-cloud-aws-autoconfigure/pom.xml index 3d1998eab0..59a0e1de96 100644 --- a/spring-cloud-aws-autoconfigure/pom.xml +++ b/spring-cloud-aws-autoconfigure/pom.xml @@ -71,6 +71,11 @@ spring-cloud-aws-secrets-manager true + + io.awspring.cloud + spring-cloud-aws-app-config + true + io.awspring.cloud spring-cloud-aws-ses @@ -207,6 +212,11 @@ s3vectors true + + software.amazon.awssdk + appconfig + test + io.micrometer micrometer-observation-test diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java new file mode 100644 index 0000000000..f7044cbd67 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java @@ -0,0 +1,7 @@ +package io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.autoconfigure.AwsClientCustomizer; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClientBuilder; + +public interface AppConfigClientCustomizer extends AwsClientCustomizer { +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java new file mode 100644 index 0000000000..f391c8c96d --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java @@ -0,0 +1,44 @@ +package io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.appconfig.AppConfigPropertySource; +import io.awspring.cloud.autoconfigure.config.BootstrapLoggingHelper; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.context.config.ConfigData; +import org.springframework.boot.context.config.ConfigDataLoader; +import org.springframework.boot.context.config.ConfigDataLoaderContext; +import org.springframework.boot.logging.DeferredLogFactory; +import org.springframework.core.env.MapPropertySource; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; + +import java.util.Collections; +import java.util.Map; + +public class AppConfigConfigDataLoader implements ConfigDataLoader { + + public AppConfigConfigDataLoader(DeferredLogFactory logFactory) { + BootstrapLoggingHelper.reconfigureLoggers(logFactory, + "io.awspring.cloud.appconfig.AppConfigPropertySource", + "io.awspring.cloud.autoconfigure.config.appconfig.AppConfigPropertySources"); + } + + @Override + @Nullable + public ConfigData load(ConfigDataLoaderContext context, AppConfigDataResource resource) { + // resource is disabled if appconfig integration is disabled via + // spring.cloud.aws.appconfig.enabled=false + if (resource.isEnabled()) { + AppConfigDataClient appConfigDataClient = context.getBootstrapContext().get(AppConfigDataClient.class); + AppConfigPropertySource propertySource = resource.getPropertySources() + .createPropertySource(resource.getContext(), resource.isOptional(), appConfigDataClient); + if (propertySource != null) { + return new ConfigData(Collections.singletonList(propertySource)); + } else { + return null; + } + } else { + // create dummy empty config data + return new ConfigData( + Collections.singletonList(new MapPropertySource("aws-appconfig:" + context, Map.of()))); + } + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java new file mode 100644 index 0000000000..1173a15244 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java @@ -0,0 +1,108 @@ +package io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.appconfig.RequestContext; +import io.awspring.cloud.autoconfigure.AwsSyncClientCustomizer; +import io.awspring.cloud.autoconfigure.config.AbstractAwsConfigDataLocationResolver; +import io.awspring.cloud.autoconfigure.core.AwsProperties; +import io.awspring.cloud.autoconfigure.core.CredentialsProperties; +import io.awspring.cloud.autoconfigure.core.RegionProperties; +import org.apache.commons.logging.Log; +import org.springframework.boot.bootstrap.BootstrapContext; +import org.springframework.boot.context.config.ConfigDataLocation; +import org.springframework.boot.context.config.ConfigDataLocationNotFoundException; +import org.springframework.boot.context.config.ConfigDataLocationResolverContext; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.logging.DeferredLogFactory; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClientBuilder; + +import java.util.ArrayList; +import java.util.List; + +public class AppConfigDataLocationResolver extends AbstractAwsConfigDataLocationResolver { + + /** + * AWS App Config Data prefix. + */ + public static final String PREFIX = "aws-appconfig:"; + + private final Log log; + + public AppConfigDataLocationResolver(DeferredLogFactory deferredLogFactory) { + this.log = deferredLogFactory.getLog(AppConfigDataLocationResolver.class); + } + + @Override + protected String getPrefix() { + return PREFIX; + } + + @Override + public List resolve(ConfigDataLocationResolverContext resolverContext, + ConfigDataLocation location) throws ConfigDataLocationNotFoundException { + AppConfigProperties appConfigProperties = loadProperties(resolverContext.getBinder()); + List locations = new ArrayList<>(); + AppConfigPropertySources propertySources = new AppConfigPropertySources(); + List contexts = getCustomContexts(location.getNonPrefixedValue(PREFIX)); + + + if (appConfigProperties.isEnabled()) { + registerBean(resolverContext, AwsProperties.class, loadAwsProperties(resolverContext.getBinder())); + registerBean(resolverContext, AppConfigProperties.class, appConfigProperties); + registerBean(resolverContext, CredentialsProperties.class, + loadCredentialsProperties(resolverContext.getBinder())); + registerBean(resolverContext, RegionProperties.class, loadRegionProperties(resolverContext.getBinder())); + + registerAndPromoteBean(resolverContext, AppConfigDataClient.class, this::createAppConfigDataClient); + + contexts.forEach(propertySourceContext -> locations + .add(new AppConfigDataResource(resolveContext(propertySourceContext, appConfigProperties.getSeparator()), location.isOptional(), propertySources))); + + if (!location.isOptional() && locations.isEmpty()) { + throw new AppConfigKeysMissingException( + "No AppConfigData keys provided in `spring.config.import=aws-appconfig:` configuration."); + } + } else { + // create dummy resources with enabled flag set to false, + // because returned locations cannot be empty + contexts.forEach(propertySourceContext -> locations.add( + new AppConfigDataResource(resolveContext(propertySourceContext, appConfigProperties.getSeparator()), location.isOptional(), false, propertySources))); + } + return locations; + } + + private RequestContext resolveContext(String propertySourceContext, String separator) { + var response = propertySourceContext.split(java.util.regex.Pattern.quote(separator)); + return new RequestContext(response[0].trim(), response[1].trim(), response[2].trim(), propertySourceContext); + } + + private AppConfigDataClient createAppConfigDataClient(BootstrapContext context) { + AppConfigDataClientBuilder builder = configure(AppConfigDataClient.builder(), context.get(AppConfigProperties.class), context); + + try { + AppConfigClientCustomizer appConfigClientCustomizer = context.get(AppConfigClientCustomizer.class); + if (appConfigClientCustomizer != null) { + appConfigClientCustomizer.customize(builder); + } + } catch (IllegalStateException e) { + log.debug("Bean of type AwsSyncClientCustomizer is not registered: " + e.getMessage()); + } + + try { + AwsSyncClientCustomizer awsSyncClientCustomizer = context.get(AwsSyncClientCustomizer.class); + if (awsSyncClientCustomizer != null) { + awsSyncClientCustomizer.customize(builder); + } + } catch (IllegalStateException e) { + log.debug("Bean of type AwsSyncClientCustomizer is not registered: " + e.getMessage()); + } + + return builder.build(); + } + + + protected AppConfigProperties loadProperties(Binder binder) { + return binder.bind(AppConfigProperties.PREFIX, Bindable.of(AppConfigProperties.class)).orElseGet(AppConfigProperties::new); + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java new file mode 100644 index 0000000000..6f6a0363af --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java @@ -0,0 +1,82 @@ +package io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.appconfig.RequestContext; +import org.springframework.boot.context.config.ConfigDataResource; +import org.springframework.core.style.ToStringCreator; + +import java.util.Objects; + +/** + * @author Matej Nedic + * @since 4.0.1 + */ +public class AppConfigDataResource extends ConfigDataResource { + + private final RequestContext context; + + private final boolean enabled; + + private final boolean optional; + + private final AppConfigPropertySources propertySources; + + public AppConfigDataResource(RequestContext context, boolean optional, AppConfigPropertySources propertySources) { + this(context, optional, true, propertySources); + } + + public AppConfigDataResource(RequestContext context, boolean optional, boolean enabled, AppConfigPropertySources propertySources) { + this.context = context; + this.optional = optional; + this.propertySources = propertySources; + this.enabled = enabled; + } + + /** + * Returns context which is equal to S3 bucket and property file. + * + * @return the context + */ + public RequestContext getContext() { + return this.context; + } + + /** + * If application startup should fail when secret cannot be loaded or does not exist. + * + * @return is optional + */ + public boolean isOptional() { + return this.optional; + } + + public boolean isEnabled() { + return enabled; + } + + public AppConfigPropertySources getPropertySources() { + return this.propertySources; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AppConfigDataResource that = (AppConfigDataResource) o; + return this.optional == that.optional && this.context.equals(that.context); + } + + @Override + public int hashCode() { + return Objects.hash(this.optional, this.context); + } + + @Override + public String toString() { + return new ToStringCreator(this).append("context", context).append("optional", optional).toString(); + + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java new file mode 100644 index 0000000000..a62933f0ee --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java @@ -0,0 +1,31 @@ +/* + * Copyright 2013-2024 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 io.awspring.cloud.autoconfigure.config.appconfig; + +import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; +import org.springframework.boot.diagnostics.FailureAnalysis; + +public class AppConfigExceptionHappenedAnalyzer + extends AbstractFailureAnalyzer { + + @Override + protected FailureAnalysis analyze(Throwable rootFailure, AwsAppConfigPropertySourceNotFoundException cause) { + return new FailureAnalysis( + "Could not import properties from App Config. Exception happened while trying to load the keys: " + + cause.getMessage(), + "Depending on error message determine action course", cause); + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java new file mode 100644 index 0000000000..fbf3febe06 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java @@ -0,0 +1,24 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.autoconfigure.config.appconfig; + +public class AppConfigKeysMissingException extends RuntimeException { + + public AppConfigKeysMissingException(String message) { + super(message); + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java new file mode 100644 index 0000000000..083128b12b --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java @@ -0,0 +1,14 @@ +package io.awspring.cloud.autoconfigure.config.appconfig; + +import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; +import org.springframework.boot.diagnostics.FailureAnalysis; + +public class AppConfigMissingKeysFailureAnalyzer extends AbstractFailureAnalyzer { + + @Override + protected FailureAnalysis analyze(Throwable rootFailure, AppConfigKeysMissingException cause) { + return new FailureAnalysis("Could not import properties from AWS App Config: " + cause.getMessage(), + "Consider providing keys, for example `spring.config.import=aws-appconfig:/config#app#prod`", cause); + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java new file mode 100644 index 0000000000..33ffc926d1 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java @@ -0,0 +1,55 @@ +package io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.autoconfigure.AwsClientProperties; +import io.awspring.cloud.autoconfigure.config.reload.ReloadProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +import static io.awspring.cloud.autoconfigure.config.appconfig.AppConfigProperties.PREFIX; + +@ConfigurationProperties(prefix = PREFIX) +public class AppConfigProperties extends AwsClientProperties { + + /** + * The prefix used for App Config related properties. + */ + public static final String PREFIX = "spring.cloud.aws.appconfig"; + + /** + * Enables App Config import integration. + */ + private boolean enabled = true; + + /** + * Properties related to configuration reload. + */ + @NestedConfigurationProperty + private ReloadProperties reload = new ReloadProperties(); + + + private String separator = "#"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getSeparator() { + return separator; + } + + public void setSeparator(String separator) { + this.separator = separator; + } + + public ReloadProperties getReload() { + return reload; + } + + public void setReload(ReloadProperties reload) { + this.reload = reload; + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java new file mode 100644 index 0000000000..a782e97bac --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java @@ -0,0 +1,32 @@ +package io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.appconfig.AppConfigPropertySource; +import io.awspring.cloud.appconfig.RequestContext; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; +import org.springframework.util.Assert; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; + +public class AppConfigPropertySources { + private static final Log LOG = LogFactory.getLog(AppConfigPropertySources.class); + + @Nullable + public AppConfigPropertySource createPropertySource(RequestContext context, boolean optional, AppConfigDataClient client) { + Assert.notNull(context, "RequestContext is required"); + Assert.notNull(client, "AppConfigDataClient is required"); + + LOG.info("Loading properties from AWS App Config: " + context + ", optional: " + optional); + try { + AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + propertySource.init(); + return propertySource; + } catch (Exception e) { + LOG.warn("Unable to load AWS App Config from " + context + ". " + e.getMessage()); + if (!optional) { + throw new AwsAppConfigPropertySourceNotFoundException(e); + } + } + return null; + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java new file mode 100644 index 0000000000..9a0d1a1a74 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java @@ -0,0 +1,88 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.autoconfigure.config.appconfig; + +import io.awspring.cloud.appconfig.AppConfigPropertySource; +import io.awspring.cloud.autoconfigure.config.reload.ConfigurationChangeDetector; +import io.awspring.cloud.autoconfigure.config.reload.ConfigurationUpdateStrategy; +import io.awspring.cloud.autoconfigure.config.reload.PollingAwsPropertySourceChangeDetector; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.autoconfigure.RefreshAutoConfiguration; +import org.springframework.cloud.autoconfigure.RefreshEndpointAutoConfiguration; +import org.springframework.cloud.commons.util.TaskSchedulerWrapper; +import org.springframework.cloud.context.refresh.ContextRefresher; +import org.springframework.cloud.context.restart.RestartEndpoint; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +import java.util.Optional; + +/** + * {@link EnableAutoConfiguration Auto-Configuration} for reloading properties from AppConfig. + * + * @author Matej Nedic + */ +@AutoConfiguration +@EnableConfigurationProperties(AppConfigProperties.class) +@ConditionalOnClass({ EndpointAutoConfiguration.class, RestartEndpoint.class, ContextRefresher.class }) +@AutoConfigureAfter({ InfoEndpointAutoConfiguration.class, RefreshEndpointAutoConfiguration.class, + RefreshAutoConfiguration.class }) +@ConditionalOnProperty(value = AppConfigProperties.PREFIX + ".reload.strategy") +@ConditionalOnBean(ContextRefresher.class) +public class AppConfigReloadAutoConfiguration { + + @Bean("appConfigTaskScheduler") + @ConditionalOnMissingBean + public TaskSchedulerWrapper appConfigTaskScheduler() { + ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler(); + + threadPoolTaskScheduler.setThreadNamePrefix("spring-cloud-aws-appconfig-ThreadPoolTaskScheduler-"); + threadPoolTaskScheduler.setDaemon(true); + + return new TaskSchedulerWrapper<>(threadPoolTaskScheduler); + } + + @Bean("appConfigConfigurationUpdateStrategy") + @ConditionalOnMissingBean(name = "appConfigConfigurationUpdateStrategy") + public ConfigurationUpdateStrategy appConfigConfigurationUpdateStrategy(AppConfigProperties properties, + Optional restarter, ContextRefresher refresher) { + return ConfigurationUpdateStrategy.create(properties.getReload(), refresher, restarter); + } + + @Bean + @ConditionalOnBean(ConfigurationUpdateStrategy.class) + public ConfigurationChangeDetector appConfigPollingAwsPropertySourceChangeDetector( + AppConfigProperties properties, + @Qualifier("appConfigConfigurationUpdateStrategy") ConfigurationUpdateStrategy strategy, + @Qualifier("appConfigTaskScheduler") TaskSchedulerWrapper taskScheduler, + ConfigurableEnvironment environment) { + + return new PollingAwsPropertySourceChangeDetector<>(properties.getReload(), AppConfigPropertySource.class, + strategy, taskScheduler.getTaskScheduler(), environment); + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java new file mode 100644 index 0000000000..a46eb4e47a --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java @@ -0,0 +1,7 @@ +package io.awspring.cloud.autoconfigure.config.appconfig; + +public class AwsAppConfigPropertySourceNotFoundException extends RuntimeException { + public AwsAppConfigPropertySourceNotFoundException(Exception e) { + super(e); + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 13c7a0de54..fc05b77416 100644 --- a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -29,6 +29,12 @@ "name": "spring.cloud.aws.sqs.enabled", "description": "Enables SQS integration.", "type": "java.lang.Boolean" + }, + { + "defaultValue": true, + "name": "spring.cloud.aws.appconfig.enabled", + "description": "Enables App Config integration.", + "type": "java.lang.Boolean" } ] } diff --git a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories index ae97226a8a..59019ce26e 100644 --- a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring.factories @@ -2,12 +2,14 @@ org.springframework.boot.context.config.ConfigDataLocationResolver=\ io.awspring.cloud.autoconfigure.config.parameterstore.ParameterStoreConfigDataLocationResolver,\ io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerConfigDataLocationResolver,\ +io.awspring.cloud.autoconfigure.config.appconfig.AppConfigDataLocationResolver,\ io.awspring.cloud.autoconfigure.config.s3.S3ConfigDataLocationResolver # ConfigData Loaders org.springframework.boot.context.config.ConfigDataLoader=\ io.awspring.cloud.autoconfigure.config.parameterstore.ParameterStoreConfigDataLoader,\ io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerConfigDataLoader,\ +io.awspring.cloud.autoconfigure.config.appconfig.AppConfigConfigDataLoader,\ io.awspring.cloud.autoconfigure.config.s3.S3ConfigDataLoader # Failure Analyzers @@ -17,4 +19,6 @@ io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerExceptionHap io.awspring.cloud.autoconfigure.config.parameterstore.ParameterStoreExceptionHappenedAnalyzer, \ io.awspring.cloud.autoconfigure.config.s3.S3ExceptionHappenedAnalyzer, \ io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerMissingKeysFailureAnalyzer,\ -io.awspring.cloud.autoconfigure.config.s3.S3MissingKeysFailureAnalyzer +io.awspring.cloud.autoconfigure.config.s3.S3MissingKeysFailureAnalyzer,\ +io.awspring.cloud.autoconfigure.config.appconfig.AppConfigExceptionHappenedAnalyzer,\ +io.awspring.cloud.autoconfigure.config.appconfig.AppConfigMissingKeysFailureAnalyzer diff --git a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 685d3a75ae..9e088ebd3e 100644 --- a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -15,4 +15,5 @@ io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerReloadAutoCo io.awspring.cloud.autoconfigure.config.secretsmanager.SecretsManagerAutoConfiguration io.awspring.cloud.autoconfigure.config.parameterstore.ParameterStoreReloadAutoConfiguration io.awspring.cloud.autoconfigure.config.parameterstore.ParameterStoreAutoConfiguration +io.awspring.cloud.autoconfigure.config.appconfig.AppConfigReloadAutoConfiguration io.awspring.cloud.autoconfigure.config.s3.S3ReloadAutoConfiguration diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java new file mode 100644 index 0000000000..73eabc2c43 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java @@ -0,0 +1,509 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.autoconfigure.config.appconfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + +import io.awspring.cloud.autoconfigure.AwsSyncClientCustomizer; +import io.awspring.cloud.autoconfigure.ConfiguredAwsClient; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.bootstrap.BootstrapRegistry; +import org.springframework.boot.bootstrap.BootstrapRegistryInitializer; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.io.ClassPathResource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.appconfig.AppConfigClient; +import software.amazon.awssdk.services.appconfig.model.*; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; + +/** + * Integration tests for loading configuration properties from AWS AppConfig. + * + * @author Matej Nedic + */ +@Testcontainers +@ExtendWith(OutputCaptureExtension.class) +class AppConfigConfigDataLoaderIntegrationTests { + + private static final String NEW_LINE_CHAR = System.lineSeparator(); + private static String APP_ID; + private static String ENV_ID; + private static String PROFILE_ID_PROPERTIES; + private static String PROFILE_ID_YAML; + private static String PROFILE_ID_JSON; + + private static String api_key = System.getenv("LOCALSTACK_AUTH_TOKEN"); + + @Container + static LocalStackContainer localstack = new LocalStackContainer( + DockerImageName.parse("localstack/localstack-pro:latest")) + .withEnv("LOCALSTACK_AUTH_TOKEN", api_key).withReuse(false); + + @BeforeAll + static void beforeAll() throws IOException { + + try (AppConfigClient appConfigClient = AppConfigClient.builder() + .endpointOverride(localstack.getEndpoint()) + .region(Region.of(localstack.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) + .build()) { + CreateApplicationResponse appResponse = appConfigClient.createApplication( + CreateApplicationRequest.builder() + .name("myApp") + .description("My Application") + .build()); + APP_ID = appResponse.id(); + + CreateEnvironmentResponse envResponse = appConfigClient.createEnvironment( + CreateEnvironmentRequest.builder() + .applicationId(APP_ID) + .name("myEnv") + .description("My Environment") + .build()); + ENV_ID = envResponse.id(); + + CreateDeploymentStrategyResponse strategyResponse = appConfigClient.createDeploymentStrategy( + CreateDeploymentStrategyRequest.builder() + .name("myStrategy") + .description("My Strategy") + .deploymentDurationInMinutes(0) + .growthFactor(100.0f) + .finalBakeTimeInMinutes(0) + .build()); + + PROFILE_ID_PROPERTIES = createProfileWithContent(appConfigClient, APP_ID, ENV_ID, strategyResponse.id(), + "propertiesProfile", "text/plain", + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); + + PROFILE_ID_YAML = createProfileWithContent(appConfigClient, APP_ID, ENV_ID, strategyResponse.id(), + "yamlProfile", "application/x-yaml", + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml"); + + PROFILE_ID_JSON = createProfileWithContent(appConfigClient, APP_ID, ENV_ID, strategyResponse.id(), + "jsonProfile", "application/json", + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.json"); + } + } + + private static String createProfileWithContent(AppConfigClient appConfigClient, String appId, String envId, + String strategyId, String profileName, String contentType, String resourcePath) throws IOException { + CreateConfigurationProfileResponse profileResponse = appConfigClient.createConfigurationProfile( + CreateConfigurationProfileRequest.builder() + .applicationId(appId) + .name(profileName) + .locationUri("hosted") + .build()); + + ClassPathResource resource = new ClassPathResource(resourcePath); + String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + + appConfigClient.createHostedConfigurationVersion( + CreateHostedConfigurationVersionRequest.builder() + .applicationId(appId) + .configurationProfileId(profileResponse.id()) + .content(SdkBytes.fromUtf8String(content)) + .contentType(contentType) + .build()); + + appConfigClient.startDeployment( + StartDeploymentRequest.builder() + .applicationId(appId) + .environmentId(envId) + .deploymentStrategyId(strategyId) + .configurationProfileId(profileResponse.id()) + .configurationVersion("1") + .build()); + + return profileResponse.id(); + } + + @Test + void resolvesPropertyFromAppConfig() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = runApplication(application, + "aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID)) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("cloud.aws.s3.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("yes"); + } + } + + @Test + void clientIsConfiguredWithCustomizerProvidedToBootstrapRegistry() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + application.addBootstrapRegistryInitializer(new CustomizerConfiguration()); + + try (ConfigurableApplicationContext context = runApplication(application, + "aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID)) { + ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(AppConfigDataClient.class)); + assertThat(client.getApiCallTimeout()).isEqualTo(Duration.ofMillis(2001)); + assertThat(client.getSyncHttpClient()).isNotNull(); + } + } + + @Test + void whenKeysAreNotSpecifiedFailsWithHumanReadableFailureMessage(CapturedOutput output) { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = runApplication(application, "aws-appconfig:")) { + fail("Context without keys should fail to start"); + } + catch (Exception e) { + assertThat(e).isInstanceOf(AppConfigKeysMissingException.class); + String errorMessage = "Description:%1$s%1$sCould not import properties from AWS App Config" + .formatted(NEW_LINE_CHAR); + assertThat(output.getOut()).contains(errorMessage); + } + } + + @Test + void whenKeysCannotBeFoundFailWithHumanReadableMessage(CapturedOutput output) { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = runApplication(application, + "aws-appconfig:invalidApp#invalidEnv#invalidProfile")) { + fail("Context with invalid keys should fail to start"); + } + catch (Exception e) { + assertThat(e).isInstanceOf(AwsAppConfigPropertySourceNotFoundException.class); + String errorMessage = "Description:%1$s%1$sCould not import properties from App Config. Exception happened while trying to load the keys" + .formatted(NEW_LINE_CHAR); + assertThat(output.getOut()).contains(errorMessage); + } + } + + @Test + void credentialsProviderCanBeOverwrittenInBootstrapConfig() { + AwsCredentialsProvider bootstrapCredentialsProvider = StaticCredentialsProvider + .create(AwsBasicCredentials.create("mock-key", "mock-secret")); + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + application.addBootstrapRegistryInitializer(registry -> { + registry.register(AwsCredentialsProvider.class, ctx -> bootstrapCredentialsProvider); + }); + + try (ConfigurableApplicationContext context = runApplication(application, + "aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID)) { + ConfiguredAwsClient appConfigDataClient = new ConfiguredAwsClient( + context.getBean(AppConfigDataClient.class)); + assertThat(appConfigDataClient.getAwsCredentialsProvider()).isEqualTo(bootstrapCredentialsProvider); + } + } + + @Test + void endpointCanBeOverwrittenWithGlobalAwsProperties() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = runApplication(application, + "aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, "spring.cloud.aws.endpoint")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + } + } + + @Test + void propertyIsNotResolvedWhenIntegrationIsDisabled() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = application.run( + "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, + "--spring.cloud.aws.appconfig.enabled=false", + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.endpoint=" + localstack.getEndpoint(), + "--spring.cloud.aws.region.static=eu-west-1")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isNull(); + assertThat(context.getBeanProvider(AppConfigDataClient.class).getIfAvailable()).isNull(); + } + } + + @Test + void serviceSpecificEndpointTakesPrecedenceOverGlobalAwsRegion() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = application.run( + "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, + "--spring.cloud.aws.appconfig.region=" + localstack.getRegion(), + "--spring.cloud.aws.endpoint=http://non-existing-host/", + "--spring.cloud.aws.appconfig.endpoint=" + localstack.getEndpoint(), + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.region.static=eu-west-1")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + assertThat(context.getBean(AwsCredentialsProvider.class)).isInstanceOf(StaticCredentialsProvider.class); + } + } + + @Test + void appConfigDataClientUsesGlobalRegion() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = application.run( + "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, + "--spring.cloud.aws.endpoint=" + localstack.getEndpoint(), + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.region.static=" + localstack.getRegion())) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + } + } + + @Test + void customSeparatorIsRespected() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = application.run( + "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "/" + ENV_ID + "/" + APP_ID, + "--spring.cloud.aws.appconfig.separator=/", + "--spring.cloud.aws.endpoint=" + localstack.getEndpoint(), + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.region.static=" + localstack.getRegion())) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + } + } + + @Test + void resolvesPropertiesFromYamlContentType() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = runApplication(application, + "aws-appconfig:" + PROFILE_ID_YAML + "#" + ENV_ID + "#" + APP_ID)) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("cloud.aws.s3.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("no"); + } + } + + @Test + void resolvesPropertiesFromJsonContentType() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = runApplication(application, + "aws-appconfig:" + PROFILE_ID_JSON + "#" + ENV_ID + "#" + APP_ID)) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("cloud.aws.s3.enabled")).isEqualTo("true"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("yes"); + } + } + + @Nested + class ReloadConfigurationTests { + + @Test + void reloadsProperties() throws IOException { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = application.run( + "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, + "--spring.cloud.aws.appconfig.reload.strategy=refresh", + "--spring.cloud.aws.appconfig.reload.period=PT1S", + "--spring.cloud.aws.appconfig.region=" + localstack.getRegion(), + "--spring.cloud.aws.appconfig.endpoint=" + localstack.getEndpoint(), + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.region.static=" + localstack.getRegion(), + "--logging.level.io.awspring.cloud.appconfig=debug")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("false"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("updated"); + }); + } finally { + resetAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); + } + } + + + @Test + void doesNotReloadPropertiesWhenMonitoringIsDisabled() throws IOException { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = application.run( + "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, + "--spring.cloud.aws.appconfig.reload.period=PT1S", + "--spring.cloud.aws.appconfig.region=" + localstack.getRegion(), + "--spring.cloud.aws.appconfig.endpoint=" + localstack.getEndpoint(), + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.region.static=eu-west-1", + "--logging.level.io.awspring.cloud.appconfig=debug")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); + + await().during(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + }); + } finally { + resetAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); + } + + } + + @Test + void reloadsPropertiesWithRestartContextStrategy() throws IOException { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + + try (ConfigurableApplicationContext context = application.run( + "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, + "--spring.cloud.aws.appconfig.reload.strategy=RESTART_CONTEXT", + "--spring.cloud.aws.appconfig.reload.period=PT1S", + "--spring.cloud.aws.appconfig.reload.max-wait-for-restart=PT1S", + "--management.endpoint.restart.enabled=true", + "--management.endpoints.web.exposure.include=restart", + "--spring.cloud.aws.appconfig.region=" + localstack.getRegion(), + "--spring.cloud.aws.appconfig.endpoint=" + localstack.getEndpoint(), + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.region.static=" + localstack.getRegion(), + "--logging.level.io.awspring.cloud.appconfig=debug")) { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); + + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); + + await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { + assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("false"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("updated"); + }); + } finally { + resetAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); + } + } + + private void updateAppConfigConfiguration(String profileId, String resourcePath) throws IOException { + + try (AppConfigClient appConfigClient = AppConfigClient.builder() + .endpointOverride(localstack.getEndpoint()) + .region(Region.of(localstack.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) + .build()) { + + ClassPathResource resource = new ClassPathResource(resourcePath); + String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + + CreateHostedConfigurationVersionResponse versionResponse = appConfigClient.createHostedConfigurationVersion( + CreateHostedConfigurationVersionRequest.builder() + .applicationId(APP_ID) + .configurationProfileId(profileId) + .content(SdkBytes.fromUtf8String(content)) + .contentType("text/plain") + .build()); + + ListDeploymentStrategiesResponse strategies = appConfigClient.listDeploymentStrategies( + ListDeploymentStrategiesRequest.builder().build()); + String strategyId = strategies.items().get(0).id(); + + appConfigClient.startDeployment( + StartDeploymentRequest.builder() + .applicationId(APP_ID) + .environmentId(ENV_ID) + .deploymentStrategyId(strategyId) + .configurationProfileId(profileId) + .configurationVersion(String.valueOf(versionResponse.versionNumber())) + .build()); + } + } + + private void resetAppConfigConfiguration(String profileId, String resourcePath) throws IOException { + updateAppConfigConfiguration(profileId, resourcePath); + } + } + + private ConfigurableApplicationContext runApplication(SpringApplication application, String springConfigImport) { + return runApplication(application, springConfigImport, "spring.cloud.aws.appconfig.endpoint"); + } + + private ConfigurableApplicationContext runApplication(SpringApplication application, String springConfigImport, + String endpointProperty) { + return application.run("--spring.config.import=" + springConfigImport, + "--spring.cloud.aws.appconfig.region=" + localstack.getRegion(), + "--" + endpointProperty + "=" + localstack.getEndpoint(), + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.region.static=eu-west-1", + "--logging.level.io.awspring.cloud.appconfig=debug"); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class App { + } + + static class CustomizerConfiguration implements BootstrapRegistryInitializer { + + @Override + public void initialize(BootstrapRegistry registry) { + registry.register(AppConfigClientCustomizer.class, context -> (builder -> { + builder.overrideConfiguration(builder.overrideConfiguration().copy(c -> { + c.apiCallTimeout(Duration.ofMillis(2001)); + })); + })); + registry.register(AwsSyncClientCustomizer.class, context -> (builder -> { + builder.httpClient(ApacheHttpClient.builder().connectionTimeout(Duration.ofMillis(1542)).build()); + })); + } + } +} diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java new file mode 100644 index 0000000000..bfe13d2834 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java @@ -0,0 +1,130 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.autoconfigure.config.appconfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockingDetails; + +import io.awspring.cloud.appconfig.AppConfigPropertySource; +import io.awspring.cloud.autoconfigure.config.reload.ConfigurationUpdateStrategy; +import io.awspring.cloud.autoconfigure.config.reload.PollingAwsPropertySourceChangeDetector; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.autoconfigure.ConfigurationPropertiesRebinderAutoConfiguration; +import org.springframework.cloud.autoconfigure.RefreshAutoConfiguration; +import org.springframework.cloud.autoconfigure.RefreshEndpointAutoConfiguration; +import org.springframework.cloud.commons.util.TaskSchedulerWrapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; + +/** + * Tests for {@link AppConfigReloadAutoConfiguration}. + * + * @author Matej Nedic + */ +class AppConfigReloadAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.cloud.aws.region.static:eu-west-1").withConfiguration( + AutoConfigurations.of(AppConfigReloadAutoConfiguration.class, RefreshAutoConfiguration.class)); + + @Test + void createsBeansForRefreshStrategy() { + this.contextRunner.withPropertyValues("spring.cloud.aws.appconfig.reload.strategy:refresh") + .run(this::createsBeans); + } + + @Test + void createsBeansForRestartStrategy() { + this.contextRunner + .withConfiguration(AutoConfigurations.of(EndpointAutoConfiguration.class, + RefreshEndpointAutoConfiguration.class, ConfigurationPropertiesRebinderAutoConfiguration.class)) + .withPropertyValues("spring.cloud.aws.appconfig.reload.strategy:restart_context", + "management.endpoint.restart.enabled:true", "management.endpoints.web.exposure.include:restart") + .run(this::createsBeans); + } + + @Test + void doesntCreateBeansWhenStrategyNotSet() { + this.contextRunner.run(this::doesNotCreateBeans); + } + + @Test + void usesCustomTaskScheduler() { + this.contextRunner.withPropertyValues("spring.cloud.aws.appconfig.reload.strategy:refresh") + .withUserConfiguration(CustomTaskSchedulerConfig.class).run(ctx -> { + Object taskScheduler = ctx.getBean("appConfigTaskScheduler"); + assertThat(mockingDetails(taskScheduler).isMock()).isTrue(); + }); + } + + @Test + void usesCustomConfigurationUpdateStrategy() { + this.contextRunner.withPropertyValues("spring.cloud.aws.appconfig.reload.strategy:refresh") + .withUserConfiguration(CustomConfigurationUpdateStrategyConfig.class).run(ctx -> { + ConfigurationUpdateStrategy configurationUpdateStrategy = ctx + .getBean(ConfigurationUpdateStrategy.class); + assertThat(mockingDetails(configurationUpdateStrategy).isMock()).isTrue(); + }); + } + + private void doesNotCreateBeans(AssertableApplicationContext ctx) { + assertThat(ctx).doesNotHaveBean("appConfigConfigurationUpdateStrategy"); + assertThat(ctx).doesNotHaveBean("appConfigPollingAwsPropertySourceChangeDetector"); + assertThat(ctx).doesNotHaveBean("appConfigTaskScheduler"); + } + + private void createsBeans(AssertableApplicationContext ctx) { + assertThat(ctx).hasBean("appConfigConfigurationUpdateStrategy"); + assertThat(ctx.getBean("appConfigConfigurationUpdateStrategy")) + .isInstanceOf(ConfigurationUpdateStrategy.class); + + assertThat(ctx).hasBean("appConfigPollingAwsPropertySourceChangeDetector"); + assertThat(ctx).getBean("appConfigPollingAwsPropertySourceChangeDetector") + .isInstanceOf(PollingAwsPropertySourceChangeDetector.class); + + PollingAwsPropertySourceChangeDetector changeDetector = ctx + .getBean(PollingAwsPropertySourceChangeDetector.class); + assertThat(changeDetector.getPropertySourceClass()).isEqualTo(AppConfigPropertySource.class); + + assertThat(ctx).getBean("appConfigTaskScheduler").isInstanceOf(TaskSchedulerWrapper.class); + } + + @Configuration(proxyBeanMethods = false) + static class CustomTaskSchedulerConfig { + + @Bean("appConfigTaskScheduler") + public TaskSchedulerWrapper customTaskScheduler() { + return mock(TaskSchedulerWrapper.class, Answers.RETURNS_DEEP_STUBS); + } + } + + @Configuration(proxyBeanMethods = false) + static class CustomConfigurationUpdateStrategyConfig { + + @Bean("appConfigConfigurationUpdateStrategy") + public ConfigurationUpdateStrategy configurationUpdateStrategy() { + return mock(ConfigurationUpdateStrategy.class); + } + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties new file mode 100644 index 0000000000..fc72fd9fd0 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties @@ -0,0 +1,3 @@ +cloud.aws.sqs.enabled=false +cloud.aws.s3.enabled=true +some.property.to.be.checked=updated diff --git a/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.json b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.json new file mode 100644 index 0000000000..fe9a99d36a --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.json @@ -0,0 +1,21 @@ +{ + "cloud": { + "aws": { + "sqs": { + "enabled": true + }, + "s3": { + "enabled": true + } + } + }, + "some": { + "property": { + "to": { + "be": { + "checked": "yes" + } + } + } + } +} diff --git a/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties new file mode 100644 index 0000000000..634d9446d3 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties @@ -0,0 +1,3 @@ +cloud.aws.sqs.enabled=true +cloud.aws.s3.enabled=true +some.property.to.be.checked=yes diff --git a/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml new file mode 100644 index 0000000000..62fd06a2db --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/resources/io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml @@ -0,0 +1,11 @@ +cloud: + aws: + sqs: + enabled: true + s3: + enabled: true +some: + property: + to: + be: + checked: "no" diff --git a/spring-cloud-aws-dependencies/pom.xml b/spring-cloud-aws-dependencies/pom.xml index ff30a70b90..083fa06cd2 100644 --- a/spring-cloud-aws-dependencies/pom.xml +++ b/spring-cloud-aws-dependencies/pom.xml @@ -105,6 +105,13 @@ ${project.version} + + + io.awspring.cloud + spring-cloud-aws-app-config + ${project.version} + + io.awspring.cloud spring-cloud-aws-parameter-store From eba782717ab918b47147f3430be2d046e7ee54ee Mon Sep 17 00:00:00 2001 From: matejnedic Date: Mon, 9 Feb 2026 23:22:17 +0100 Subject: [PATCH 2/7] Introduce AppConfig integration --- pom.xml | 1 + .../appconfig/AppConfigPropertySource.java | 6 + .../cloud/appconfig/RequestContext.java | 6 + .../AppConfigPropertySourceTest.java | 200 +++----- .../awspring/cloud/appconfig/test-config.json | 1 + .../cloud/appconfig/test-config.properties | 2 + .../awspring/cloud/appconfig/test-config.yaml | 4 + .../appconfig/AppConfigClientCustomizer.java | 5 + .../appconfig/AppConfigConfigDataLoader.java | 6 + .../AppConfigDataLocationResolver.java | 5 + .../appconfig/AppConfigDataResource.java | 3 +- .../AppConfigExceptionHappenedAnalyzer.java | 6 + .../AppConfigKeysMissingException.java | 7 + .../AppConfigMissingKeysFailureAnalyzer.java | 7 + .../config/appconfig/AppConfigProperties.java | 5 + .../appconfig/AppConfigPropertySources.java | 13 + .../AppConfigReloadAutoConfiguration.java | 1 + ...ConfigPropertySourceNotFoundException.java | 7 + ...onfigConfigDataLoaderIntegrationTests.java | 459 +++++++----------- .../pom.xml | 26 + 20 files changed, 343 insertions(+), 427 deletions(-) create mode 100644 spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.json create mode 100644 spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.properties create mode 100644 spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.yaml create mode 100644 spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml diff --git a/pom.xml b/pom.xml index 9dd8ff7d87..e5996702fa 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,7 @@ spring-cloud-aws-starters/spring-cloud-aws-starter-metrics spring-cloud-aws-starters/spring-cloud-aws-starter-parameter-store spring-cloud-aws-starters/spring-cloud-aws-starter-s3 + spring-cloud-aws-starters/spring-cloud-aws-starter-app-config spring-cloud-aws-starters/spring-cloud-aws-starter-integration-s3 spring-cloud-aws-starters/spring-cloud-aws-starter-secrets-manager spring-cloud-aws-starters/spring-cloud-aws-starter-ses diff --git a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java index 6788c7703c..83f602a542 100644 --- a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java +++ b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java @@ -16,6 +16,12 @@ import java.util.Map; import java.util.Properties; +/** + * Retrieves configuration property sources path from the AWS AppConfig using the provided {@link AppConfigDataClient}. + * + * @author Matej Nedic + * @since 4.1.0 + */ public class AppConfigPropertySource extends AwsPropertySource { private static final String YAML_TYPE = "application/x-yaml"; private static final String YAML_TYPE_ALTERNATIVE = "text/yaml"; diff --git a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java index d6e7193560..667cc29137 100644 --- a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java +++ b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java @@ -2,6 +2,12 @@ import java.util.Objects; +/** + * Context used by {@link AppConfigPropertySource} to construct a call to AppConfig. + * + * @author Matej Nedic + * @since 4.1.0 + */ public class RequestContext { /** * ConfigurationProfileIdentifier or ConfigurationProfileName that is used to fetch SessionToken. diff --git a/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java b/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java index 9d3c1073e9..6a3f4783b2 100644 --- a/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java +++ b/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java @@ -23,7 +23,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +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.ClassPathResource; import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationRequest; @@ -38,12 +42,32 @@ */ class AppConfigPropertySourceTest { - private AppConfigDataClient client = mock(AppConfigDataClient.class); + private static final RequestContext DEFAULT_CONTEXT = new RequestContext("profile1", "env1", "app1", + "app1#env1#profile1"); + + private static final String RESOURCE_PATH = "io/awspring/cloud/appconfig/"; + + private final AppConfigDataClient client = mock(AppConfigDataClient.class); + + private static String loadResource(String filename) { + try { + ClassPathResource resource = new ClassPathResource(RESOURCE_PATH + filename); + return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + } + catch (IOException e) { + throw new RuntimeException("Failed to load test resource: " + filename, e); + } + } + + @BeforeEach + void setupDefaultSessionMock() { + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) + .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + } @Test void shouldParsePropertiesContentType() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) .thenAnswer(invocation -> { @@ -54,12 +78,7 @@ void shouldParsePropertiesContentType() { return StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build(); }); - String propertiesContent = "key1=value1\nkey2=value2"; - GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(propertiesContent)) - .contentType("text/plain") - .nextPollConfigurationToken("next-token") - .build(); + GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.properties"), "text/plain"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) .thenAnswer(invocation -> { @@ -70,7 +89,7 @@ void shouldParsePropertiesContentType() { propertySource.init(); - assertThat(propertySource.getName()).isEqualTo("aws-appconfig:" + context); + assertThat(propertySource.getName()).isEqualTo("aws-appconfig:" + DEFAULT_CONTEXT); assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2"); assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); assertThat(propertySource.getProperty("key2")).isEqualTo("value2"); @@ -78,24 +97,16 @@ void shouldParsePropertiesContentType() { @Test void shouldParseYamlContentType() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); - - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - String yamlContent = "key1: value1\nkey2: value2\nnested:\n key3: value3"; - GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(yamlContent)) - .contentType("application/x-yaml") - .nextPollConfigurationToken("next-token") - .build(); + GetLatestConfigurationResponse response = buildResponse( + loadResource("test-config.yaml"), "application/x-yaml"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); propertySource.init(); - assertThat(propertySource.getName()).isEqualTo("aws-appconfig:" + context); + assertThat(propertySource.getName()).isEqualTo("aws-appconfig:" + DEFAULT_CONTEXT); assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2", "nested.key3"); assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); assertThat(propertySource.getProperty("key2")).isEqualTo("value2"); @@ -104,41 +115,24 @@ void shouldParseYamlContentType() { @Test void shouldParseAlternativeYamlContentType() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); - - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - String yamlContent = "key1: value1\nkey2: value2"; - GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(yamlContent)) - .contentType("text/yaml") - .nextPollConfigurationToken("next-token") - .build(); + GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.yaml"), "text/yaml"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); propertySource.init(); - assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2"); + assertThat(propertySource.getPropertyNames()).containsExactlyInAnyOrder("key1", "key2", "nested.key3"); assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); } @Test void shouldParseJsonContentType() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); - - String jsonContent = "{\"key1\": \"value1\", \"key2\": \"value2\"}"; - GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(jsonContent)) - .contentType("application/json") - .nextPollConfigurationToken("next-token") - .build(); + GetLatestConfigurationResponse response = buildResponse( + loadResource("test-config.json"), "application/json"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); @@ -151,17 +145,9 @@ void shouldParseJsonContentType() { @Test void throwsExceptionForUnsupportedContentType() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); - - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String("some content")) - .contentType("application/xml") - .nextPollConfigurationToken("next-token") - .build(); + GetLatestConfigurationResponse response = buildResponse("some content", "application/xml"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); @@ -172,18 +158,9 @@ void throwsExceptionForUnsupportedContentType() { @Test void shouldInitializeSessionTokenOnFirstInit() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); - - String propertiesContent = "key1=value1"; - GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(propertiesContent)) - .contentType("text/plain") - .nextPollConfigurationToken("next-token") - .build(); + GetLatestConfigurationResponse response = buildResponse("key1=value1", "text/plain"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); @@ -195,14 +172,7 @@ void shouldInitializeSessionTokenOnFirstInit() { @Test void shouldReuseSessionTokenOnSubsequentInit() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - - String propertiesContent = "key1=value1"; - GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(propertiesContent)) - .contentType("text/plain") - .nextPollConfigurationToken("next-token") - .build(); + GetLatestConfigurationResponse response = buildResponse("key1=value1", "text/plain"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) .thenAnswer(invocation -> { @@ -211,7 +181,8 @@ void shouldReuseSessionTokenOnSubsequentInit() { return response; }); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client, "existing-token", null); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client, "existing-token", + null); propertySource.init(); // Should NOT call startConfigurationSession since token already exists @@ -221,13 +192,8 @@ void shouldReuseSessionTokenOnSubsequentInit() { @Test void shouldHandleEmptyConfigurationResponse() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); - - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - // Empty configuration (no changes) GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() .configuration(SdkBytes.fromByteArray(new byte[0])) .contentType("text/plain") @@ -248,24 +214,10 @@ void shouldHandleEmptyConfigurationResponse() { @Test void shouldUpdateNextPollTokenAfterInit() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); - - String propertiesContent = "key1=value1"; - GetLatestConfigurationResponse firstResponse = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(propertiesContent)) - .contentType("text/plain") - .nextPollConfigurationToken("next-token-1") - .build(); - - GetLatestConfigurationResponse secondResponse = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String("key2=value2")) - .contentType("text/plain") - .nextPollConfigurationToken("next-token-2") - .build(); + GetLatestConfigurationResponse firstResponse = buildResponse("key1=value1", "text/plain", "next-token-1"); + GetLatestConfigurationResponse secondResponse = buildResponse("key2=value2", "text/plain", "next-token-2"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) .thenAnswer(invocation -> { @@ -289,18 +241,9 @@ void shouldUpdateNextPollTokenAfterInit() { @Test void copyShouldPreserveSessionTokenAndProperties() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); - - String propertiesContent = "key1=value1\nkey2=value2"; - GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(propertiesContent)) - .contentType("text/plain") - .nextPollConfigurationToken("next-token") - .build(); + GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.properties"), "text/plain"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); @@ -316,33 +259,20 @@ void copyShouldPreserveSessionTokenAndProperties() { @Test void copyShouldNotCallStartSessionAgain() { - RequestContext context = new RequestContext("profile1", "env1", "app1", "app1#env1#profile1"); - AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); - - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenReturn(StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build()); + AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - String propertiesContent = "key1=value1"; - GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(propertiesContent)) - .contentType("text/plain") - .nextPollConfigurationToken("next-token") - .build(); + GetLatestConfigurationResponse response = buildResponse("key1=value1", "text/plain"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); propertySource.init(); - + AppConfigPropertySource copy = propertySource.copy(); - - GetLatestConfigurationResponse secondResponse = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String("key2=value2")) - .contentType("text/plain") - .nextPollConfigurationToken("next-token-2") - .build(); - + + GetLatestConfigurationResponse secondResponse = buildResponse("key2=value2", "text/plain", "next-token-2"); + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(secondResponse); - + copy.init(); verify(client, times(1)).startConfigurationSession(any(StartConfigurationSessionRequest.class)); @@ -374,4 +304,16 @@ void shouldUseCorrectContextIdentifiers() { verify(client, times(1)).startConfigurationSession(any(StartConfigurationSessionRequest.class)); } + + private GetLatestConfigurationResponse buildResponse(String content, String contentType) { + return buildResponse(content, contentType, "next-token"); + } + + private GetLatestConfigurationResponse buildResponse(String content, String contentType, String nextToken) { + return GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromUtf8String(content)) + .contentType(contentType) + .nextPollConfigurationToken(nextToken) + .build(); + } } diff --git a/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.json b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.json new file mode 100644 index 0000000000..e78fb996b1 --- /dev/null +++ b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.json @@ -0,0 +1 @@ +{"key1": "value1", "key2": "value2"} diff --git a/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.properties b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.properties new file mode 100644 index 0000000000..48580bfcce --- /dev/null +++ b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.properties @@ -0,0 +1,2 @@ +key1=value1 +key2=value2 diff --git a/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.yaml b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.yaml new file mode 100644 index 0000000000..1527ce0040 --- /dev/null +++ b/spring-cloud-aws-app-config/src/test/resources/io/awspring/cloud/appconfig/test-config.yaml @@ -0,0 +1,4 @@ +key1: value1 +key2: value2 +nested: + key3: value3 diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java index f7044cbd67..0d415b8634 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java @@ -3,5 +3,10 @@ import io.awspring.cloud.autoconfigure.AwsClientCustomizer; import software.amazon.awssdk.services.appconfigdata.AppConfigDataClientBuilder; +/** + * Callback interface that can be used to customize a {@link AppConfigDataClientBuilder}. + * + * @author Matej Nedic + */ public interface AppConfigClientCustomizer extends AwsClientCustomizer { } diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java index f391c8c96d..ed6989b256 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java @@ -13,6 +13,12 @@ import java.util.Collections; import java.util.Map; +/** + * Loads config data from AWS AppConfig. + * + * @author Matej Nedic + * @since 4.1.0 + */ public class AppConfigConfigDataLoader implements ConfigDataLoader { public AppConfigConfigDataLoader(DeferredLogFactory logFactory) { diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java index 1173a15244..2a718b2f31 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java @@ -20,6 +20,11 @@ import java.util.ArrayList; import java.util.List; +/** + * Resolves config data locations in AWS App Config. + * @author Matej Nedic + * @since 4.1.0 + */ public class AppConfigDataLocationResolver extends AbstractAwsConfigDataLocationResolver { /** diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java index 6f6a0363af..ba24d5bcba 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java @@ -7,8 +7,9 @@ import java.util.Objects; /** + * Config data resource for AWS App Config integration. * @author Matej Nedic - * @since 4.0.1 + * @since 4.1.0 */ public class AppConfigDataResource extends ConfigDataResource { diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java index a62933f0ee..43cdfd398c 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java @@ -18,6 +18,12 @@ import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; import org.springframework.boot.diagnostics.FailureAnalysis; +/** + * An {@link AbstractFailureAnalyzer} that performs analysis of an AppConfig configuration failure caused by failure of {@link software.amazon.awssdk.services.appconfigdata.AppConfigDataClient} call. + * + * @author Matej Nedic + * @since 4.1.0 + */ public class AppConfigExceptionHappenedAnalyzer extends AbstractFailureAnalyzer { diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java index fbf3febe06..08c4628263 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigKeysMissingException.java @@ -15,6 +15,13 @@ */ package io.awspring.cloud.autoconfigure.config.appconfig; +/** + * Thrown when configuration provided to ConfigDataLoader is missing AppConfig keys, for example + * `spring.config.import=aws-appconfig:`. + * + * @author Kunal Varpe + * @since 3.3.0 + */ public class AppConfigKeysMissingException extends RuntimeException { public AppConfigKeysMissingException(String message) { diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java index 083128b12b..1ddc3e91ac 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java @@ -3,6 +3,13 @@ import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; import org.springframework.boot.diagnostics.FailureAnalysis; +/** + * An {@link AbstractFailureAnalyzer} that performs analysis of an AppConfig configuration failure caused by not providing an AppConfig + * key to `spring.config.import` property. + * + * @author Matej Nedic + * @since 4.1.0 + */ public class AppConfigMissingKeysFailureAnalyzer extends AbstractFailureAnalyzer { @Override diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java index 33ffc926d1..afc408dcdd 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java @@ -7,6 +7,11 @@ import static io.awspring.cloud.autoconfigure.config.appconfig.AppConfigProperties.PREFIX; +/** + * Configuration properties for AWS AppConfig integration. + * @author Matej Nedic + * @since 4.1.0 + */ @ConfigurationProperties(prefix = PREFIX) public class AppConfigProperties extends AwsClientProperties { diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java index a782e97bac..8f2854f062 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java @@ -8,9 +8,22 @@ import org.springframework.util.Assert; import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; +/** + * Provides prefix config import support. + * + * @author Matej Nedic + * @since 4.1.0 + */ public class AppConfigPropertySources { private static final Log LOG = LogFactory.getLog(AppConfigPropertySources.class); + /** + * Creates property source for given context. + * @param context property source context extracted from the parameter name + * @param optional if creating context should fail with exception if parameter cannot be loaded + * @param client AWS AppConfigData client + * @return a property source or null if config could not be loaded and optional is set to true + */ @Nullable public AppConfigPropertySource createPropertySource(RequestContext context, boolean optional, AppConfigDataClient client) { Assert.notNull(context, "RequestContext is required"); diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java index 9a0d1a1a74..e53e356920 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java @@ -46,6 +46,7 @@ * {@link EnableAutoConfiguration Auto-Configuration} for reloading properties from AppConfig. * * @author Matej Nedic + * @since 4.1.0 */ @AutoConfiguration @EnableConfigurationProperties(AppConfigProperties.class) diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java index a46eb4e47a..c44a8fb855 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java @@ -1,5 +1,12 @@ package io.awspring.cloud.autoconfigure.config.appconfig; + +/** + * An exception thrown if there is failure when calling AppConfig. + * + * @author Matej Nedic + * @since 4.1.0 + */ public class AwsAppConfigPropertySourceNotFoundException extends RuntimeException { public AwsAppConfigPropertySourceNotFoundException(Exception e) { super(e); diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java index 73eabc2c43..bc1cecd247 100644 --- a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java @@ -16,18 +16,21 @@ package io.awspring.cloud.autoconfigure.config.appconfig; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.testcontainers.shaded.org.awaitility.Awaitility.await; import io.awspring.cloud.autoconfigure.AwsSyncClientCustomizer; import io.awspring.cloud.autoconfigure.ConfiguredAwsClient; import java.io.IOException; -import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringBootConfiguration; @@ -60,74 +63,83 @@ */ @Testcontainers @ExtendWith(OutputCaptureExtension.class) +@EnabledIfEnvironmentVariable(named = "LOCALSTACK_AUTH_TOKEN", matches = ".+", disabledReason = "Requires LocalStack Pro image") class AppConfigConfigDataLoaderIntegrationTests { private static final String NEW_LINE_CHAR = System.lineSeparator(); + private static final String REGION = "eu-central-1"; + private static AppConfigClient appConfigClient; private static String APP_ID; private static String ENV_ID; + private static String STRATEGY_ID; private static String PROFILE_ID_PROPERTIES; private static String PROFILE_ID_YAML; private static String PROFILE_ID_JSON; + private static String IMPORT_PROPERTIES; + private static String IMPORT_YAML; + private static String IMPORT_JSON; - private static String api_key = System.getenv("LOCALSTACK_AUTH_TOKEN"); + private static final String api_key = System.getenv("LOCALSTACK_AUTH_TOKEN"); @Container static LocalStackContainer localstack = new LocalStackContainer( - DockerImageName.parse("localstack/localstack-pro:latest")) - .withEnv("LOCALSTACK_AUTH_TOKEN", api_key).withReuse(false); + DockerImageName.parse("localstack/localstack-pro:4.4.0")) + .withEnv("LOCALSTACK_AUTH_TOKEN", api_key) + .withEnv("AWS_DEFAULT_REGION", REGION); @BeforeAll static void beforeAll() throws IOException { + appConfigClient = AppConfigClient.builder() + .endpointOverride(localstack.getEndpoint()) + .region(Region.of(REGION)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) + .build(); + + CreateApplicationResponse appResponse = appConfigClient.createApplication( + CreateApplicationRequest.builder() + .name("myApp") + .description("My Application") + .build()); + APP_ID = appResponse.id(); + + CreateEnvironmentResponse envResponse = appConfigClient.createEnvironment( + CreateEnvironmentRequest.builder() + .applicationId(APP_ID) + .name("myEnv") + .description("My Environment") + .build()); + ENV_ID = envResponse.id(); + + CreateDeploymentStrategyResponse strategyResponse = appConfigClient.createDeploymentStrategy( + CreateDeploymentStrategyRequest.builder() + .name("myStrategy") + .description("My Strategy") + .deploymentDurationInMinutes(0) + .growthFactor(100.0f) + .finalBakeTimeInMinutes(0) + .build()); + STRATEGY_ID = strategyResponse.id(); + + PROFILE_ID_PROPERTIES = createProfileWithContent("propertiesProfile", "text/plain", + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); + + PROFILE_ID_YAML = createProfileWithContent("yamlProfile", "application/x-yaml", + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml"); + + PROFILE_ID_JSON = createProfileWithContent("jsonProfile", "application/json", + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.json"); - try (AppConfigClient appConfigClient = AppConfigClient.builder() - .endpointOverride(localstack.getEndpoint()) - .region(Region.of(localstack.getRegion())) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) - .build()) { - CreateApplicationResponse appResponse = appConfigClient.createApplication( - CreateApplicationRequest.builder() - .name("myApp") - .description("My Application") - .build()); - APP_ID = appResponse.id(); - - CreateEnvironmentResponse envResponse = appConfigClient.createEnvironment( - CreateEnvironmentRequest.builder() - .applicationId(APP_ID) - .name("myEnv") - .description("My Environment") - .build()); - ENV_ID = envResponse.id(); - - CreateDeploymentStrategyResponse strategyResponse = appConfigClient.createDeploymentStrategy( - CreateDeploymentStrategyRequest.builder() - .name("myStrategy") - .description("My Strategy") - .deploymentDurationInMinutes(0) - .growthFactor(100.0f) - .finalBakeTimeInMinutes(0) - .build()); - - PROFILE_ID_PROPERTIES = createProfileWithContent(appConfigClient, APP_ID, ENV_ID, strategyResponse.id(), - "propertiesProfile", "text/plain", - "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); - - PROFILE_ID_YAML = createProfileWithContent(appConfigClient, APP_ID, ENV_ID, strategyResponse.id(), - "yamlProfile", "application/x-yaml", - "io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml"); - - PROFILE_ID_JSON = createProfileWithContent(appConfigClient, APP_ID, ENV_ID, strategyResponse.id(), - "jsonProfile", "application/json", - "io/awspring/cloud/autoconfigure/config/appconfig/test-config.json"); - } + IMPORT_PROPERTIES = "aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID; + IMPORT_YAML = "aws-appconfig:" + PROFILE_ID_YAML + "#" + ENV_ID + "#" + APP_ID; + IMPORT_JSON = "aws-appconfig:" + PROFILE_ID_JSON + "#" + ENV_ID + "#" + APP_ID; } - private static String createProfileWithContent(AppConfigClient appConfigClient, String appId, String envId, - String strategyId, String profileName, String contentType, String resourcePath) throws IOException { + private static String createProfileWithContent(String profileName, String contentType, + String resourcePath) throws IOException { CreateConfigurationProfileResponse profileResponse = appConfigClient.createConfigurationProfile( CreateConfigurationProfileRequest.builder() - .applicationId(appId) + .applicationId(APP_ID) .name(profileName) .locationUri("hosted") .build()); @@ -137,7 +149,7 @@ private static String createProfileWithContent(AppConfigClient appConfigClient, appConfigClient.createHostedConfigurationVersion( CreateHostedConfigurationVersionRequest.builder() - .applicationId(appId) + .applicationId(APP_ID) .configurationProfileId(profileResponse.id()) .content(SdkBytes.fromUtf8String(content)) .contentType(contentType) @@ -145,9 +157,9 @@ private static String createProfileWithContent(AppConfigClient appConfigClient, appConfigClient.startDeployment( StartDeploymentRequest.builder() - .applicationId(appId) - .environmentId(envId) - .deploymentStrategyId(strategyId) + .applicationId(APP_ID) + .environmentId(ENV_ID) + .deploymentStrategyId(STRATEGY_ID) .configurationProfileId(profileResponse.id()) .configurationVersion("1") .build()); @@ -157,166 +169,66 @@ private static String createProfileWithContent(AppConfigClient appConfigClient, @Test void resolvesPropertyFromAppConfig() { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); + SpringApplication application = createApplication(); - try (ConfigurableApplicationContext context = runApplication(application, - "aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID)) { + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES)) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); assertThat(context.getEnvironment().getProperty("cloud.aws.s3.enabled")).isEqualTo("true"); assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("yes"); } } - @Test - void clientIsConfiguredWithCustomizerProvidedToBootstrapRegistry() { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); - application.addBootstrapRegistryInitializer(new CustomizerConfiguration()); - - try (ConfigurableApplicationContext context = runApplication(application, - "aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID)) { - ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(AppConfigDataClient.class)); - assertThat(client.getApiCallTimeout()).isEqualTo(Duration.ofMillis(2001)); - assertThat(client.getSyncHttpClient()).isNotNull(); - } - } - @Test void whenKeysAreNotSpecifiedFailsWithHumanReadableFailureMessage(CapturedOutput output) { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); + SpringApplication application = createApplication(); - try (ConfigurableApplicationContext context = runApplication(application, "aws-appconfig:")) { - fail("Context without keys should fail to start"); - } - catch (Exception e) { - assertThat(e).isInstanceOf(AppConfigKeysMissingException.class); - String errorMessage = "Description:%1$s%1$sCould not import properties from AWS App Config" - .formatted(NEW_LINE_CHAR); - assertThat(output.getOut()).contains(errorMessage); - } + assertThatThrownBy(() -> runApplication(application, "aws-appconfig:")) + .isInstanceOf(AppConfigKeysMissingException.class); + String errorMessage = "Description:%1$s%1$sCould not import properties from AWS App Config" + .formatted(NEW_LINE_CHAR); + assertThat(output.getOut()).contains(errorMessage); } @Test void whenKeysCannotBeFoundFailWithHumanReadableMessage(CapturedOutput output) { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); + SpringApplication application = createApplication(); - try (ConfigurableApplicationContext context = runApplication(application, - "aws-appconfig:invalidApp#invalidEnv#invalidProfile")) { - fail("Context with invalid keys should fail to start"); - } - catch (Exception e) { - assertThat(e).isInstanceOf(AwsAppConfigPropertySourceNotFoundException.class); - String errorMessage = "Description:%1$s%1$sCould not import properties from App Config. Exception happened while trying to load the keys" - .formatted(NEW_LINE_CHAR); - assertThat(output.getOut()).contains(errorMessage); - } - } - - @Test - void credentialsProviderCanBeOverwrittenInBootstrapConfig() { - AwsCredentialsProvider bootstrapCredentialsProvider = StaticCredentialsProvider - .create(AwsBasicCredentials.create("mock-key", "mock-secret")); - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); - application.addBootstrapRegistryInitializer(registry -> { - registry.register(AwsCredentialsProvider.class, ctx -> bootstrapCredentialsProvider); - }); - - try (ConfigurableApplicationContext context = runApplication(application, - "aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID)) { - ConfiguredAwsClient appConfigDataClient = new ConfiguredAwsClient( - context.getBean(AppConfigDataClient.class)); - assertThat(appConfigDataClient.getAwsCredentialsProvider()).isEqualTo(bootstrapCredentialsProvider); - } - } - - @Test - void endpointCanBeOverwrittenWithGlobalAwsProperties() { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); - - try (ConfigurableApplicationContext context = runApplication(application, - "aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, "spring.cloud.aws.endpoint")) { - assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); - } + assertThatThrownBy(() -> runApplication(application, "aws-appconfig:invalidApp#invalidEnv#invalidProfile")) + .isInstanceOf(AwsAppConfigPropertySourceNotFoundException.class); + String errorMessage = "Description:%1$s%1$sCould not import properties from App Config. Exception happened while trying to load the keys" + .formatted(NEW_LINE_CHAR); + assertThat(output.getOut()).contains(errorMessage); } @Test void propertyIsNotResolvedWhenIntegrationIsDisabled() { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); + SpringApplication application = createApplication(); - try (ConfigurableApplicationContext context = application.run( - "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, - "--spring.cloud.aws.appconfig.enabled=false", - "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), - "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), - "--spring.cloud.aws.endpoint=" + localstack.getEndpoint(), - "--spring.cloud.aws.region.static=eu-west-1")) { + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES, + "spring.cloud.aws.endpoint", + "--spring.cloud.aws.appconfig.enabled=false")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isNull(); assertThat(context.getBeanProvider(AppConfigDataClient.class).getIfAvailable()).isNull(); } } - @Test - void serviceSpecificEndpointTakesPrecedenceOverGlobalAwsRegion() { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); - - try (ConfigurableApplicationContext context = application.run( - "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, - "--spring.cloud.aws.appconfig.region=" + localstack.getRegion(), - "--spring.cloud.aws.endpoint=http://non-existing-host/", - "--spring.cloud.aws.appconfig.endpoint=" + localstack.getEndpoint(), - "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), - "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), - "--spring.cloud.aws.region.static=eu-west-1")) { - assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); - assertThat(context.getBean(AwsCredentialsProvider.class)).isInstanceOf(StaticCredentialsProvider.class); - } - } - - @Test - void appConfigDataClientUsesGlobalRegion() { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); - - try (ConfigurableApplicationContext context = application.run( - "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, - "--spring.cloud.aws.endpoint=" + localstack.getEndpoint(), - "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), - "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), - "--spring.cloud.aws.region.static=" + localstack.getRegion())) { - assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); - } - } - @Test void customSeparatorIsRespected() { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); + SpringApplication application = createApplication(); - try (ConfigurableApplicationContext context = application.run( - "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "/" + ENV_ID + "/" + APP_ID, - "--spring.cloud.aws.appconfig.separator=/", - "--spring.cloud.aws.endpoint=" + localstack.getEndpoint(), - "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), - "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), - "--spring.cloud.aws.region.static=" + localstack.getRegion())) { + try (ConfigurableApplicationContext context = runApplication(application, + "aws-appconfig:" + PROFILE_ID_PROPERTIES + "/" + ENV_ID + "/" + APP_ID, + "spring.cloud.aws.endpoint", + "--spring.cloud.aws.appconfig.separator=/")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); } } @Test void resolvesPropertiesFromYamlContentType() { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); + SpringApplication application = createApplication(); - try (ConfigurableApplicationContext context = runApplication(application, - "aws-appconfig:" + PROFILE_ID_YAML + "#" + ENV_ID + "#" + APP_ID)) { + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_YAML)) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); assertThat(context.getEnvironment().getProperty("cloud.aws.s3.enabled")).isEqualTo("true"); assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("no"); @@ -325,11 +237,9 @@ void resolvesPropertiesFromYamlContentType() { @Test void resolvesPropertiesFromJsonContentType() { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); + SpringApplication application = createApplication(); - try (ConfigurableApplicationContext context = runApplication(application, - "aws-appconfig:" + PROFILE_ID_JSON + "#" + ENV_ID + "#" + APP_ID)) { + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_JSON)) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); assertThat(context.getEnvironment().getProperty("cloud.aws.s3.enabled")).isEqualTo("true"); assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("yes"); @@ -339,137 +249,104 @@ void resolvesPropertiesFromJsonContentType() { @Nested class ReloadConfigurationTests { + @AfterEach + void resetConfiguration() throws IOException { + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); + } + @Test void reloadsProperties() throws IOException { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); - - try (ConfigurableApplicationContext context = application.run( - "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, - "--spring.cloud.aws.appconfig.reload.strategy=refresh", - "--spring.cloud.aws.appconfig.reload.period=PT1S", - "--spring.cloud.aws.appconfig.region=" + localstack.getRegion(), - "--spring.cloud.aws.appconfig.endpoint=" + localstack.getEndpoint(), - "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), - "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), - "--spring.cloud.aws.region.static=" + localstack.getRegion(), - "--logging.level.io.awspring.cloud.appconfig=debug")) { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, + IMPORT_PROPERTIES, + "spring.cloud.aws.appconfig.endpoint", + "--spring.cloud.aws.appconfig.reload.strategy=refresh", + "--spring.cloud.aws.appconfig.reload.period=PT1S")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); - updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("false"); assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("updated"); }); - } finally { - resetAppConfigConfiguration(PROFILE_ID_PROPERTIES, - "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); } } - @Test void doesNotReloadPropertiesWhenMonitoringIsDisabled() throws IOException { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); - - try (ConfigurableApplicationContext context = application.run( - "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, - "--spring.cloud.aws.appconfig.reload.period=PT1S", - "--spring.cloud.aws.appconfig.region=" + localstack.getRegion(), - "--spring.cloud.aws.appconfig.endpoint=" + localstack.getEndpoint(), - "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), - "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), - "--spring.cloud.aws.region.static=eu-west-1", - "--logging.level.io.awspring.cloud.appconfig=debug")) { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, + IMPORT_PROPERTIES, + "spring.cloud.aws.appconfig.endpoint", + "--spring.cloud.aws.appconfig.reload.period=PT1S")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); - updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); await().during(Duration.ofSeconds(5)).untilAsserted(() -> { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); }); - } finally { - resetAppConfigConfiguration(PROFILE_ID_PROPERTIES, - "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); - } - + } } @Test void reloadsPropertiesWithRestartContextStrategy() throws IOException { - SpringApplication application = new SpringApplication(App.class); - application.setWebApplicationType(WebApplicationType.NONE); - - try (ConfigurableApplicationContext context = application.run( - "--spring.config.import=aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID, - "--spring.cloud.aws.appconfig.reload.strategy=RESTART_CONTEXT", - "--spring.cloud.aws.appconfig.reload.period=PT1S", - "--spring.cloud.aws.appconfig.reload.max-wait-for-restart=PT1S", - "--management.endpoint.restart.enabled=true", - "--management.endpoints.web.exposure.include=restart", - "--spring.cloud.aws.appconfig.region=" + localstack.getRegion(), - "--spring.cloud.aws.appconfig.endpoint=" + localstack.getEndpoint(), - "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), - "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), - "--spring.cloud.aws.region.static=" + localstack.getRegion(), - "--logging.level.io.awspring.cloud.appconfig=debug")) { + SpringApplication application = createApplication(); + + try (ConfigurableApplicationContext context = runApplication(application, + IMPORT_PROPERTIES, + "spring.cloud.aws.appconfig.endpoint", + "--spring.cloud.aws.appconfig.reload.strategy=RESTART_CONTEXT", + "--spring.cloud.aws.appconfig.reload.period=PT1S", + "--spring.cloud.aws.appconfig.reload.max-wait-for-restart=PT1S", + "--management.endpoint.restart.enabled=true", + "--management.endpoints.web.exposure.include=restart")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); - updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, + updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("false"); assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("updated"); }); - } finally { - resetAppConfigConfiguration(PROFILE_ID_PROPERTIES, - "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); } } private void updateAppConfigConfiguration(String profileId, String resourcePath) throws IOException { - - try (AppConfigClient appConfigClient = AppConfigClient.builder() - .endpointOverride(localstack.getEndpoint()) - .region(Region.of(localstack.getRegion())) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) - .build()) { - - ClassPathResource resource = new ClassPathResource(resourcePath); - String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - - CreateHostedConfigurationVersionResponse versionResponse = appConfigClient.createHostedConfigurationVersion( - CreateHostedConfigurationVersionRequest.builder() - .applicationId(APP_ID) - .configurationProfileId(profileId) - .content(SdkBytes.fromUtf8String(content)) - .contentType("text/plain") - .build()); - - ListDeploymentStrategiesResponse strategies = appConfigClient.listDeploymentStrategies( - ListDeploymentStrategiesRequest.builder().build()); - String strategyId = strategies.items().get(0).id(); - - appConfigClient.startDeployment( - StartDeploymentRequest.builder() - .applicationId(APP_ID) - .environmentId(ENV_ID) - .deploymentStrategyId(strategyId) - .configurationProfileId(profileId) - .configurationVersion(String.valueOf(versionResponse.versionNumber())) - .build()); - } + ClassPathResource resource = new ClassPathResource(resourcePath); + String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + + //Create new version + CreateHostedConfigurationVersionResponse versionResponse = appConfigClient.createHostedConfigurationVersion( + CreateHostedConfigurationVersionRequest.builder() + .applicationId(APP_ID) + .configurationProfileId(profileId) + .content(SdkBytes.fromUtf8String(content)) + .contentType("text/plain") + .build()); + + appConfigClient.startDeployment( + StartDeploymentRequest.builder() + .applicationId(APP_ID) + .environmentId(ENV_ID) + .deploymentStrategyId(STRATEGY_ID) + .configurationProfileId(profileId) + .configurationVersion(String.valueOf(versionResponse.versionNumber())) + .build()); } + } - private void resetAppConfigConfiguration(String profileId, String resourcePath) throws IOException { - updateAppConfigConfiguration(profileId, resourcePath); - } + private SpringApplication createApplication() { + SpringApplication application = new SpringApplication(App.class); + application.setWebApplicationType(WebApplicationType.NONE); + return application; } private ConfigurableApplicationContext runApplication(SpringApplication application, String springConfigImport) { @@ -477,33 +354,21 @@ private ConfigurableApplicationContext runApplication(SpringApplication applicat } private ConfigurableApplicationContext runApplication(SpringApplication application, String springConfigImport, - String endpointProperty) { - return application.run("--spring.config.import=" + springConfigImport, - "--spring.cloud.aws.appconfig.region=" + localstack.getRegion(), - "--" + endpointProperty + "=" + localstack.getEndpoint(), - "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), - "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), - "--spring.cloud.aws.region.static=eu-west-1", - "--logging.level.io.awspring.cloud.appconfig=debug"); + String endpointProperty, String... extraArgs) { + List args = new ArrayList<>(List.of( + "--spring.config.import=" + springConfigImport, + "--spring.cloud.aws.appconfig.region=" + REGION, + "--" + endpointProperty + "=" + localstack.getEndpoint(), + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.region.static=" + REGION, + "--logging.level.io.awspring.cloud.appconfig=debug")); + args.addAll(List.of(extraArgs)); + return application.run(args.toArray(String[]::new)); } @SpringBootConfiguration @EnableAutoConfiguration static class App { } - - static class CustomizerConfiguration implements BootstrapRegistryInitializer { - - @Override - public void initialize(BootstrapRegistry registry) { - registry.register(AppConfigClientCustomizer.class, context -> (builder -> { - builder.overrideConfiguration(builder.overrideConfiguration().copy(c -> { - c.apiCallTimeout(Duration.ofMillis(2001)); - })); - })); - registry.register(AwsSyncClientCustomizer.class, context -> (builder -> { - builder.httpClient(ApacheHttpClient.builder().connectionTimeout(Duration.ofMillis(1542)).build()); - })); - } - } } diff --git a/spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml b/spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml new file mode 100644 index 0000000000..ac04add100 --- /dev/null +++ b/spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml @@ -0,0 +1,26 @@ + + + + spring-cloud-aws + io.awspring.cloud + 4.0.0 + ../../pom.xml + + 4.0.0 + + spring-cloud-aws-starter-app-config + Spring Cloud AWS App Config Starter + + + + io.awspring.cloud + spring-cloud-aws-app-config + + + io.awspring.cloud + spring-cloud-aws-starter + + + From 36661439683de657583c17c18eec68f192e27908 Mon Sep 17 00:00:00 2001 From: matejnedic Date: Mon, 9 Feb 2026 23:53:32 +0100 Subject: [PATCH 3/7] Add Documentation --- docs/src/main/asciidoc/appconfig.adoc | 192 ++++++++++++++++++ docs/src/main/asciidoc/index.adoc | 2 + docs/src/main/asciidoc/sqs.adoc | 2 - .../AppConfigPropertySourceTest.java | 4 +- .../AppConfigDataLocationResolver.java | 6 +- ...onfigConfigDataLoaderIntegrationTests.java | 10 +- 6 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 docs/src/main/asciidoc/appconfig.adoc diff --git a/docs/src/main/asciidoc/appconfig.adoc b/docs/src/main/asciidoc/appconfig.adoc new file mode 100644 index 0000000000..d5b2765bf4 --- /dev/null +++ b/docs/src/main/asciidoc/appconfig.adoc @@ -0,0 +1,192 @@ +[#spring-cloud-aws-app-config] +== AppConfig Integration + +https://docs.aws.amazon.com/appconfig/latest/userguide/what-is-appconfig.html[AppConfig] helps store configuration safely in the cloud. The service also supports continuous fetching of changes so they can be applied during runtime. + +Spring Cloud AWS adds support for loading configuration from AppConfig through the Spring Boot https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-external-config-files-importing[config import feature]. + +Maven coordinates, using <>: + +[source,xml] +---- + + io.awspring.cloud + spring-cloud-aws-starter-app-config + +---- + +=== Loading External Configuration + +To fetch configuration from AppConfig and add it to Spring's environment properties, add the `spring.config.import` property to `application.properties`: + +When specifying the key, all of the following values must be set: + +1. ApplicationName or ApplicationIdentifier +2. ConfigurationProfileName or ConfigurationProfileIdentifier +3. EnvironmentName or EnvironmentIdentifier + +Spring Cloud AWS AppConfig supports the following file types: + +1. YAML +2. Plain text (properties) +3. JSON + +For example, assuming that the ApplicationName in AppConfig is `cool-spring-application`, ConfigurationProfileName is `ProductionConfig`, and EnvironmentName is `prod`: + +[source,properties] +---- +spring.config.import=aws-app-config:cool-spring-application#ProductionConfig#prod +---- + +If a config with the given values does not exist in AppConfig, the application will fail to start. If the config file is not required for the application and it should continue to start even when the file is missing, add `optional` before the prefix: + +[source,properties] +---- +spring.config.import=optional:aws-app-config:cool-spring-application#ProductionConfig#prod +---- + +To load multiple configurations, separate their names with `;`: + +[source,properties] +---- +spring.config.import=aws-app-config:cool-spring-application#ProductionConfig#prod;new-spring-application#ProdConfig#prod2 +---- + +If some config files are required and others are optional, list them as separate entries in the `spring.config.import` property: + +[source,properties] +---- +spring.config.import[0]=optional:aws-app-config:new-spring-application#ProdConfig#prod +spring.config.import[1]=aws-app-config:cool-spring-application#ProductionConfig#prod2 +---- + +Fetched config file properties can be referenced with `@Value`, bound to `@ConfigurationProperties` classes, or referenced in the `application.properties` file. + +[source,java] +---- +@Value("${username}") +private String username; + +@Value("${password}") +private String password; +---- + +=== Using AppConfigDataClient + +The starter automatically configures and registers a `AppConfigDataClient` bean in the Spring application context. The `AppConfigDataClient` bean can be used to retrieve configuration files imperatively. + +=== Customizing AppConfigDataClient + +To use a custom `AppConfigDataClient` in `spring.config.import`, provide an implementation of `BootstrapRegistryInitializer`. For example: + +[source,java] +---- +package com.app; + + +import org.springframework.boot.BootstrapRegistry; +import org.springframework.boot.BootstrapRegistryInitializer; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; + +public class AppConfigBootstrapConfiguration implements BootstrapRegistryInitializer { + + @Override + public void initialize(BootstrapRegistry registry) { + registry.register(AppConfigDataClient.class, context -> { + AwsCredentialsProvider awsCredentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create("yourAccessKey", "yourSecretKey")); + return AppConfigDataClient.builder().credentialsProvider(awsCredentialsProvider).region(Region.EU_WEST_2).build(); + }); + } +} +---- + +Note that this class must be listed under the `org.springframework.boot.BootstrapRegistryInitializer` key in `META-INF/spring.factories`: + +[source,properties] +---- +org.springframework.boot.BootstrapRegistryInitializer=com.app.AppConfigBootstrapConfiguration +---- + +If you want to use the autoconfigured `AppConfigDataClient` but change the underlying SDKClient or ClientOverrideConfiguration, you will need to register a bean of type `AwsAppConfigDataClientCustomizer`. +Autoconfiguration will configure the `AppConfigDataClient` bean with the provided values. For example: + +[source,java] +---- +package com.app; + +import io.awspring.cloud.autoconfigure.config.appconfig.AwsAppConfigDataClientCustomizer; +import java.time.Duration; +import org.springframework.boot.BootstrapRegistry; +import org.springframework.boot.BootstrapRegistryInitializer; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.apache.ApacheHttpClient; + +class AppConfigBootstrapConfiguration implements BootstrapRegistryInitializer { + + @Override + public void initialize(BootstrapRegistry registry) { + registry.register(AwsAppConfigDataClientCustomizer.class, + context -> new AwsAppConfigDataClientCustomizer() { + + @Override + public ClientOverrideConfiguration overrideConfiguration() { + return ClientOverrideConfiguration.builder().apiCallTimeout(Duration.ofMillis(500)) + .build(); + } + + @Override + public SdkHttpClient httpClient() { + return ApacheHttpClient.builder().connectionTimeout(Duration.ofMillis(1000)).build(); + } + }); + } +} +---- + +=== Configuration + +The Spring Boot Starter for AppConfig provides the following configuration options: + +[cols="2,3,1,1"] +|=== +| Name | Description | Required | Default value +| `spring.cloud.aws.appconfig.enabled` | Enables the AppConfig integration. | No | `true` +| `spring.cloud.aws.appconfig.endpoint` | Configures the endpoint used by `AppConfigDataClient`. | No | `null` +| `spring.cloud.aws.appconfig.region` | Configures the region used by `AppConfigDataClient`. | No | `null` +| `spring.cloud.aws.appconfig.separator` | Configures the separator used to split the import key into application, profile, and environment. | No | `#` +|=== + +=== IAM Permissions + +The following IAM permissions are required by Spring Cloud AWS: + +[cols="2"] +|=== +| Start session | `appconfig:StartConfigurationSession` +| Get configuration | `appconfig:GetLatestConfiguration` +|=== + +Sample IAM policy granting access to AppConfig: + +[source,json,indent=0] +---- +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "appconfig:GetLatestConfiguration", + "appconfig:StartConfigurationSession" + ], + "Resource": "yourARN" + } + ] +} +---- diff --git a/docs/src/main/asciidoc/index.adoc b/docs/src/main/asciidoc/index.adoc index 5adaf4055f..287ce51c22 100644 --- a/docs/src/main/asciidoc/index.adoc +++ b/docs/src/main/asciidoc/index.adoc @@ -162,6 +162,8 @@ include::spring-modulith.adoc[] include::kinesis-stream-binder.adoc[] +include::appconfig.adoc[] + include::testing.adoc[] include::docker-compose.adoc[] diff --git a/docs/src/main/asciidoc/sqs.adoc b/docs/src/main/asciidoc/sqs.adoc index a50bcc9e69..0f7a8dfd2e 100644 --- a/docs/src/main/asciidoc/sqs.adoc +++ b/docs/src/main/asciidoc/sqs.adoc @@ -2093,8 +2093,6 @@ By default, `StringMessageConverter`, `SimpleMessageConverter` and `JacksonJsonM Set to `null` to disable automatic inference and rely on header-based type mapping. See <> for more information. ----- - NOTE: Any number of `SqsListenerConfigurer` beans can be registered in the context. All instances will be looked up at application startup and iterated through. diff --git a/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java b/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java index 6a3f4783b2..cd11be66b0 100644 --- a/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java +++ b/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java @@ -43,7 +43,7 @@ class AppConfigPropertySourceTest { private static final RequestContext DEFAULT_CONTEXT = new RequestContext("profile1", "env1", "app1", - "app1#env1#profile1"); + "app1#profile1#env1"); private static final String RESOURCE_PATH = "io/awspring/cloud/appconfig/"; @@ -280,7 +280,7 @@ void copyShouldNotCallStartSessionAgain() { @Test void shouldUseCorrectContextIdentifiers() { - RequestContext context = new RequestContext("myProfile", "myEnv", "myApp", "myApp#myEnv#myProfile"); + RequestContext context = new RequestContext("myProfile", "myEnv", "myApp", "myApp#myProfile#myEnv"); AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java index 2a718b2f31..e18d6ef7e5 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java @@ -79,7 +79,11 @@ public List resolve(ConfigDataLocationResolverContext res private RequestContext resolveContext(String propertySourceContext, String separator) { var response = propertySourceContext.split(java.util.regex.Pattern.quote(separator)); - return new RequestContext(response[0].trim(), response[1].trim(), response[2].trim(), propertySourceContext); + // Format: ApplicationName#ConfigurationProfileName#EnvironmentName + String applicationIdentifier = response[0].trim(); + String configurationProfileIdentifier = response[1].trim(); + String environmentIdentifier = response[2].trim(); + return new RequestContext(configurationProfileIdentifier, environmentIdentifier, applicationIdentifier, propertySourceContext); } private AppConfigDataClient createAppConfigDataClient(BootstrapContext context) { diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java index bc1cecd247..cabf2a163f 100644 --- a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java @@ -130,9 +130,9 @@ static void beforeAll() throws IOException { PROFILE_ID_JSON = createProfileWithContent("jsonProfile", "application/json", "io/awspring/cloud/autoconfigure/config/appconfig/test-config.json"); - IMPORT_PROPERTIES = "aws-appconfig:" + PROFILE_ID_PROPERTIES + "#" + ENV_ID + "#" + APP_ID; - IMPORT_YAML = "aws-appconfig:" + PROFILE_ID_YAML + "#" + ENV_ID + "#" + APP_ID; - IMPORT_JSON = "aws-appconfig:" + PROFILE_ID_JSON + "#" + ENV_ID + "#" + APP_ID; + IMPORT_PROPERTIES = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_PROPERTIES + "#" + ENV_ID; + IMPORT_YAML = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_YAML + "#" + ENV_ID; + IMPORT_JSON = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_JSON + "#" + ENV_ID; } private static String createProfileWithContent(String profileName, String contentType, @@ -193,7 +193,7 @@ void whenKeysAreNotSpecifiedFailsWithHumanReadableFailureMessage(CapturedOutput void whenKeysCannotBeFoundFailWithHumanReadableMessage(CapturedOutput output) { SpringApplication application = createApplication(); - assertThatThrownBy(() -> runApplication(application, "aws-appconfig:invalidApp#invalidEnv#invalidProfile")) + assertThatThrownBy(() -> runApplication(application, "aws-appconfig:invalidApp#invalidProfile#invalidEnv")) .isInstanceOf(AwsAppConfigPropertySourceNotFoundException.class); String errorMessage = "Description:%1$s%1$sCould not import properties from App Config. Exception happened while trying to load the keys" .formatted(NEW_LINE_CHAR); @@ -217,7 +217,7 @@ void customSeparatorIsRespected() { SpringApplication application = createApplication(); try (ConfigurableApplicationContext context = runApplication(application, - "aws-appconfig:" + PROFILE_ID_PROPERTIES + "/" + ENV_ID + "/" + APP_ID, + "aws-appconfig:" + APP_ID + "/" + PROFILE_ID_PROPERTIES + "/" + ENV_ID, "spring.cloud.aws.endpoint", "--spring.cloud.aws.appconfig.separator=/")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); From 75d35ea3f8ab426cc7e69f5db1313329d0ad35b9 Mon Sep 17 00:00:00 2001 From: matejnedic Date: Mon, 9 Feb 2026 23:55:26 +0100 Subject: [PATCH 4/7] Update docs --- docs/src/main/asciidoc/appconfig.adoc | 37 --------------------------- 1 file changed, 37 deletions(-) diff --git a/docs/src/main/asciidoc/appconfig.adoc b/docs/src/main/asciidoc/appconfig.adoc index d5b2765bf4..62282c6ad9 100644 --- a/docs/src/main/asciidoc/appconfig.adoc +++ b/docs/src/main/asciidoc/appconfig.adoc @@ -111,43 +111,6 @@ Note that this class must be listed under the `org.springframework.boot.Bootstra org.springframework.boot.BootstrapRegistryInitializer=com.app.AppConfigBootstrapConfiguration ---- -If you want to use the autoconfigured `AppConfigDataClient` but change the underlying SDKClient or ClientOverrideConfiguration, you will need to register a bean of type `AwsAppConfigDataClientCustomizer`. -Autoconfiguration will configure the `AppConfigDataClient` bean with the provided values. For example: - -[source,java] ----- -package com.app; - -import io.awspring.cloud.autoconfigure.config.appconfig.AwsAppConfigDataClientCustomizer; -import java.time.Duration; -import org.springframework.boot.BootstrapRegistry; -import org.springframework.boot.BootstrapRegistryInitializer; -import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; -import software.amazon.awssdk.http.SdkHttpClient; -import software.amazon.awssdk.http.apache.ApacheHttpClient; - -class AppConfigBootstrapConfiguration implements BootstrapRegistryInitializer { - - @Override - public void initialize(BootstrapRegistry registry) { - registry.register(AwsAppConfigDataClientCustomizer.class, - context -> new AwsAppConfigDataClientCustomizer() { - - @Override - public ClientOverrideConfiguration overrideConfiguration() { - return ClientOverrideConfiguration.builder().apiCallTimeout(Duration.ofMillis(500)) - .build(); - } - - @Override - public SdkHttpClient httpClient() { - return ApacheHttpClient.builder().connectionTimeout(Duration.ofMillis(1000)).build(); - } - }); - } -} ----- - === Configuration The Spring Boot Starter for AppConfig provides the following configuration options: From 16fbb9e0d1471bf706ca508f2f54da6f66ee82b8 Mon Sep 17 00:00:00 2001 From: matejnedic Date: Mon, 9 Feb 2026 23:57:00 +0100 Subject: [PATCH 5/7] Update docs --- docs/src/main/asciidoc/appconfig.adoc | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/src/main/asciidoc/appconfig.adoc b/docs/src/main/asciidoc/appconfig.adoc index 62282c6ad9..6381c931a9 100644 --- a/docs/src/main/asciidoc/appconfig.adoc +++ b/docs/src/main/asciidoc/appconfig.adoc @@ -111,6 +111,36 @@ Note that this class must be listed under the `org.springframework.boot.Bootstra org.springframework.boot.BootstrapRegistryInitializer=com.app.AppConfigBootstrapConfiguration ---- + +If you want to use autoconfigured `AppConfigDataClient` but change underlying SDKClient or `ClientOverrideConfiguration` you will need to register bean of type `AppConfigClientCustomizer`: +Autoconfiguration will configure `AppConfigDataClient` Bean with provided values after that, for example: + +[source,java] +---- +package com.app; + +import io.awspring.cloud.autoconfigure.config.appconfig.AppConfigClientCustomizer; +import java.time.Duration; +import org.springframework.boot.BootstrapRegistry; +import org.springframework.boot.BootstrapRegistryInitializer; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClientBuilder; + +class AppConfigBootstrapConfiguration implements BootstrapRegistryInitializer { + + @Override + public void initialize(BootstrapRegistry registry) { + registry.register(AppConfigClientCustomizer.class, context -> (builder -> { + builder.overrideConfiguration(builder.overrideConfiguration().copy(c -> { + c.apiCallTimeout(Duration.ofMillis(2001)); + })); + })); + } +} +---- + === Configuration The Spring Boot Starter for AppConfig provides the following configuration options: From 80482b29b56701ba32e971e0d5771be60c7b98cd Mon Sep 17 00:00:00 2001 From: matejnedic Date: Tue, 10 Feb 2026 00:01:55 +0100 Subject: [PATCH 6/7] Spotless Apply --- .../appconfig/AppConfigPropertySource.java | 50 +++-- .../cloud/appconfig/RequestContext.java | 29 ++- .../AppConfigPropertySourceTest.java | 115 +++++----- .../appconfig/AppConfigClientCustomizer.java | 15 ++ .../appconfig/AppConfigConfigDataLoader.java | 35 ++- .../AppConfigDataLocationResolver.java | 54 +++-- .../appconfig/AppConfigDataResource.java | 21 +- .../AppConfigExceptionHappenedAnalyzer.java | 3 +- .../AppConfigMissingKeysFailureAnalyzer.java | 21 +- .../config/appconfig/AppConfigProperties.java | 22 +- .../appconfig/AppConfigPropertySources.java | 21 +- .../AppConfigReloadAutoConfiguration.java | 3 +- ...ConfigPropertySourceNotFoundException.java | 16 +- ...onfigConfigDataLoaderIntegrationTests.java | 204 +++++++----------- ...AppConfigReloadAutoConfigurationTests.java | 3 +- 15 files changed, 354 insertions(+), 258 deletions(-) diff --git a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java index 83f602a542..c21e7a4663 100644 --- a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java +++ b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/AppConfigPropertySource.java @@ -1,6 +1,25 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.appconfig; import io.awspring.cloud.core.config.AwsPropertySource; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; import org.springframework.core.io.InputStreamResource; @@ -11,11 +30,6 @@ import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationResponse; import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionRequest; -import java.io.InputStream; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Properties; - /** * Retrieves configuration property sources path from the AWS AppConfig using the provided {@link AppConfigDataClient}. * @@ -37,7 +51,8 @@ public AppConfigPropertySource(RequestContext context, AppConfigDataClient appCo this(context, appConfigClient, null, null); } - public AppConfigPropertySource(RequestContext context, AppConfigDataClient appConfigClient, @Nullable String sessionToken, Map properties) { + public AppConfigPropertySource(RequestContext context, AppConfigDataClient appConfigClient, + @Nullable String sessionToken, Map properties) { super("aws-appconfig:" + context, appConfigClient); Assert.notNull(context, "context is required"); this.context = context; @@ -50,22 +65,21 @@ public AppConfigPropertySource(RequestContext context, AppConfigDataClient appCo public void init() { if (!StringUtils.hasText(sessionToken)) { var request = StartConfigurationSessionRequest.builder() - .applicationIdentifier(context.getApplicationIdentifier()) - .environmentIdentifier(context.getEnvironmentIdentifier()) - .configurationProfileIdentifier(context.getConfigurationProfileIdentifier()) - .build(); + .applicationIdentifier(context.getApplicationIdentifier()) + .environmentIdentifier(context.getEnvironmentIdentifier()) + .configurationProfileIdentifier(context.getConfigurationProfileIdentifier()).build(); sessionToken = appConfigClient.startConfigurationSession(request).initialConfigurationToken(); } - GetLatestConfigurationRequest request = GetLatestConfigurationRequest.builder().configurationToken(sessionToken).build(); + GetLatestConfigurationRequest request = GetLatestConfigurationRequest.builder().configurationToken(sessionToken) + .build(); GetLatestConfigurationResponse response = this.source.getLatestConfiguration(request); if (response.configuration().asByteArray().length > 0) { properties.clear(); var props = switch (response.contentType()) { - case TEXT_TYPE -> readProperties(response.configuration().asInputStream()); - case YAML_TYPE, YAML_TYPE_ALTERNATIVE, JSON_TYPE -> readYaml(response.configuration().asInputStream()); - default -> throw new IllegalStateException( - "Cannot parse unknown content type: " + response.contentType()); + case TEXT_TYPE -> readProperties(response.configuration().asInputStream()); + case YAML_TYPE, YAML_TYPE_ALTERNATIVE, JSON_TYPE -> readYaml(response.configuration().asInputStream()); + default -> throw new IllegalStateException("Cannot parse unknown content type: " + response.contentType()); }; for (Map.Entry entry : props.entrySet()) { properties.put(String.valueOf(entry.getKey()), entry.getValue()); @@ -93,7 +107,8 @@ private Properties readProperties(InputStream inputStream) { Properties properties = new Properties(); try (InputStream in = inputStream) { properties.load(in); - } catch (Exception e) { + } + catch (Exception e) { throw new IllegalStateException("Cannot load environment", e); } return properties; @@ -104,7 +119,8 @@ private Properties readYaml(InputStream inputStream) { try (InputStream in = inputStream) { yaml.setResources(new InputStreamResource(in)); return yaml.getObject(); - } catch (Exception e) { + } + catch (Exception e) { throw new IllegalStateException("Cannot load environment", e); } } diff --git a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java index 667cc29137..7d7215fdac 100644 --- a/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java +++ b/spring-cloud-aws-app-config/src/main/java/io/awspring/cloud/appconfig/RequestContext.java @@ -1,3 +1,18 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.appconfig; import java.util.Objects; @@ -27,7 +42,8 @@ public class RequestContext { */ private String context; - public RequestContext(String configurationProfileIdentifier, String environmentIdentifier, String applicationIdentifier, String context) { + public RequestContext(String configurationProfileIdentifier, String environmentIdentifier, + String applicationIdentifier, String context) { this.configurationProfileIdentifier = configurationProfileIdentifier; this.environmentIdentifier = environmentIdentifier; this.applicationIdentifier = applicationIdentifier; @@ -68,10 +84,15 @@ public void setContext(String context) { @Override public boolean equals(Object object) { - if (this == object) return true; - if (object == null || getClass() != object.getClass()) return false; + if (this == object) + return true; + if (object == null || getClass() != object.getClass()) + return false; RequestContext that = (RequestContext) object; - return Objects.equals(configurationProfileIdentifier, that.configurationProfileIdentifier) && Objects.equals(environmentIdentifier, that.environmentIdentifier) && Objects.equals(applicationIdentifier, that.applicationIdentifier) && Objects.equals(context, that.context); + return Objects.equals(configurationProfileIdentifier, that.configurationProfileIdentifier) + && Objects.equals(environmentIdentifier, that.environmentIdentifier) + && Objects.equals(applicationIdentifier, that.applicationIdentifier) + && Objects.equals(context, that.context); } @Override diff --git a/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java b/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java index cd11be66b0..1a6159699a 100644 --- a/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java +++ b/spring-cloud-aws-app-config/src/test/java/io/awspring/cloud/appconfig/AppConfigPropertySourceTest.java @@ -69,23 +69,21 @@ void setupDefaultSessionMock() { void shouldParsePropertiesContentType() { AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenAnswer(invocation -> { - StartConfigurationSessionRequest request = invocation.getArgument(0); - assertThat(request.applicationIdentifier()).isEqualTo("app1"); - assertThat(request.environmentIdentifier()).isEqualTo("env1"); - assertThat(request.configurationProfileIdentifier()).isEqualTo("profile1"); - return StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build(); - }); + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))).thenAnswer(invocation -> { + StartConfigurationSessionRequest request = invocation.getArgument(0); + assertThat(request.applicationIdentifier()).isEqualTo("app1"); + assertThat(request.environmentIdentifier()).isEqualTo("env1"); + assertThat(request.configurationProfileIdentifier()).isEqualTo("profile1"); + return StartConfigurationSessionResponse.builder().initialConfigurationToken("initial-token").build(); + }); GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.properties"), "text/plain"); - when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) - .thenAnswer(invocation -> { - GetLatestConfigurationRequest request = invocation.getArgument(0); - assertThat(request.configurationToken()).isEqualTo("initial-token"); - return response; - }); + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("initial-token"); + return response; + }); propertySource.init(); @@ -99,8 +97,7 @@ void shouldParsePropertiesContentType() { void shouldParseYamlContentType() { AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - GetLatestConfigurationResponse response = buildResponse( - loadResource("test-config.yaml"), "application/x-yaml"); + GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.yaml"), "application/x-yaml"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); @@ -131,8 +128,7 @@ void shouldParseAlternativeYamlContentType() { void shouldParseJsonContentType() { AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); - GetLatestConfigurationResponse response = buildResponse( - loadResource("test-config.json"), "application/json"); + GetLatestConfigurationResponse response = buildResponse(loadResource("test-config.json"), "application/json"); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); @@ -151,9 +147,8 @@ void throwsExceptionForUnsupportedContentType() { when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); - assertThatThrownBy(propertySource::init) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Cannot parse unknown content type: application/xml"); + assertThatThrownBy(propertySource::init).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot parse unknown content type: application/xml"); } @Test @@ -174,12 +169,11 @@ void shouldInitializeSessionTokenOnFirstInit() { void shouldReuseSessionTokenOnSubsequentInit() { GetLatestConfigurationResponse response = buildResponse("key1=value1", "text/plain"); - when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) - .thenAnswer(invocation -> { - GetLatestConfigurationRequest request = invocation.getArgument(0); - assertThat(request.configurationToken()).isEqualTo("existing-token"); - return response; - }); + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("existing-token"); + return response; + }); AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client, "existing-token", null); @@ -195,17 +189,14 @@ void shouldHandleEmptyConfigurationResponse() { AppConfigPropertySource propertySource = new AppConfigPropertySource(DEFAULT_CONTEXT, client); GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromByteArray(new byte[0])) - .contentType("text/plain") - .nextPollConfigurationToken("next-token") - .build(); - - when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) - .thenAnswer(invocation -> { - GetLatestConfigurationRequest request = invocation.getArgument(0); - assertThat(request.configurationToken()).isEqualTo("initial-token"); - return response; - }); + .configuration(SdkBytes.fromByteArray(new byte[0])).contentType("text/plain") + .nextPollConfigurationToken("next-token").build(); + + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("initial-token"); + return response; + }); propertySource.init(); @@ -219,17 +210,15 @@ void shouldUpdateNextPollTokenAfterInit() { GetLatestConfigurationResponse firstResponse = buildResponse("key1=value1", "text/plain", "next-token-1"); GetLatestConfigurationResponse secondResponse = buildResponse("key2=value2", "text/plain", "next-token-2"); - when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))) - .thenAnswer(invocation -> { - GetLatestConfigurationRequest request = invocation.getArgument(0); - assertThat(request.configurationToken()).isEqualTo("initial-token"); - return firstResponse; - }) - .thenAnswer(invocation -> { - GetLatestConfigurationRequest request = invocation.getArgument(0); - assertThat(request.configurationToken()).isEqualTo("next-token-1"); - return secondResponse; - }); + when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("initial-token"); + return firstResponse; + }).thenAnswer(invocation -> { + GetLatestConfigurationRequest request = invocation.getArgument(0); + assertThat(request.configurationToken()).isEqualTo("next-token-1"); + return secondResponse; + }); propertySource.init(); assertThat(propertySource.getProperty("key1")).isEqualTo("value1"); @@ -283,20 +272,17 @@ void shouldUseCorrectContextIdentifiers() { RequestContext context = new RequestContext("myProfile", "myEnv", "myApp", "myApp#myProfile#myEnv"); AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); - when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))) - .thenAnswer(invocation -> { - StartConfigurationSessionRequest request = invocation.getArgument(0); - assertThat(request.applicationIdentifier()).isEqualTo("myApp"); - assertThat(request.environmentIdentifier()).isEqualTo("myEnv"); - assertThat(request.configurationProfileIdentifier()).isEqualTo("myProfile"); - return StartConfigurationSessionResponse.builder().initialConfigurationToken("token").build(); - }); + when(client.startConfigurationSession(any(StartConfigurationSessionRequest.class))).thenAnswer(invocation -> { + StartConfigurationSessionRequest request = invocation.getArgument(0); + assertThat(request.applicationIdentifier()).isEqualTo("myApp"); + assertThat(request.environmentIdentifier()).isEqualTo("myEnv"); + assertThat(request.configurationProfileIdentifier()).isEqualTo("myProfile"); + return StartConfigurationSessionResponse.builder().initialConfigurationToken("token").build(); + }); GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromByteArray(new byte[0])) - .contentType("text/plain") - .nextPollConfigurationToken("next-token") - .build(); + .configuration(SdkBytes.fromByteArray(new byte[0])).contentType("text/plain") + .nextPollConfigurationToken("next-token").build(); when(client.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); @@ -310,10 +296,7 @@ private GetLatestConfigurationResponse buildResponse(String content, String cont } private GetLatestConfigurationResponse buildResponse(String content, String contentType, String nextToken) { - return GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(content)) - .contentType(contentType) - .nextPollConfigurationToken(nextToken) - .build(); + return GetLatestConfigurationResponse.builder().configuration(SdkBytes.fromUtf8String(content)) + .contentType(contentType).nextPollConfigurationToken(nextToken).build(); } } diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java index 0d415b8634..abdab8c274 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigClientCustomizer.java @@ -1,3 +1,18 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; import io.awspring.cloud.autoconfigure.AwsClientCustomizer; diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java index ed6989b256..002b80f61b 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoader.java @@ -1,7 +1,24 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; import io.awspring.cloud.appconfig.AppConfigPropertySource; import io.awspring.cloud.autoconfigure.config.BootstrapLoggingHelper; +import java.util.Collections; +import java.util.Map; import org.jspecify.annotations.Nullable; import org.springframework.boot.context.config.ConfigData; import org.springframework.boot.context.config.ConfigDataLoader; @@ -10,9 +27,6 @@ import org.springframework.core.env.MapPropertySource; import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; -import java.util.Collections; -import java.util.Map; - /** * Loads config data from AWS AppConfig. * @@ -22,9 +36,8 @@ public class AppConfigConfigDataLoader implements ConfigDataLoader { public AppConfigConfigDataLoader(DeferredLogFactory logFactory) { - BootstrapLoggingHelper.reconfigureLoggers(logFactory, - "io.awspring.cloud.appconfig.AppConfigPropertySource", - "io.awspring.cloud.autoconfigure.config.appconfig.AppConfigPropertySources"); + BootstrapLoggingHelper.reconfigureLoggers(logFactory, "io.awspring.cloud.appconfig.AppConfigPropertySource", + "io.awspring.cloud.autoconfigure.config.appconfig.AppConfigPropertySources"); } @Override @@ -35,16 +48,18 @@ public ConfigData load(ConfigDataLoaderContext context, AppConfigDataResource re if (resource.isEnabled()) { AppConfigDataClient appConfigDataClient = context.getBootstrapContext().get(AppConfigDataClient.class); AppConfigPropertySource propertySource = resource.getPropertySources() - .createPropertySource(resource.getContext(), resource.isOptional(), appConfigDataClient); + .createPropertySource(resource.getContext(), resource.isOptional(), appConfigDataClient); if (propertySource != null) { return new ConfigData(Collections.singletonList(propertySource)); - } else { + } + else { return null; } - } else { + } + else { // create dummy empty config data return new ConfigData( - Collections.singletonList(new MapPropertySource("aws-appconfig:" + context, Map.of()))); + Collections.singletonList(new MapPropertySource("aws-appconfig:" + context, Map.of()))); } } } diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java index e18d6ef7e5..fbe52ca13e 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataLocationResolver.java @@ -1,3 +1,18 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; import io.awspring.cloud.appconfig.RequestContext; @@ -6,6 +21,8 @@ import io.awspring.cloud.autoconfigure.core.AwsProperties; import io.awspring.cloud.autoconfigure.core.CredentialsProperties; import io.awspring.cloud.autoconfigure.core.RegionProperties; +import java.util.ArrayList; +import java.util.List; import org.apache.commons.logging.Log; import org.springframework.boot.bootstrap.BootstrapContext; import org.springframework.boot.context.config.ConfigDataLocation; @@ -17,9 +34,6 @@ import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; import software.amazon.awssdk.services.appconfigdata.AppConfigDataClientBuilder; -import java.util.ArrayList; -import java.util.List; - /** * Resolves config data locations in AWS App Config. * @author Matej Nedic @@ -45,34 +59,36 @@ protected String getPrefix() { @Override public List resolve(ConfigDataLocationResolverContext resolverContext, - ConfigDataLocation location) throws ConfigDataLocationNotFoundException { + ConfigDataLocation location) throws ConfigDataLocationNotFoundException { AppConfigProperties appConfigProperties = loadProperties(resolverContext.getBinder()); List locations = new ArrayList<>(); AppConfigPropertySources propertySources = new AppConfigPropertySources(); List contexts = getCustomContexts(location.getNonPrefixedValue(PREFIX)); - if (appConfigProperties.isEnabled()) { registerBean(resolverContext, AwsProperties.class, loadAwsProperties(resolverContext.getBinder())); registerBean(resolverContext, AppConfigProperties.class, appConfigProperties); registerBean(resolverContext, CredentialsProperties.class, - loadCredentialsProperties(resolverContext.getBinder())); + loadCredentialsProperties(resolverContext.getBinder())); registerBean(resolverContext, RegionProperties.class, loadRegionProperties(resolverContext.getBinder())); registerAndPromoteBean(resolverContext, AppConfigDataClient.class, this::createAppConfigDataClient); - contexts.forEach(propertySourceContext -> locations - .add(new AppConfigDataResource(resolveContext(propertySourceContext, appConfigProperties.getSeparator()), location.isOptional(), propertySources))); + contexts.forEach(propertySourceContext -> locations.add( + new AppConfigDataResource(resolveContext(propertySourceContext, appConfigProperties.getSeparator()), + location.isOptional(), propertySources))); if (!location.isOptional() && locations.isEmpty()) { throw new AppConfigKeysMissingException( - "No AppConfigData keys provided in `spring.config.import=aws-appconfig:` configuration."); + "No AppConfigData keys provided in `spring.config.import=aws-appconfig:` configuration."); } - } else { + } + else { // create dummy resources with enabled flag set to false, // because returned locations cannot be empty contexts.forEach(propertySourceContext -> locations.add( - new AppConfigDataResource(resolveContext(propertySourceContext, appConfigProperties.getSeparator()), location.isOptional(), false, propertySources))); + new AppConfigDataResource(resolveContext(propertySourceContext, appConfigProperties.getSeparator()), + location.isOptional(), false, propertySources))); } return locations; } @@ -83,18 +99,21 @@ private RequestContext resolveContext(String propertySourceContext, String separ String applicationIdentifier = response[0].trim(); String configurationProfileIdentifier = response[1].trim(); String environmentIdentifier = response[2].trim(); - return new RequestContext(configurationProfileIdentifier, environmentIdentifier, applicationIdentifier, propertySourceContext); + return new RequestContext(configurationProfileIdentifier, environmentIdentifier, applicationIdentifier, + propertySourceContext); } private AppConfigDataClient createAppConfigDataClient(BootstrapContext context) { - AppConfigDataClientBuilder builder = configure(AppConfigDataClient.builder(), context.get(AppConfigProperties.class), context); + AppConfigDataClientBuilder builder = configure(AppConfigDataClient.builder(), + context.get(AppConfigProperties.class), context); try { AppConfigClientCustomizer appConfigClientCustomizer = context.get(AppConfigClientCustomizer.class); if (appConfigClientCustomizer != null) { appConfigClientCustomizer.customize(builder); } - } catch (IllegalStateException e) { + } + catch (IllegalStateException e) { log.debug("Bean of type AwsSyncClientCustomizer is not registered: " + e.getMessage()); } @@ -103,15 +122,16 @@ private AppConfigDataClient createAppConfigDataClient(BootstrapContext context) if (awsSyncClientCustomizer != null) { awsSyncClientCustomizer.customize(builder); } - } catch (IllegalStateException e) { + } + catch (IllegalStateException e) { log.debug("Bean of type AwsSyncClientCustomizer is not registered: " + e.getMessage()); } return builder.build(); } - protected AppConfigProperties loadProperties(Binder binder) { - return binder.bind(AppConfigProperties.PREFIX, Bindable.of(AppConfigProperties.class)).orElseGet(AppConfigProperties::new); + return binder.bind(AppConfigProperties.PREFIX, Bindable.of(AppConfigProperties.class)) + .orElseGet(AppConfigProperties::new); } } diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java index ba24d5bcba..9c66fbf73d 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigDataResource.java @@ -1,11 +1,25 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; import io.awspring.cloud.appconfig.RequestContext; +import java.util.Objects; import org.springframework.boot.context.config.ConfigDataResource; import org.springframework.core.style.ToStringCreator; -import java.util.Objects; - /** * Config data resource for AWS App Config integration. * @author Matej Nedic @@ -25,7 +39,8 @@ public AppConfigDataResource(RequestContext context, boolean optional, AppConfig this(context, optional, true, propertySources); } - public AppConfigDataResource(RequestContext context, boolean optional, boolean enabled, AppConfigPropertySources propertySources) { + public AppConfigDataResource(RequestContext context, boolean optional, boolean enabled, + AppConfigPropertySources propertySources) { this.context = context; this.optional = optional; this.propertySources = propertySources; diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java index 43cdfd398c..bb56f97692 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigExceptionHappenedAnalyzer.java @@ -19,7 +19,8 @@ import org.springframework.boot.diagnostics.FailureAnalysis; /** - * An {@link AbstractFailureAnalyzer} that performs analysis of an AppConfig configuration failure caused by failure of {@link software.amazon.awssdk.services.appconfigdata.AppConfigDataClient} call. + * An {@link AbstractFailureAnalyzer} that performs analysis of an AppConfig configuration failure caused by failure of + * {@link software.amazon.awssdk.services.appconfigdata.AppConfigDataClient} call. * * @author Matej Nedic * @since 4.1.0 diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java index 1ddc3e91ac..4644632ccc 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigMissingKeysFailureAnalyzer.java @@ -1,11 +1,26 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; import org.springframework.boot.diagnostics.FailureAnalysis; /** - * An {@link AbstractFailureAnalyzer} that performs analysis of an AppConfig configuration failure caused by not providing an AppConfig - * key to `spring.config.import` property. + * An {@link AbstractFailureAnalyzer} that performs analysis of an AppConfig configuration failure caused by not + * providing an AppConfig key to `spring.config.import` property. * * @author Matej Nedic * @since 4.1.0 @@ -15,7 +30,7 @@ public class AppConfigMissingKeysFailureAnalyzer extends AbstractFailureAnalyzer @Override protected FailureAnalysis analyze(Throwable rootFailure, AppConfigKeysMissingException cause) { return new FailureAnalysis("Could not import properties from AWS App Config: " + cause.getMessage(), - "Consider providing keys, for example `spring.config.import=aws-appconfig:/config#app#prod`", cause); + "Consider providing keys, for example `spring.config.import=aws-appconfig:/config#app#prod`", cause); } } diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java index afc408dcdd..eee1886625 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigProperties.java @@ -1,12 +1,27 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; +import static io.awspring.cloud.autoconfigure.config.appconfig.AppConfigProperties.PREFIX; + import io.awspring.cloud.autoconfigure.AwsClientProperties; import io.awspring.cloud.autoconfigure.config.reload.ReloadProperties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; -import static io.awspring.cloud.autoconfigure.config.appconfig.AppConfigProperties.PREFIX; - /** * Configuration properties for AWS AppConfig integration. * @author Matej Nedic @@ -31,7 +46,6 @@ public class AppConfigProperties extends AwsClientProperties { @NestedConfigurationProperty private ReloadProperties reload = new ReloadProperties(); - private String separator = "#"; public boolean isEnabled() { @@ -42,7 +56,7 @@ public void setEnabled(boolean enabled) { this.enabled = enabled; } - public String getSeparator() { + public String getSeparator() { return separator; } diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java index 8f2854f062..a1ca5757d7 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigPropertySources.java @@ -1,3 +1,18 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; import io.awspring.cloud.appconfig.AppConfigPropertySource; @@ -25,7 +40,8 @@ public class AppConfigPropertySources { * @return a property source or null if config could not be loaded and optional is set to true */ @Nullable - public AppConfigPropertySource createPropertySource(RequestContext context, boolean optional, AppConfigDataClient client) { + public AppConfigPropertySource createPropertySource(RequestContext context, boolean optional, + AppConfigDataClient client) { Assert.notNull(context, "RequestContext is required"); Assert.notNull(client, "AppConfigDataClient is required"); @@ -34,7 +50,8 @@ public AppConfigPropertySource createPropertySource(RequestContext context, bool AppConfigPropertySource propertySource = new AppConfigPropertySource(context, client); propertySource.init(); return propertySource; - } catch (Exception e) { + } + catch (Exception e) { LOG.warn("Unable to load AWS App Config from " + context + ". " + e.getMessage()); if (!optional) { throw new AwsAppConfigPropertySourceNotFoundException(e); diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java index e53e356920..34fc8cce56 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfiguration.java @@ -19,6 +19,7 @@ import io.awspring.cloud.autoconfigure.config.reload.ConfigurationChangeDetector; import io.awspring.cloud.autoconfigure.config.reload.ConfigurationUpdateStrategy; import io.awspring.cloud.autoconfigure.config.reload.PollingAwsPropertySourceChangeDetector; +import java.util.Optional; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration; @@ -40,8 +41,6 @@ import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -import java.util.Optional; - /** * {@link EnableAutoConfiguration Auto-Configuration} for reloading properties from AppConfig. * diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java index c44a8fb855..0825fb40fa 100644 --- a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/config/appconfig/AwsAppConfigPropertySourceNotFoundException.java @@ -1,6 +1,20 @@ +/* + * Copyright 2013-2026 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 io.awspring.cloud.autoconfigure.config.appconfig; - /** * An exception thrown if there is failure when calling AppConfig. * diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java index cabf2a163f..8b8fcd7268 100644 --- a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigConfigDataLoaderIntegrationTests.java @@ -19,8 +19,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.testcontainers.shaded.org.awaitility.Awaitility.await; -import io.awspring.cloud.autoconfigure.AwsSyncClientCustomizer; -import io.awspring.cloud.autoconfigure.ConfiguredAwsClient; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Duration; @@ -36,8 +34,6 @@ import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.WebApplicationType; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.bootstrap.BootstrapRegistry; -import org.springframework.boot.bootstrap.BootstrapRegistryInitializer; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.context.ConfigurableApplicationContext; @@ -47,10 +43,8 @@ import org.testcontainers.localstack.LocalStackContainer; import org.testcontainers.utility.DockerImageName; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.http.apache.ApacheHttpClient; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.appconfig.AppConfigClient; import software.amazon.awssdk.services.appconfig.model.*; @@ -83,86 +77,59 @@ class AppConfigConfigDataLoaderIntegrationTests { @Container static LocalStackContainer localstack = new LocalStackContainer( - DockerImageName.parse("localstack/localstack-pro:4.4.0")) - .withEnv("LOCALSTACK_AUTH_TOKEN", api_key) - .withEnv("AWS_DEFAULT_REGION", REGION); + DockerImageName.parse("localstack/localstack-pro:4.4.0")).withEnv("LOCALSTACK_AUTH_TOKEN", api_key) + .withEnv("AWS_DEFAULT_REGION", REGION); @BeforeAll static void beforeAll() throws IOException { - appConfigClient = AppConfigClient.builder() - .endpointOverride(localstack.getEndpoint()) - .region(Region.of(REGION)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) - .build(); + appConfigClient = AppConfigClient.builder().endpointOverride(localstack.getEndpoint()).region(Region.of(REGION)) + .credentialsProvider(StaticCredentialsProvider + .create(AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) + .build(); CreateApplicationResponse appResponse = appConfigClient.createApplication( - CreateApplicationRequest.builder() - .name("myApp") - .description("My Application") - .build()); - APP_ID = appResponse.id(); - - CreateEnvironmentResponse envResponse = appConfigClient.createEnvironment( - CreateEnvironmentRequest.builder() - .applicationId(APP_ID) - .name("myEnv") - .description("My Environment") - .build()); - ENV_ID = envResponse.id(); - - CreateDeploymentStrategyResponse strategyResponse = appConfigClient.createDeploymentStrategy( - CreateDeploymentStrategyRequest.builder() - .name("myStrategy") - .description("My Strategy") - .deploymentDurationInMinutes(0) - .growthFactor(100.0f) - .finalBakeTimeInMinutes(0) - .build()); - STRATEGY_ID = strategyResponse.id(); - - PROFILE_ID_PROPERTIES = createProfileWithContent("propertiesProfile", "text/plain", + CreateApplicationRequest.builder().name("myApp").description("My Application").build()); + APP_ID = appResponse.id(); + + CreateEnvironmentResponse envResponse = appConfigClient.createEnvironment(CreateEnvironmentRequest.builder() + .applicationId(APP_ID).name("myEnv").description("My Environment").build()); + ENV_ID = envResponse.id(); + + CreateDeploymentStrategyResponse strategyResponse = appConfigClient.createDeploymentStrategy( + CreateDeploymentStrategyRequest.builder().name("myStrategy").description("My Strategy") + .deploymentDurationInMinutes(0).growthFactor(100.0f).finalBakeTimeInMinutes(0).build()); + STRATEGY_ID = strategyResponse.id(); + + PROFILE_ID_PROPERTIES = createProfileWithContent("propertiesProfile", "text/plain", "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); - PROFILE_ID_YAML = createProfileWithContent("yamlProfile", "application/x-yaml", + PROFILE_ID_YAML = createProfileWithContent("yamlProfile", "application/x-yaml", "io/awspring/cloud/autoconfigure/config/appconfig/test-config.yaml"); - PROFILE_ID_JSON = createProfileWithContent("jsonProfile", "application/json", + PROFILE_ID_JSON = createProfileWithContent("jsonProfile", "application/json", "io/awspring/cloud/autoconfigure/config/appconfig/test-config.json"); - IMPORT_PROPERTIES = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_PROPERTIES + "#" + ENV_ID; - IMPORT_YAML = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_YAML + "#" + ENV_ID; - IMPORT_JSON = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_JSON + "#" + ENV_ID; + IMPORT_PROPERTIES = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_PROPERTIES + "#" + ENV_ID; + IMPORT_YAML = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_YAML + "#" + ENV_ID; + IMPORT_JSON = "aws-appconfig:" + APP_ID + "#" + PROFILE_ID_JSON + "#" + ENV_ID; } - private static String createProfileWithContent(String profileName, String contentType, - String resourcePath) throws IOException { - CreateConfigurationProfileResponse profileResponse = appConfigClient.createConfigurationProfile( - CreateConfigurationProfileRequest.builder() - .applicationId(APP_ID) - .name(profileName) - .locationUri("hosted") - .build()); + private static String createProfileWithContent(String profileName, String contentType, String resourcePath) + throws IOException { + CreateConfigurationProfileResponse profileResponse = appConfigClient + .createConfigurationProfile(CreateConfigurationProfileRequest.builder().applicationId(APP_ID) + .name(profileName).locationUri("hosted").build()); ClassPathResource resource = new ClassPathResource(resourcePath); String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - appConfigClient.createHostedConfigurationVersion( - CreateHostedConfigurationVersionRequest.builder() - .applicationId(APP_ID) - .configurationProfileId(profileResponse.id()) - .content(SdkBytes.fromUtf8String(content)) - .contentType(contentType) - .build()); - - appConfigClient.startDeployment( - StartDeploymentRequest.builder() - .applicationId(APP_ID) - .environmentId(ENV_ID) - .deploymentStrategyId(STRATEGY_ID) - .configurationProfileId(profileResponse.id()) - .configurationVersion("1") - .build()); + appConfigClient.createHostedConfigurationVersion(CreateHostedConfigurationVersionRequest.builder() + .applicationId(APP_ID).configurationProfileId(profileResponse.id()) + .content(SdkBytes.fromUtf8String(content)).contentType(contentType).build()); + + appConfigClient.startDeployment(StartDeploymentRequest.builder().applicationId(APP_ID).environmentId(ENV_ID) + .deploymentStrategyId(STRATEGY_ID).configurationProfileId(profileResponse.id()) + .configurationVersion("1").build()); return profileResponse.id(); } @@ -183,9 +150,9 @@ void whenKeysAreNotSpecifiedFailsWithHumanReadableFailureMessage(CapturedOutput SpringApplication application = createApplication(); assertThatThrownBy(() -> runApplication(application, "aws-appconfig:")) - .isInstanceOf(AppConfigKeysMissingException.class); + .isInstanceOf(AppConfigKeysMissingException.class); String errorMessage = "Description:%1$s%1$sCould not import properties from AWS App Config" - .formatted(NEW_LINE_CHAR); + .formatted(NEW_LINE_CHAR); assertThat(output.getOut()).contains(errorMessage); } @@ -194,9 +161,9 @@ void whenKeysCannotBeFoundFailWithHumanReadableMessage(CapturedOutput output) { SpringApplication application = createApplication(); assertThatThrownBy(() -> runApplication(application, "aws-appconfig:invalidApp#invalidProfile#invalidEnv")) - .isInstanceOf(AwsAppConfigPropertySourceNotFoundException.class); + .isInstanceOf(AwsAppConfigPropertySourceNotFoundException.class); String errorMessage = "Description:%1$s%1$sCould not import properties from App Config. Exception happened while trying to load the keys" - .formatted(NEW_LINE_CHAR); + .formatted(NEW_LINE_CHAR); assertThat(output.getOut()).contains(errorMessage); } @@ -205,8 +172,7 @@ void propertyIsNotResolvedWhenIntegrationIsDisabled() { SpringApplication application = createApplication(); try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES, - "spring.cloud.aws.endpoint", - "--spring.cloud.aws.appconfig.enabled=false")) { + "spring.cloud.aws.endpoint", "--spring.cloud.aws.appconfig.enabled=false")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isNull(); assertThat(context.getBeanProvider(AppConfigDataClient.class).getIfAvailable()).isNull(); } @@ -217,9 +183,8 @@ void customSeparatorIsRespected() { SpringApplication application = createApplication(); try (ConfigurableApplicationContext context = runApplication(application, - "aws-appconfig:" + APP_ID + "/" + PROFILE_ID_PROPERTIES + "/" + ENV_ID, - "spring.cloud.aws.endpoint", - "--spring.cloud.aws.appconfig.separator=/")) { + "aws-appconfig:" + APP_ID + "/" + PROFILE_ID_PROPERTIES + "/" + ENV_ID, "spring.cloud.aws.endpoint", + "--spring.cloud.aws.appconfig.separator=/")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); } } @@ -252,26 +217,25 @@ class ReloadConfigurationTests { @AfterEach void resetConfiguration() throws IOException { updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, - "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); + "io/awspring/cloud/autoconfigure/config/appconfig/test-config.properties"); } @Test void reloadsProperties() throws IOException { SpringApplication application = createApplication(); - try (ConfigurableApplicationContext context = runApplication(application, - IMPORT_PROPERTIES, - "spring.cloud.aws.appconfig.endpoint", - "--spring.cloud.aws.appconfig.reload.strategy=refresh", - "--spring.cloud.aws.appconfig.reload.period=PT1S")) { + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES, + "spring.cloud.aws.appconfig.endpoint", "--spring.cloud.aws.appconfig.reload.strategy=refresh", + "--spring.cloud.aws.appconfig.reload.period=PT1S")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, - "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); + "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("false"); - assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("updated"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")) + .isEqualTo("updated"); }); } } @@ -280,14 +244,12 @@ void reloadsProperties() throws IOException { void doesNotReloadPropertiesWhenMonitoringIsDisabled() throws IOException { SpringApplication application = createApplication(); - try (ConfigurableApplicationContext context = runApplication(application, - IMPORT_PROPERTIES, - "spring.cloud.aws.appconfig.endpoint", - "--spring.cloud.aws.appconfig.reload.period=PT1S")) { + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES, + "spring.cloud.aws.appconfig.endpoint", "--spring.cloud.aws.appconfig.reload.period=PT1S")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, - "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); + "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); await().during(Duration.ofSeconds(5)).untilAsserted(() -> { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); @@ -299,22 +261,22 @@ void doesNotReloadPropertiesWhenMonitoringIsDisabled() throws IOException { void reloadsPropertiesWithRestartContextStrategy() throws IOException { SpringApplication application = createApplication(); - try (ConfigurableApplicationContext context = runApplication(application, - IMPORT_PROPERTIES, - "spring.cloud.aws.appconfig.endpoint", - "--spring.cloud.aws.appconfig.reload.strategy=RESTART_CONTEXT", - "--spring.cloud.aws.appconfig.reload.period=PT1S", - "--spring.cloud.aws.appconfig.reload.max-wait-for-restart=PT1S", - "--management.endpoint.restart.enabled=true", - "--management.endpoints.web.exposure.include=restart")) { + try (ConfigurableApplicationContext context = runApplication(application, IMPORT_PROPERTIES, + "spring.cloud.aws.appconfig.endpoint", + "--spring.cloud.aws.appconfig.reload.strategy=RESTART_CONTEXT", + "--spring.cloud.aws.appconfig.reload.period=PT1S", + "--spring.cloud.aws.appconfig.reload.max-wait-for-restart=PT1S", + "--management.endpoint.restart.enabled=true", + "--management.endpoints.web.exposure.include=restart")) { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("true"); updateAppConfigConfiguration(PROFILE_ID_PROPERTIES, - "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); + "io/awspring/cloud/autoconfigure/config/appconfig/test-config-updated.properties"); await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> { assertThat(context.getEnvironment().getProperty("cloud.aws.sqs.enabled")).isEqualTo("false"); - assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")).isEqualTo("updated"); + assertThat(context.getEnvironment().getProperty("some.property.to.be.checked")) + .isEqualTo("updated"); }); } } @@ -323,23 +285,15 @@ private void updateAppConfigConfiguration(String profileId, String resourcePath) ClassPathResource resource = new ClassPathResource(resourcePath); String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - //Create new version - CreateHostedConfigurationVersionResponse versionResponse = appConfigClient.createHostedConfigurationVersion( - CreateHostedConfigurationVersionRequest.builder() - .applicationId(APP_ID) - .configurationProfileId(profileId) - .content(SdkBytes.fromUtf8String(content)) - .contentType("text/plain") - .build()); - - appConfigClient.startDeployment( - StartDeploymentRequest.builder() - .applicationId(APP_ID) - .environmentId(ENV_ID) - .deploymentStrategyId(STRATEGY_ID) - .configurationProfileId(profileId) - .configurationVersion(String.valueOf(versionResponse.versionNumber())) - .build()); + // Create new version + CreateHostedConfigurationVersionResponse versionResponse = appConfigClient + .createHostedConfigurationVersion(CreateHostedConfigurationVersionRequest.builder() + .applicationId(APP_ID).configurationProfileId(profileId) + .content(SdkBytes.fromUtf8String(content)).contentType("text/plain").build()); + + appConfigClient.startDeployment(StartDeploymentRequest.builder().applicationId(APP_ID).environmentId(ENV_ID) + .deploymentStrategyId(STRATEGY_ID).configurationProfileId(profileId) + .configurationVersion(String.valueOf(versionResponse.versionNumber())).build()); } } @@ -355,14 +309,12 @@ private ConfigurableApplicationContext runApplication(SpringApplication applicat private ConfigurableApplicationContext runApplication(SpringApplication application, String springConfigImport, String endpointProperty, String... extraArgs) { - List args = new ArrayList<>(List.of( - "--spring.config.import=" + springConfigImport, - "--spring.cloud.aws.appconfig.region=" + REGION, - "--" + endpointProperty + "=" + localstack.getEndpoint(), - "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), - "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), - "--spring.cloud.aws.region.static=" + REGION, - "--logging.level.io.awspring.cloud.appconfig=debug")); + List args = new ArrayList<>(List.of("--spring.config.import=" + springConfigImport, + "--spring.cloud.aws.appconfig.region=" + REGION, + "--" + endpointProperty + "=" + localstack.getEndpoint(), + "--spring.cloud.aws.credentials.access-key=" + localstack.getAccessKey(), + "--spring.cloud.aws.credentials.secret-key=" + localstack.getSecretKey(), + "--spring.cloud.aws.region.static=" + REGION, "--logging.level.io.awspring.cloud.appconfig=debug")); args.addAll(List.of(extraArgs)); return application.run(args.toArray(String[]::new)); } diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java index bfe13d2834..21e0150d09 100644 --- a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/config/appconfig/AppConfigReloadAutoConfigurationTests.java @@ -95,8 +95,7 @@ private void doesNotCreateBeans(AssertableApplicationContext ctx) { private void createsBeans(AssertableApplicationContext ctx) { assertThat(ctx).hasBean("appConfigConfigurationUpdateStrategy"); - assertThat(ctx.getBean("appConfigConfigurationUpdateStrategy")) - .isInstanceOf(ConfigurationUpdateStrategy.class); + assertThat(ctx.getBean("appConfigConfigurationUpdateStrategy")).isInstanceOf(ConfigurationUpdateStrategy.class); assertThat(ctx).hasBean("appConfigPollingAwsPropertySourceChangeDetector"); assertThat(ctx).getBean("appConfigPollingAwsPropertySourceChangeDetector") From b4301c63997f8a52235423f2125753fce30328d4 Mon Sep 17 00:00:00 2001 From: matejnedic Date: Tue, 10 Feb 2026 00:09:00 +0100 Subject: [PATCH 7/7] Update --- spring-cloud-aws-app-config/pom.xml | 2 +- spring-cloud-aws-dependencies/pom.xml | 7 +++++++ .../spring-cloud-aws-starter-app-config/pom.xml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/spring-cloud-aws-app-config/pom.xml b/spring-cloud-aws-app-config/pom.xml index 0dd959c0f1..e3d9388c79 100644 --- a/spring-cloud-aws-app-config/pom.xml +++ b/spring-cloud-aws-app-config/pom.xml @@ -5,7 +5,7 @@ io.awspring.cloud spring-cloud-aws - 4.0.0 + 4.0.1-SNAPSHOT 4.0.0 diff --git a/spring-cloud-aws-dependencies/pom.xml b/spring-cloud-aws-dependencies/pom.xml index 53f9b97bbc..828acb8d6e 100644 --- a/spring-cloud-aws-dependencies/pom.xml +++ b/spring-cloud-aws-dependencies/pom.xml @@ -144,6 +144,13 @@ spring-cloud-aws-starter-integration-kinesis ${project.version} + + + io.awspring.cloud + spring-cloud-aws-starter-app-config + ${project.version} + + io.awspring.cloud spring-cloud-aws-starter-integration-kinesis-producer diff --git a/spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml b/spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml index ac04add100..10b441261e 100644 --- a/spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml +++ b/spring-cloud-aws-starters/spring-cloud-aws-starter-app-config/pom.xml @@ -5,7 +5,7 @@ spring-cloud-aws io.awspring.cloud - 4.0.0 + 4.0.1-SNAPSHOT ../../pom.xml 4.0.0