diff --git a/docs/utilities/parameters.md b/docs/utilities/parameters.md index e2d0cb965..c546d96a2 100644 --- a/docs/utilities/parameters.md +++ b/docs/utilities/parameters.md @@ -4,9 +4,13 @@ description: Utility --- -The parameters utility provides a way to retrieve parameter values from -[AWS Systems Manager Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) or -[AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). It also provides a base class to create your parameter provider implementation. +The parameters utility provides a way to retrieve parameter values from: + + - [AWS Systems Manager Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) + - [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) + - [AWS AppConfig](https://aws.amazon.com/systems-manager/features/appconfig/) + +It also provides a base class to create your parameter provider implementation. **Key features** @@ -40,11 +44,12 @@ To install this utility, add the following dependency to your project. This utility requires additional permissions to work as expected. See the table below: -Provider | Function/Method | IAM Permission -------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------- -SSM Parameter Store | `SSMProvider.get(String)` `SSMProvider.get(String, Class)` | `ssm:GetParameter` -SSM Parameter Store | `SSMProvider.getMultiple(String)` | `ssm:GetParametersByPath` -Secrets Manager | `SecretsProvider.get(String)` `SecretsProvider.get(String, Class)` | `secretsmanager:GetSecretValue` +| Provider | Function/Method | IAM Permission | +|---------------------|------------------------------------------------------------------------|-----------------------------------------------------------------------------| +| SSM Parameter Store | `SSMProvider.get(String)` `SSMProvider.get(String, Class)` | `ssm:GetParameter` | +| SSM Parameter Store | `SSMProvider.getMultiple(String)` | `ssm:GetParametersByPath` | +| Secrets Manager | `SecretsProvider.get(String)` `SecretsProvider.get(String, Class)` | `secretsmanager:GetSecretValue` | +| AppConfig | `AppConfigProvider.get(String)` `AppConfigProvider.get(String, Class)` | `appconfig:GetLatestConfiguration` & `appconfig:StartConfigurationSession` | ## SSM Parameter Store @@ -99,10 +104,10 @@ in order to get data from other regions or use specific credentials. The AWS Systems Manager Parameter Store provider supports two additional arguments for the `get()` and `getMultiple()` methods: -| Option | Default | Description | -|---------------|---------|-------------| -| **withDecryption()** | `False` | Will automatically decrypt the parameter. | -| **recursive()** | `False` | For `getMultiple()` only, will fetch all parameter values recursively based on a path prefix. | +| Option | Default | Description | +|----------------------|---------|-----------------------------------------------------------------------------------------------| +| **withDecryption()** | `False` | Will automatically decrypt the parameter. | +| **recursive()** | `False` | For `getMultiple()` only, will fetch all parameter values recursively based on a path prefix. | **Example:** @@ -166,6 +171,46 @@ in order to get data from other regions or use specific credentials. } ``` +## AppConfig + +!!! info + See how to create configuration in AppConfig in the [documentation](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-working.html). + +=== "AppConfigProvider" + + ```java hl_lines="5 9-10" + import software.amazon.lambda.powertools.parameters.AppConfigProvider; + import software.amazon.lambda.powertools.parameters.ParamManager; + + public class AppWithConfig implements RequestHandler { + // Get an instance of the AppConfig Provider + AppConfigProvider appConfigProvider = ParamManager.getAppConfigProvider(); + + // Retrieve some configuration + // The key must be in form /application/environment/configuration and match your AppConfig setup + String value = appConfigProvider.get("/app/prod/config"); + + } + ``` + +=== "AppConfigProvider with a custom client" + + ```java hl_lines="5 8 11" + import software.amazon.lambda.powertools.parameters.AppConfigProvider; + import software.amazon.lambda.powertools.parameters.ParamManager; + + public class AppWithConfig implements RequestHandler { + AppConfigDataClient client = AppConfigDataClient.builder().region(Region.EU_CENTRAL_1).build(); + + // Get an instance of the AppConfig Provider + AppConfigProvider appConfigProvider = ParamManager.getAppConfigProvider(client); + + // Retrieve a single secret + String value = appConfigProvider.get("/app/prod/config"); + + } + ``` + ## Advanced configuration ### Caching diff --git a/powertools-parameters/pom.xml b/powertools-parameters/pom.xml index b255d8b13..4c21d7605 100644 --- a/powertools-parameters/pom.xml +++ b/powertools-parameters/pom.xml @@ -69,6 +69,20 @@ + + software.amazon.awssdk + appconfigdata + + + software.amazon.awssdk + apache-client + + + software.amazon.awssdk + netty-nio-client + + + software.amazon.awssdk url-connection-client @@ -118,6 +132,34 @@ aspectjweaver test + + org.junit-pioneer + junit-pioneer + 1.6.2 + test + - + + + jdk16 + + [16,) + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + --add-opens java.base/java.util=ALL-UNNAMED + --add-opens java.base/java.lang=ALL-UNNAMED + + + + + + + diff --git a/powertools-parameters/src/main/java/software/amazon/lambda/powertools/parameters/AppConfigProvider.java b/powertools-parameters/src/main/java/software/amazon/lambda/powertools/parameters/AppConfigProvider.java new file mode 100644 index 000000000..d2e743812 --- /dev/null +++ b/powertools-parameters/src/main/java/software/amazon/lambda/powertools/parameters/AppConfigProvider.java @@ -0,0 +1,171 @@ +package software.amazon.lambda.powertools.parameters; + +import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider; +import software.amazon.awssdk.core.SdkSystemSetting; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +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; +import software.amazon.lambda.powertools.parameters.cache.CacheManager; +import software.amazon.lambda.powertools.parameters.transform.TransformationManager; +import software.amazon.lambda.powertools.parameters.transform.Transformer; + +import java.time.temporal.ChronoUnit; +import java.util.Map; + +public class AppConfigProvider extends BaseProvider { + + private AppConfigDataClient client; + + AppConfigProvider(CacheManager cacheManager) { + this(cacheManager, defaultClient()); + } + + AppConfigProvider(CacheManager cacheManager, AppConfigDataClient client) { + super(cacheManager); + this.client = client; + } + + /** + * Create a builder that can be used to configure and create a {@link AppConfigProvider}. + * + * @return a new instance of {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + @Override + protected String getValue(String key) { + String[] profile = key.split("/"); + if (profile.length < 3) { + throw new IllegalArgumentException("Your key is incorrect, please specify an 'application', an 'environment' and the 'configuration' separated with '/', eg. '/myapp/prod/myvar'"); + } + int index = key.startsWith("/") ? 1 : 0; + String application = profile[index]; + String environment = profile[index + 1]; + String configuration = profile[index + 2]; + + return getValueWithClient(application, environment, configuration); + } + + private String getValueWithClient(String application, String environment, String configuration) { + StartConfigurationSessionResponse sessionResponse = client.startConfigurationSession(StartConfigurationSessionRequest.builder() + .applicationIdentifier(application) + .environmentIdentifier(environment) + .configurationProfileIdentifier(configuration) + .build()); + GetLatestConfigurationResponse configurationResponse = client.getLatestConfiguration(GetLatestConfigurationRequest.builder() + .configurationToken(sessionResponse.initialConfigurationToken()) + .build()); + return configurationResponse.configuration().asUtf8String(); + } + + /** + * @throws UnsupportedOperationException as it is not possible to get multiple values simultaneously from App Config + */ + @Override + protected Map getMultipleValues(String path) { + throw new UnsupportedOperationException("Impossible to get multiple values from AWS Secrets Manager"); + } + + /** + * {@inheritDoc} + */ + @Override + public AppConfigProvider defaultMaxAge(int maxAge, ChronoUnit unit) { + super.defaultMaxAge(maxAge, unit); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public AppConfigProvider withMaxAge(int maxAge, ChronoUnit unit) { + super.withMaxAge(maxAge, unit); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public AppConfigProvider withTransformation(Class transformerClass) { + super.withTransformation(transformerClass); + return this; + } + + private static AppConfigDataClient defaultClient() { + return AppConfigDataClient.builder() + .httpClientBuilder(UrlConnectionHttpClient.builder()) + .credentialsProvider(EnvironmentVariableCredentialsProvider.create()) + .region(Region.of(System.getenv(SdkSystemSetting.AWS_REGION.environmentVariable()))) + .build(); + } + + static class Builder { + + private AppConfigDataClient client; + private CacheManager cacheManager; + private TransformationManager transformationManager; + + /** + * Create a {@link AppConfigProvider} instance. + * + * @return a {@link AppConfigProvider} + */ + public AppConfigProvider build() { + if (cacheManager == null) { + throw new IllegalStateException("No CacheManager provided, please provide one"); + } + AppConfigProvider provider; + if (client != null) { + provider = new AppConfigProvider(cacheManager, client); + } else { + provider = new AppConfigProvider(cacheManager); + } + if (transformationManager != null) { + provider.setTransformationManager(transformationManager); + } + return provider; + } + + /** + * Set custom {@link AppConfigDataClient} to pass to the {@link AppConfigProvider}.
+ * Use it if you want to customize the region or any other part of the client. + * + * @param client Custom client + * @return the builder to chain calls (eg.
builder.withClient().build()
) + */ + public Builder withClient(AppConfigDataClient client) { + this.client = client; + return this; + } + + /** + * Mandatory. Provide a CacheManager to the {@link AppConfigProvider} + * + * @param cacheManager the manager that will handle the cache of parameters + * @return the builder to chain calls (eg.
builder.withCacheManager().build()
) + */ + public Builder withCacheManager(CacheManager cacheManager) { + this.cacheManager = cacheManager; + return this; + } + + /** + * Provide a transformationManager to the {@link AppConfigProvider} + * + * @param transformationManager the manager that will handle transformation of parameters + * @return the builder to chain calls (eg.
builder.withTransformationManager().build()
) + */ + public Builder withTransformationManager(TransformationManager transformationManager) { + this.transformationManager = transformationManager; + return this; + } + } +} diff --git a/powertools-parameters/src/main/java/software/amazon/lambda/powertools/parameters/ParamManager.java b/powertools-parameters/src/main/java/software/amazon/lambda/powertools/parameters/ParamManager.java index 0131ae179..86bf2f045 100644 --- a/powertools-parameters/src/main/java/software/amazon/lambda/powertools/parameters/ParamManager.java +++ b/powertools-parameters/src/main/java/software/amazon/lambda/powertools/parameters/ParamManager.java @@ -13,6 +13,7 @@ */ package software.amazon.lambda.powertools.parameters; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; import software.amazon.awssdk.services.ssm.SsmClient; import software.amazon.lambda.powertools.parameters.cache.CacheManager; @@ -36,7 +37,7 @@ public final class ParamManager { /** * Get a concrete implementation of {@link BaseProvider}.
- * You can specify {@link SecretsProvider} or {@link SSMProvider} or create your custom provider + * You can specify {@link SecretsProvider} or {@link SSMProvider} or {@link AppConfigProvider} or create your custom provider * by extending {@link BaseProvider} if you need to integrate with a different parameter store. * @return a {@link SecretsProvider} */ @@ -65,6 +66,15 @@ public static SSMProvider getSsmProvider() { return getProvider(SSMProvider.class); } + /** + * Get a {@link AppConfigProvider} with default {@link AppConfigDataClient}.
+ * If you need to customize the region, or other part of the client, use {@link ParamManager#getAppConfigProvider(AppConfigDataClient)} instead. + * @return a {@link AppConfigProvider} + */ + public static AppConfigProvider getAppConfigProvider() { + return getProvider(AppConfigProvider.class); + } + /** * Get a {@link SecretsProvider} with your custom {@link SecretsManagerClient}.
* Use this to configure region or other part of the client. Use {@link ParamManager#getSsmProvider()} if you don't need this customization. @@ -91,6 +101,19 @@ public static SSMProvider getSsmProvider(SsmClient client) { .build()); } + /** + * Get a {@link AppConfigProvider} with your custom {@link AppConfigDataClient}.
+ * Use this to configure region or other part of the client. Use {@link ParamManager#getAppConfigProvider()} if you don't need this customization. + * @return a {@link AppConfigProvider} + */ + public static AppConfigProvider getAppConfigProvider(AppConfigDataClient client) { + return (AppConfigProvider) providers.computeIfAbsent(AppConfigProvider.class, (k) -> AppConfigProvider.builder() + .withClient(client) + .withCacheManager(cacheManager) + .withTransformationManager(transformationManager) + .build()); + } + public static CacheManager getCacheManager() { return cacheManager; } diff --git a/powertools-parameters/src/test/java/software/amazon/lambda/powertools/parameters/AppConfigProviderTest.java b/powertools-parameters/src/test/java/software/amazon/lambda/powertools/parameters/AppConfigProviderTest.java new file mode 100644 index 000000000..e6d04ad3e --- /dev/null +++ b/powertools-parameters/src/test/java/software/amazon/lambda/powertools/parameters/AppConfigProviderTest.java @@ -0,0 +1,97 @@ +package software.amazon.lambda.powertools.parameters; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +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; +import software.amazon.lambda.powertools.parameters.cache.CacheManager; +import software.amazon.lambda.powertools.parameters.transform.TransformationManager; +import software.amazon.lambda.powertools.parameters.transform.Transformer; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.data.MapEntry.entry; +import static org.mockito.MockitoAnnotations.openMocks; + +public class AppConfigProviderTest { + + @Mock + AppConfigDataClient client; + + @Captor + ArgumentCaptor paramCaptor; + @Captor + ArgumentCaptor sessionCaptor; + + CacheManager cacheManager; + + AppConfigProvider provider; + + @BeforeEach + public void init() { + openMocks(this); + cacheManager = new CacheManager(); + provider = AppConfigProvider.builder().withCacheManager(cacheManager).withTransformationManager(new TransformationManager()).withClient(client).build(); + } + + @Test + public void getValue_withClient() { + String key = "/app/env/Key1"; + String expectedValue = "Value1"; + + StartConfigurationSessionResponse session = StartConfigurationSessionResponse.builder().initialConfigurationToken("fakeToken").build(); + Mockito.when(client.startConfigurationSession(sessionCaptor.capture())).thenReturn(session); + + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder().configuration(SdkBytes.fromString(expectedValue, StandardCharsets.UTF_8)).build(); + Mockito.when(client.getLatestConfiguration(paramCaptor.capture())).thenReturn(response); + + String value = provider.getValue(key); + + assertThat(value).isEqualTo(expectedValue); + assertThat(paramCaptor.getValue().configurationToken()).isEqualTo("fakeToken"); + assertThat(sessionCaptor.getValue().applicationIdentifier()).isEqualTo("app"); + assertThat(sessionCaptor.getValue().environmentIdentifier()).isEqualTo("env"); + assertThat(sessionCaptor.getValue().configurationProfileIdentifier()).isEqualTo("Key1"); + } + + @Test + public void getWithTransformer() { + String key = "/app/env/Key2"; + String expectedValue = "{\"foo\":\"Foo\", \"bar\":42, \"baz\":123456789}"; + + StartConfigurationSessionResponse session = StartConfigurationSessionResponse.builder().initialConfigurationToken("sessionToken").build(); + Mockito.when(client.startConfigurationSession(sessionCaptor.capture())).thenReturn(session); + + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder().configuration(SdkBytes.fromString(expectedValue, StandardCharsets.UTF_8)).build(); + Mockito.when(client.getLatestConfiguration(paramCaptor.capture())).thenReturn(response); + + Map map = provider.withTransformation(Transformer.json).get(key, Map.class); + assertThat(map).contains( + entry("foo", "Foo"), + entry("bar", 42), + entry("baz", 123456789)); + assertThat(paramCaptor.getValue().configurationToken()).isEqualTo("sessionToken"); + assertThat(sessionCaptor.getValue().applicationIdentifier()).isEqualTo("app"); + assertThat(sessionCaptor.getValue().environmentIdentifier()).isEqualTo("env"); + assertThat(sessionCaptor.getValue().configurationProfileIdentifier()).isEqualTo("Key2"); + } + + @Test + public void invalidKey() { + String key = "keyWithoutAppEnvConfig"; + assertThatThrownBy(() -> {provider.getValue(key); }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Your key is incorrect, please specify an 'application', an 'environment' and the 'configuration' separated with '/', eg. '/myapp/prod/myvar'"); + } +} diff --git a/powertools-parameters/src/test/java/software/amazon/lambda/powertools/parameters/ParamManagerIntegrationTest.java b/powertools-parameters/src/test/java/software/amazon/lambda/powertools/parameters/ParamManagerIntegrationTest.java index 0b4a2093f..9edf0484b 100644 --- a/powertools-parameters/src/test/java/software/amazon/lambda/powertools/parameters/ParamManagerIntegrationTest.java +++ b/powertools-parameters/src/test/java/software/amazon/lambda/powertools/parameters/ParamManagerIntegrationTest.java @@ -19,12 +19,20 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; +import org.mockito.Mockito; +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; import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest; import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse; import software.amazon.awssdk.services.ssm.SsmClient; import software.amazon.awssdk.services.ssm.model.*; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -53,6 +61,10 @@ public class ParamManagerIntegrationTest { @Captor ArgumentCaptor secretsCaptor; + @Mock + AppConfigDataClient appConfigDataClient; + + @BeforeEach public void setup() throws IllegalAccessException { @@ -116,4 +128,22 @@ public void secretsProvider_get() { assertThat(secretsProvider.get("keys")).isEqualTo(expectedValue); // second time is from cache verify(secretsManagerClient, times(1)).getSecretValue(any(GetSecretValueRequest.class)); } + + @Test + public void appConfigProvider_get() { + AppConfigProvider appConfigProvider = ParamManager.getAppConfigProvider(appConfigDataClient); + + StartConfigurationSessionResponse session = StartConfigurationSessionResponse.builder().initialConfigurationToken("fakeToken").build(); + Mockito.when(appConfigDataClient.startConfigurationSession(any(StartConfigurationSessionRequest.class))).thenReturn(session); + + String expectedValue = "Value1"; + GetLatestConfigurationResponse response = GetLatestConfigurationResponse.builder() + .configuration(SdkBytes.fromString(expectedValue, StandardCharsets.UTF_8)) + .build(); + when(appConfigDataClient.getLatestConfiguration(any(GetLatestConfigurationRequest.class))).thenReturn(response); + + assertThat(appConfigProvider.get("/app/env/key")).isEqualTo(expectedValue); + assertThat(appConfigProvider.get("/app/env/key")).isEqualTo(expectedValue); // second time is from cache + verify(appConfigDataClient, times(1)).getLatestConfiguration(any(GetLatestConfigurationRequest.class)); + } }