diff --git a/docs/utilities/parameters.md b/docs/utilities/parameters.md index 9041ac08e..7e70248d4 100644 --- a/docs/utilities/parameters.md +++ b/docs/utilities/parameters.md @@ -214,6 +214,49 @@ a `DynamoDbProvider` providing a client if you need to configure it yourself. } ``` +## AppConfig +To get parameters stored in AppConfig, use `getAppConfigProvider`, providing the application and environment +name to retrieve configuration from. As with the other providers, an overloaded method allows you to retrieve +an `AppConfigProvider` providing a client if you need to configure it yourself. + +=== "AppConfigProvider" + + ```java hl_lines="6 9" + import software.amazon.lambda.powertools.parameters.AppConfigProvider; + import software.amazon.lambda.powertools.parameters.ParamManager; + + public class AppWitAppConfigParameters implements RequestHandler { + // Get an instance of the AppConfigProvider + AppConfigProvider appConfigProvider = ParamManager.getAppConfigProvider("my-environment", "my-app"); + + // Retrieve a single parameter + String value = appConfigProvider.get("my-key"); + } + ``` + +=== "AppConfigProvider with a custom client" + + ```java hl_lines="9 10 11 12 15 18" + import software.amazon.lambda.powertools.parameters.AppConfigProvider; + import software.amazon.lambda.powertools.parameters.ParamManager; + import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; + import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; + import software.amazon.awssdk.regions.Region; + + public class AppWithDynamoDbParameters implements RequestHandler { + // Get an AppConfig Client with an explicit region + AppConfigDataClient appConfigDataClient = AppConfigDataClient.builder() + .httpClientBuilder(UrlConnectionHttpClient.builder()) + .region(Region.EU_CENTRAL_2) + .build(); + + // Get an instance of the DynamoDbProvider + AppConfigProvider appConfigProvider = ParamManager.getAppConfigProvider(appConfigDataClient, "my-environment", "my-app"); + + // Retrieve a single parameter + String value = appConfigProvider.get("my-key"); + } + ``` ## Advanced configuration diff --git a/powertools-e2e-tests/handlers/metrics/pom.xml b/powertools-e2e-tests/handlers/metrics/pom.xml index 723ad75c5..35db53899 100644 --- a/powertools-e2e-tests/handlers/metrics/pom.xml +++ b/powertools-e2e-tests/handlers/metrics/pom.xml @@ -10,7 +10,7 @@ e2e-test-handler-metrics jar - A Lambda function using powertools metrics + A Lambda function using Powertools for AWS Lambda (Java) Parameters diff --git a/powertools-e2e-tests/handlers/parameters/pom.xml b/powertools-e2e-tests/handlers/parameters/pom.xml new file mode 100644 index 000000000..baaedb324 --- /dev/null +++ b/powertools-e2e-tests/handlers/parameters/pom.xml @@ -0,0 +1,61 @@ + + 4.0.0 + + + software.amazon.lambda + e2e-test-handlers-parent + 1.0.0 + + + e2e-test-handler-parameters + jar + A Lambda function using powertools logging + + + + software.amazon.lambda + powertools-logging + + + software.amazon.lambda + powertools-parameters + + + com.amazonaws + aws-lambda-java-events + + + + + + + + org.codehaus.mojo + aspectj-maven-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + ${maven.compiler.target} + + + software.amazon.lambda + powertools-logging + + + + + + + compile + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + + diff --git a/powertools-e2e-tests/handlers/parameters/src/main/java/software/amazon/lambda/powertools/e2e/Function.java b/powertools-e2e-tests/handlers/parameters/src/main/java/software/amazon/lambda/powertools/e2e/Function.java new file mode 100644 index 000000000..f7f784f78 --- /dev/null +++ b/powertools-e2e-tests/handlers/parameters/src/main/java/software/amazon/lambda/powertools/e2e/Function.java @@ -0,0 +1,22 @@ +package software.amazon.lambda.powertools.e2e; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import software.amazon.lambda.powertools.logging.Logging; +import software.amazon.lambda.powertools.parameters.ParamManager; +import software.amazon.lambda.powertools.parameters.cache.CacheManager; +import software.amazon.lambda.powertools.parameters.AppConfigProvider; + +public class Function implements RequestHandler { + + private static final Logger LOG = LogManager.getLogger(Function.class); + + @Logging + public String handleRequest(Input input, Context context) { + AppConfigProvider provider = ParamManager.getAppConfigProvider(input.getEnvironment(), input.getApp()); + return provider.get(input.getKey()); + + } +} \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/parameters/src/main/java/software/amazon/lambda/powertools/e2e/Input.java b/powertools-e2e-tests/handlers/parameters/src/main/java/software/amazon/lambda/powertools/e2e/Input.java new file mode 100644 index 000000000..7ea22143f --- /dev/null +++ b/powertools-e2e-tests/handlers/parameters/src/main/java/software/amazon/lambda/powertools/e2e/Input.java @@ -0,0 +1,34 @@ +package software.amazon.lambda.powertools.e2e; + +import java.util.Map; + +public class Input { + + private String app; + private String environment; + private String key; + public void setApp(String app) { + this.app = app; + } + + public void setEnvironment(String environment) { + this.environment = environment; + } + + public void setKey(String key) { + this.key = key; + } + + public String getApp() { + return app; + } + + public String getEnvironment() { + return environment; + } + + public String getKey() { + return key; + } + +} diff --git a/powertools-e2e-tests/handlers/parameters/src/main/resources/log4j2.xml b/powertools-e2e-tests/handlers/parameters/src/main/resources/log4j2.xml new file mode 100644 index 000000000..8925f70b9 --- /dev/null +++ b/powertools-e2e-tests/handlers/parameters/src/main/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/powertools-e2e-tests/handlers/pom.xml b/powertools-e2e-tests/handlers/pom.xml index be53a9a5b..a804e48eb 100644 --- a/powertools-e2e-tests/handlers/pom.xml +++ b/powertools-e2e-tests/handlers/pom.xml @@ -27,6 +27,7 @@ tracing metrics idempotency + parameters @@ -51,6 +52,11 @@ powertools-idempotency ${lambda.powertools.version} + + software.amazon.lambda + powertools-parameters + ${lambda.powertools.version} + com.amazonaws aws-lambda-java-core diff --git a/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/ParametersE2ET.java b/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/ParametersE2ET.java new file mode 100644 index 000000000..5abbb98df --- /dev/null +++ b/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/ParametersE2ET.java @@ -0,0 +1,76 @@ +package software.amazon.lambda.powertools; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import software.amazon.lambda.powertools.testutils.AppConfig; +import software.amazon.lambda.powertools.testutils.Infrastructure; +import software.amazon.lambda.powertools.testutils.lambda.InvocationResult; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static software.amazon.lambda.powertools.testutils.lambda.LambdaInvoker.invokeFunction; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ParametersE2ET { + + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private Infrastructure infrastructure; + private String functionName; + private final AppConfig appConfig; + + public ParametersE2ET() { + Map params = new HashMap<>(); + params.put("key1", "value1"); + params.put("key2", "value2"); + appConfig = new AppConfig("e2eApp", "e2etest", params); + } + @BeforeAll + @Timeout(value = 5, unit = TimeUnit.MINUTES) + public void setup() { + infrastructure = Infrastructure.builder() + .testName(ParametersE2ET.class.getSimpleName()) + .pathToFunction("parameters") + .appConfig(appConfig) + .environmentVariables( + Stream.of(new String[][]{ + {"POWERTOOLS_LOG_LEVEL", "INFO"}, + {"POWERTOOLS_SERVICE_NAME", ParametersE2ET.class.getSimpleName()} + }) + .collect(Collectors.toMap(data -> data[0], data -> data[1]))) + .build(); + functionName = infrastructure.deploy(); + } + + @AfterAll + public void tearDown() { + if (infrastructure != null) + infrastructure.destroy(); + } + + @Test + public void test_getAppConfigValue() { + for (Map.EntryconfigKey: appConfig.getConfigurationValues().entrySet()) { + + // Arrange + String event1 = "{" + + "\"app\": \"" + appConfig.getApplication() + "\", " + + "\"environment\": \"" + appConfig.getEnvironment() + "\", " + + "\"key\": \"" + configKey.getKey() + "\"" + + "}"; + + // Act + InvocationResult invocationResult = invokeFunction(functionName, event1); + + // Assert + assertThat(invocationResult.getResult()).isEqualTo("\"" + configKey.getValue() + "\""); + } + } + +} diff --git a/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/testutils/AppConfig.java b/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/testutils/AppConfig.java new file mode 100644 index 000000000..c87f4ac48 --- /dev/null +++ b/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/testutils/AppConfig.java @@ -0,0 +1,34 @@ +package software.amazon.lambda.powertools.testutils; + +import java.util.HashMap; +import java.util.Map; + +/** + * Defines configuration used to setup an AppConfig + * deployment when the infrastructure is rolled out. + * + * All fields are non-nullable. + */ +public class AppConfig { + private String application; + private String environment; + private Map configurationValues; + + public AppConfig(String application, String environment, Map configurationValues) { + this.application = application; + this.environment = environment; + this.configurationValues = configurationValues; + } + + public String getApplication() { + return application; + } + + public String getEnvironment() { + return environment; + } + + public Map getConfigurationValues() { + return configurationValues; + } +} diff --git a/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/testutils/Infrastructure.java b/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/testutils/Infrastructure.java index 1fdbd3836..59035af7c 100644 --- a/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/testutils/Infrastructure.java +++ b/powertools-e2e-tests/src/test/java/software/amazon/lambda/powertools/testutils/Infrastructure.java @@ -7,12 +7,17 @@ import software.amazon.awscdk.Stack; import software.amazon.awscdk.*; import software.amazon.awscdk.cxapi.CloudAssembly; +import software.amazon.awscdk.services.appconfig.*; import software.amazon.awscdk.services.dynamodb.Attribute; import software.amazon.awscdk.services.dynamodb.AttributeType; import software.amazon.awscdk.services.dynamodb.BillingMode; import software.amazon.awscdk.services.dynamodb.Table; +import software.amazon.awscdk.services.groundstation.CfnConfig; +import software.amazon.awscdk.services.iam.PolicyStatement; +import software.amazon.awscdk.services.iam.ServicePrincipal; import software.amazon.awscdk.services.lambda.Code; import software.amazon.awscdk.services.lambda.Function; +import software.amazon.awscdk.services.lambda.Permission; import software.amazon.awscdk.services.lambda.Tracing; import software.amazon.awscdk.services.logs.LogGroup; import software.amazon.awscdk.services.logs.RetentionDays; @@ -65,6 +70,9 @@ public class Infrastructure { private final Region region; private final String account; private final String idempotencyTable; + private final AppConfig appConfig; + + private String functionName; private Object cfnTemplate; private String cfnAssetDirectory; @@ -78,6 +86,7 @@ private Infrastructure(Builder builder) { this.timeout = builder.timeoutInSeconds; this.pathToFunction = builder.pathToFunction; this.idempotencyTable = builder.idemPotencyTable; + this.appConfig = builder.appConfig; this.app = new App(); this.stack = createStackWithLambda(); @@ -140,6 +149,7 @@ public static class Builder { public long timeoutInSeconds = 30; public String pathToFunction; public String testName; + public AppConfig appConfig; private String stackName; private boolean tracing = false; private JavaRuntime runtime; @@ -200,6 +210,11 @@ public Builder idempotencyTable(String tableName) { return this; } + public Builder appConfig(AppConfig app) { + this.appConfig = app; + return this; + } + public Builder environmentVariables(Map environmentVariables) { this.environmentVariables = environmentVariables; return this; @@ -277,9 +292,73 @@ private Stack createStackWithLambda() { .tableName(idempotencyTable) .timeToLiveAttribute("expiration") .build(); + table.grantReadWriteData(function); } + if (appConfig != null) { + CfnApplication app = CfnApplication.Builder + .create(stack, "AppConfigApp") + .name(appConfig.getApplication()) + .build(); + + CfnEnvironment environment = CfnEnvironment.Builder + .create(stack, "AppConfigEnvironment") + .applicationId(app.getRef()) + .name(appConfig.getEnvironment()) + .build(); + + // Create a fast deployment strategy so we don't have to wait ages + CfnDeploymentStrategy fastDeployment = CfnDeploymentStrategy.Builder + .create(stack, "AppConfigDeployment") + .name("FastDeploymentStrategy") + .deploymentDurationInMinutes(0) + .finalBakeTimeInMinutes(0) + .growthFactor(100) + .replicateTo("NONE") + .build(); + + // Get the lambda permission to use AppConfig + function.addToRolePolicy(PolicyStatement.Builder + .create() + .actions(singletonList("appconfig:*")) + .resources(singletonList("*")) + .build() + ); + + CfnDeployment previousDeployment = null; + for (Map.Entry entry : appConfig.getConfigurationValues().entrySet()) { + CfnConfigurationProfile configProfile = CfnConfigurationProfile.Builder + .create(stack, "AppConfigProfileFor" + entry.getKey()) + .applicationId(app.getRef()) + .locationUri("hosted") + .name(entry.getKey()) + .build(); + + CfnHostedConfigurationVersion configVersion = CfnHostedConfigurationVersion.Builder + .create(stack, "AppConfigHostedVersionFor" + entry.getKey()) + .applicationId(app.getRef()) + .contentType("text/plain") + .configurationProfileId(configProfile.getRef()) + .content(entry.getValue()) + .build(); + + CfnDeployment deployment = CfnDeployment.Builder + .create(stack, "AppConfigDepoymentFor" + entry.getKey()) + .applicationId(app.getRef()) + .environmentId(environment.getRef()) + .deploymentStrategyId(fastDeployment.getRef()) + .configurationProfileId(configProfile.getRef()) + .configurationVersion(configVersion.getRef()) + .build(); + + // We need to chain the deployments to keep CFN happy + if (previousDeployment != null) { + deployment.addDependsOn(previousDeployment); + } + previousDeployment = deployment; + } + } return stack; } diff --git a/powertools-parameters/pom.xml b/powertools-parameters/pom.xml index 876aeddb1..d1fb10183 100644 --- a/powertools-parameters/pom.xml +++ b/powertools-parameters/pom.xml @@ -81,6 +81,11 @@ software.amazon.awssdk dynamodb + + software.amazon.awssdk + appconfigdata + + com.fasterxml.jackson.core jackson-databind 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..90f30cb0e --- /dev/null +++ b/powertools-parameters/src/main/java/software/amazon/lambda/powertools/parameters/AppConfigProvider.java @@ -0,0 +1,228 @@ +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.AppConfigDataClientBuilder; +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.ssm.SsmClient; +import software.amazon.awssdk.services.ssm.SsmClientBuilder; +import software.amazon.lambda.powertools.core.internal.LambdaConstants; +import software.amazon.lambda.powertools.parameters.cache.CacheManager; +import software.amazon.lambda.powertools.parameters.transform.TransformationManager; + +import java.util.HashMap; +import java.util.Map; + +import static software.amazon.lambda.powertools.core.internal.LambdaConstants.AWS_LAMBDA_INITIALIZATION_TYPE; + +/** + * Implements a {@link ParamProvider} on top of the AppConfig service. AppConfig provides + * a mechanism to retrieve and update configuration of applications over time. + * AppConfig requires the user to create an application, environment, and configuration profile. + * The configuration profile's value can then be retrieved, by key name, through this provider. + * + * Because AppConfig is designed to handle rollouts of configuration over time, we must first + * establish a session for each key we wish to retrieve, and then poll the session for the latest + * value when the user re-requests it. This means we must hold a keyed set of session tokens + * and values. + * + * @see Parameters provider documentation + * @see AppConfig documentation + */ +public class AppConfigProvider extends BaseProvider{ + + private static class EstablishedSession { + private final String nextSessionToken; + private final String lastConfigurationValue; + + private EstablishedSession(String nextSessionToken, String value) { + this.nextSessionToken = nextSessionToken; + this.lastConfigurationValue = value; + } + } + + private final AppConfigDataClient client; + + private final String application; + + private final String environment; + + private final HashMap establishedSessions = new HashMap<>(); + + AppConfigProvider(CacheManager cacheManager, AppConfigDataClient client, String environment, String application) { + super(cacheManager); + this.client = client; + this.application = application; + this.environment = environment; + } + + + /** + * Retrieve the parameter value from the AppConfig parameter store.
+ * + * @param key key of the parameter. This ties back to AppConfig's 'profile' concept + * @return the value of the parameter identified by the key + */ + @Override + protected String getValue(String key) { + // Start a configuration session if we don't already have one for the key requested + // so that we can the initial token. If we already have a session, we can take + // the next request token from there. + EstablishedSession establishedSession = establishedSessions.getOrDefault(key, null); + String sessionToken = establishedSession != null? + establishedSession.nextSessionToken : + client.startConfigurationSession(StartConfigurationSessionRequest.builder() + .applicationIdentifier(this.application) + .environmentIdentifier(this.environment) + .configurationProfileIdentifier(key) + .build()) + .initialConfigurationToken(); + + // Get the configuration using the token + GetLatestConfigurationResponse response = client.getLatestConfiguration(GetLatestConfigurationRequest.builder() + .configurationToken(sessionToken) + .build()); + + // Get the next session token we'll use next time we are asked for this key + String nextSessionToken = response.nextPollConfigurationToken(); + + // Get the value of the key. Note that AppConfig will return null if the value + // has not changed since we last asked for it in this session - in this case + // we return the value we stashed at last request. + String value = response.configuration() != null? + response.configuration().asUtf8String() : // if we have a new value, use it + establishedSession != null? + establishedSession.lastConfigurationValue : // if we don't but we have a previous value, use that + null; // otherwise we've got no value + + // Update the cache so we can get the next value later + establishedSessions.put(key, new EstablishedSession(nextSessionToken, value)); + + return value; + } + + @Override + protected Map getMultipleValues(String path) { + // Retrieving multiple values is not supported with the AppConfig provider. + throw new RuntimeException("Retrieving multiple parameter values is not supported with the AWS App Config Provider"); + } + + /** + * Create a builder that can be used to configure and create a {@link AppConfigProvider}. + * + * @return a new instance of {@link AppConfigProvider.Builder} + */ + public static AppConfigProvider.Builder builder() { + return new AppConfigProvider.Builder(); + } + + static class Builder { + private AppConfigDataClient client; + private CacheManager cacheManager; + private TransformationManager transformationManager; + private String environment; + private String application; + + /** + * Create a {@link AppConfigProvider} instance. + * + * @return a {@link AppConfigProvider} + */ + public AppConfigProvider build() { + if (cacheManager == null) { + throw new IllegalStateException("No CacheManager provided; please provide one"); + } + if (environment == null) { + throw new IllegalStateException("No environment provided; please provide one"); + } + if (application == null) { + throw new IllegalStateException("No application provided; please provide one"); + } + + // Create a AppConfigDataClient if we haven't been given one + if (client == null) { + AppConfigDataClientBuilder appConfigDataClientBuilder = AppConfigDataClient.builder() + .httpClientBuilder(UrlConnectionHttpClient.builder()) + .region(Region.of(System.getenv(SdkSystemSetting.AWS_REGION.environmentVariable()))); + + // AWS_LAMBDA_INITIALIZATION_TYPE has two values on-demand and snap-start + // when using snap-start mode, the env var creds provider isn't used and causes a fatal error if set + // fall back to the default provider chain if the mode is anything other than on-demand. + String initializationType = System.getenv().get(AWS_LAMBDA_INITIALIZATION_TYPE); + if (initializationType != null && initializationType.equals(LambdaConstants.ON_DEMAND)) { + appConfigDataClientBuilder.credentialsProvider(EnvironmentVariableCredentialsProvider.create()); + } + + client = appConfigDataClientBuilder.build(); + } + + AppConfigProvider provider = new AppConfigProvider(cacheManager, client, environment, application); + + if (transformationManager != null) { + provider.setTransformationManager(transformationManager); + } + return provider; + } + + /** + * Set custom {@link AppConfigProvider} to pass to the {@link AppConfigDataClient}.
+ * 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 AppConfigProvider.Builder withClient(AppConfigDataClient client) { + this.client = client; + return this; + } + + /** + * Mandatory. Provide an environment to the {@link AppConfigProvider} + * + * @param environment the AppConfig environment + * @return the builder to chain calls (eg.
builder.withCacheManager().build()
) + */ + public AppConfigProvider.Builder withEnvironment(String environment) { + this.environment = environment; + return this; + } + + /** + * Mandatory. Provide an application to the {@link AppConfigProvider} + * + * @param application the application to pull configuration from + * @return the builder to chain calls (eg.
builder.withCacheManager().build()
) + */ + public AppConfigProvider.Builder withApplication(String application) { + this.application = application; + 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 AppConfigProvider.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 AppConfigProvider.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 3c4b2746a..96cbabd0e 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.awssdk.services.dynamodb.DynamoDbClient; @@ -81,6 +82,24 @@ public static DynamoDbProvider getDynamoDbProvider(String tableName) { .build(); } + /** + * 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, String, String)} instead. + * @return a {@link AppConfigProvider} + */ + public static AppConfigProvider getAppConfigProvider(String environment, String application) { + // Because we need a DDB table name to configure our client, we can't use + // ParamManager#getProvider. This means that we need to make sure we do the same stuff - + // set transformation manager and cache manager. + return AppConfigProvider.builder() + .withCacheManager(cacheManager) + .withTransformationManager(transformationManager) + .withEnvironment(environment) + .withApplication(application) + .build(); + } + + /** * 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. @@ -120,6 +139,22 @@ public static DynamoDbProvider getDynamoDbProvider(DynamoDbClient client, String .withTransformationManager(transformationManager) .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(String, String)} if you don't need this customization. + * @return a {@link AppConfigProvider} + */ + public static AppConfigProvider getAppConfigProvider(AppConfigDataClient client, String environment, String application) { + return (AppConfigProvider) providers.computeIfAbsent(AppConfigProvider.class, (k) -> AppConfigProvider.builder() + .withClient(client) + .withCacheManager(cacheManager) + .withTransformationManager(transformationManager) + .withEnvironment(environment) + .withApplication(application) + .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..d72a1f042 --- /dev/null +++ b/powertools-parameters/src/test/java/software/amazon/lambda/powertools/parameters/AppConfigProviderTest.java @@ -0,0 +1,148 @@ +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.http.urlconnection.UrlConnectionHttpClient; +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 static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.MockitoAnnotations.openMocks; + +public class AppConfigProviderTest { + + @Mock + AppConfigDataClient client; + + private AppConfigProvider provider; + + @Captor + ArgumentCaptor startSessionRequestCaptor; + + @Captor + ArgumentCaptor getLatestConfigurationRequestCaptor; + private final String environmentName = "test"; + + private final String applicationName = "fakeApp"; + + private final String defaultTestKey = "key1"; + + @BeforeEach + public void init() { + openMocks(this); + provider = AppConfigProvider.builder() + .withClient(client) + .withApplication(applicationName) + .withEnvironment(environmentName) + .withCacheManager(new CacheManager()) + .withTransformationManager(new TransformationManager()) + .build(); + } + + + /** + * Tests repeated calls to the AppConfigProvider for the same key behave correctly. This is more complicated than + * it seems, as the service itself will return no-data if the value of a property remains unchanged since the + * start of a session. This means the provider must cache the result and return it again if it gets no data, but + * subsequent calls should once again return the new data. + */ + @Test + public void getValueRetrievesValue() { + // Arrange + StartConfigurationSessionResponse firstSession = StartConfigurationSessionResponse.builder() + .initialConfigurationToken("token1") + .build(); + // first response returns 'value1' + GetLatestConfigurationResponse firstResponse = GetLatestConfigurationResponse.builder() + .nextPollConfigurationToken("token2") + .configuration(SdkBytes.fromUtf8String("value1")) + .build(); + // Second response returns 'value2' + GetLatestConfigurationResponse secondResponse = GetLatestConfigurationResponse.builder() + .nextPollConfigurationToken("token3") + .configuration(SdkBytes.fromUtf8String("value2")) + .build(); + // Third response returns nothing, which means the provider should yield the previous value again + GetLatestConfigurationResponse thirdResponse = GetLatestConfigurationResponse.builder() + .nextPollConfigurationToken("token4") + .build(); + Mockito.when(client.startConfigurationSession(startSessionRequestCaptor.capture())) + .thenReturn(firstSession); + Mockito.when(client.getLatestConfiguration(getLatestConfigurationRequestCaptor.capture())) + .thenReturn(firstResponse, secondResponse, thirdResponse); + + // Act + String returnedValue1 = provider.getValue(defaultTestKey); + String returnedValue2 = provider.getValue(defaultTestKey); + String returnedValue3 = provider.getValue(defaultTestKey); + + // Assert + assertThat(returnedValue1).isEqualTo(firstResponse.configuration().asUtf8String()); + assertThat(returnedValue2).isEqualTo(secondResponse.configuration().asUtf8String()); + assertThat(returnedValue3).isEqualTo(secondResponse.configuration().asUtf8String()); // Third response is mocked to return null and should re-use previous value + assertThat(startSessionRequestCaptor.getValue().applicationIdentifier()).isEqualTo(applicationName); + assertThat(startSessionRequestCaptor.getValue().environmentIdentifier()).isEqualTo(environmentName); + assertThat(startSessionRequestCaptor.getValue().configurationProfileIdentifier()).isEqualTo(defaultTestKey); + assertThat(getLatestConfigurationRequestCaptor.getAllValues().get(0).configurationToken()).isEqualTo(firstSession.initialConfigurationToken()); + assertThat(getLatestConfigurationRequestCaptor.getAllValues().get(1).configurationToken()).isEqualTo(firstResponse.nextPollConfigurationToken()); + assertThat(getLatestConfigurationRequestCaptor.getAllValues().get(2).configurationToken()).isEqualTo(secondResponse.nextPollConfigurationToken()); + } + + /** + * If we mix requests for different keys together through the same provider, retrieval should + * work as expected. This means two separate configuration sessions should be established with AppConfig. + */ + @Test + public void multipleKeysRetrievalWorks() { + // Arrange + String param1Key = "key1"; + StartConfigurationSessionResponse param1Session = StartConfigurationSessionResponse.builder() + .initialConfigurationToken("token1a") + .build(); + GetLatestConfigurationResponse param1Response = GetLatestConfigurationResponse.builder() + .nextPollConfigurationToken("token1b") + .configuration(SdkBytes.fromUtf8String("value1")) + .build(); + String param2Key = "key2"; + StartConfigurationSessionResponse param2Session = StartConfigurationSessionResponse.builder() + .initialConfigurationToken("token2a") + .build(); + GetLatestConfigurationResponse param2Response = GetLatestConfigurationResponse.builder() + .nextPollConfigurationToken("token2b") + .configuration(SdkBytes.fromUtf8String("value1")) + .build(); + Mockito.when(client.startConfigurationSession(startSessionRequestCaptor.capture())) + .thenReturn(param1Session, param2Session); + Mockito.when(client.getLatestConfiguration(getLatestConfigurationRequestCaptor.capture())) + .thenReturn(param1Response, param2Response); + + // Act + String firstKeyValue = provider.getValue(param1Key); + String secondKeyValue = provider.getValue(param2Key); + + // Assert + assertThat(firstKeyValue).isEqualTo(param1Response.configuration().asUtf8String()); + assertThat(secondKeyValue).isEqualTo(param2Response.configuration().asUtf8String()); + assertThat(startSessionRequestCaptor.getAllValues().get(0).configurationProfileIdentifier()).isEqualTo(param1Key); + assertThat(startSessionRequestCaptor.getAllValues().get(1).configurationProfileIdentifier()).isEqualTo(param2Key); + assertThat(getLatestConfigurationRequestCaptor.getAllValues().get(0).configurationToken()).isEqualTo(param1Session.initialConfigurationToken()); + assertThat(getLatestConfigurationRequestCaptor.getAllValues().get(1).configurationToken()).isEqualTo(param2Session.initialConfigurationToken()); + + } + +} 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 ec1672ead..fca0a9362 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,6 +19,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; +import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest; @@ -45,6 +46,9 @@ public class ParamManagerIntegrationTest { @Mock DynamoDbClient ddbClient; + @Mock + private AppConfigDataClient appConfigDataClient; + @Captor ArgumentCaptor ssmParamCaptor; @@ -57,7 +61,6 @@ public class ParamManagerIntegrationTest { @Captor ArgumentCaptor secretsCaptor; - @BeforeEach public void setup() throws IllegalAccessException { openMocks(this); @@ -129,7 +132,16 @@ public void getDynamoDbProvider() { // Assert assertThat(provider).isNotNull(); + } + + @Test + public void getAppConfigProvider() { + + // Act + AppConfigProvider provider = ParamManager.getAppConfigProvider(appConfigDataClient, "test-env", "test-app"); + // Assert + assertThat(provider).isNotNull(); } }