software.amazon.lambda
powertools-logging
diff --git a/powertools-idempotency/README.md b/powertools-idempotency/README.md
new file mode 100644
index 000000000..99b9c7ac8
--- /dev/null
+++ b/powertools-idempotency/README.md
@@ -0,0 +1,14 @@
+## Idempotency
+Refer to the [documentation](https://awslabs.github.io/aws-lambda-powertools-java/utilities/idempotency/) for details on how to use this module in your Lambda function.
+
+### Contributing
+This module provides a persistence layer with a built-in store using DynamoDB.
+To unit test it, we use [DynamoDB Local](https://docs.aws.amazon.com/fr_fr/amazondynamodb/latest/developerguide/DynamoDBLocal.html) which depends on sqlite.
+You may encounter the following issue on Apple M1 chips:
+```
+com.almworks.sqlite4java.SQLiteException: [-91] cannot load library: java.lang.UnsatisfiedLinkError: native-libs/libsqlite4java-osx-1.0.392.dylib: dlopen(native-libs/libsqlite4java-osx-1.0.392.dylib, 1): no suitable image found. Did find:
+native-libs/libsqlite4java-osx-1.0.392.dylib: no matching architecture in universal wrapper
+```
+
+In such case, try with another JDK. See [stackoverflow](https://stackoverflow.com/questions/66635424/dynamodb-local-setup-on-m1-apple-silicon-mac) and this [issue](https://github.com/aws-samples/aws-dynamodb-examples/issues/22) for more info.
+We'll update the dependencies as soon as it will be solved.
\ No newline at end of file
diff --git a/powertools-idempotency/pom.xml b/powertools-idempotency/pom.xml
new file mode 100644
index 000000000..785447d56
--- /dev/null
+++ b/powertools-idempotency/pom.xml
@@ -0,0 +1,214 @@
+
+
+ 4.0.0
+
+
+ software.amazon.lambda
+ powertools-parent
+ 1.10.3
+
+
+ powertools-idempotency
+ jar
+
+ AWS Lambda Powertools Java library Idempotency
+
+
+
+ https://aws.amazon.com/lambda/
+
+ GitHub Issues
+ https://github.com/awslabs/aws-lambda-powertools-java/issues
+
+
+ https://github.com/awslabs/aws-lambda-powertools-java.git
+
+
+
+ AWS Lambda Powertools team
+ Amazon Web Services
+ https://aws.amazon.com/
+
+
+
+
+
+ ossrh
+ https://aws.oss.sonatype.org/content/repositories/snapshots
+
+
+
+
+
+ software.amazon.lambda
+ powertools-core
+
+
+ software.amazon.lambda
+ powertools-serialization
+
+
+ com.amazonaws
+ aws-lambda-java-core
+
+
+ software.amazon.awssdk
+ dynamodb
+
+
+ software.amazon.awssdk
+ netty-nio-client
+
+
+ software.amazon.awssdk
+ apache-client
+
+
+
+
+ software.amazon.awssdk
+ url-connection-client
+ ${aws.sdk.version}
+
+
+ org.aspectj
+ aspectjrt
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+ org.junit-pioneer
+ junit-pioneer
+ 1.5.0
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.mockito
+ mockito-inline
+ test
+
+
+ org.apache.commons
+ commons-lang3
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+ com.amazonaws
+ aws-lambda-java-events
+ test
+
+
+ com.amazonaws
+ aws-lambda-java-tests
+
+
+ com.amazonaws
+ DynamoDBLocal
+ [1.12,2.0)
+ test
+
+
+
+ io.github.ganadist.sqlite4java
+ libsqlite4java-osx-aarch64
+ 1.0.392
+ test
+ dylib
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ ${project.build.directory}/native-libs
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+ copy
+ test-compile
+
+ copy-dependencies
+
+
+ test
+ so,dll,dylib
+ ${project.build.directory}/native-libs
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.2.0
+
+
+
+ software.amazon.awssdk.enhanced.dynamodb
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+ dynamodb-local-oregon
+ DynamoDB Local Release Repository
+ https://s3.eu-central-1.amazonaws.com/dynamodb-local-frankfurt/release
+
+
+
\ No newline at end of file
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/Constants.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/Constants.java
new file mode 100644
index 000000000..d8f7a9a13
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/Constants.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency;
+
+public class Constants {
+ public static final String LAMBDA_FUNCTION_NAME_ENV = "AWS_LAMBDA_FUNCTION_NAME";
+ public static final String AWS_REGION_ENV = "AWS_REGION";
+ public static final String IDEMPOTENCY_DISABLED_ENV = "POWERTOOLS_IDEMPOTENCY_DISABLED";
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/Idempotency.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/Idempotency.java
new file mode 100644
index 000000000..1ff2ed47f
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/Idempotency.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency;
+
+import com.amazonaws.services.lambda.runtime.Context;
+import software.amazon.lambda.powertools.idempotency.persistence.BasePersistenceStore;
+
+/**
+ * Holds the configuration for idempotency:
+ *
+ * - The persistence layer to use for persisting the request and response of the function (mandatory).
+ * - The general configuration for idempotency (optional, see {@link IdempotencyConfig.Builder} methods to see defaults values.
+ *
+ *
+ * Use it before the function handler ({@link com.amazonaws.services.lambda.runtime.RequestHandler#handleRequest(Object, Context)})
+ * get called.
+ *
+ * Example:
+ *
+ * Idempotency.config().withPersistenceStore(...).configure();
+ *
+ */
+public class Idempotency {
+ private IdempotencyConfig config;
+ private BasePersistenceStore persistenceStore;
+
+ private Idempotency() {
+ }
+
+ public IdempotencyConfig getConfig() {
+ return config;
+ }
+
+ public BasePersistenceStore getPersistenceStore() {
+ if (persistenceStore == null) {
+ throw new IllegalStateException("Persistence Store is null, did you call 'configure()'?");
+ }
+ return persistenceStore;
+ }
+
+ private void setConfig(IdempotencyConfig config) {
+ this.config = config;
+ }
+
+ private void setPersistenceStore(BasePersistenceStore persistenceStore) {
+ this.persistenceStore = persistenceStore;
+ }
+
+ private static class Holder {
+ private final static Idempotency instance = new Idempotency();
+ }
+
+ public static Idempotency getInstance() {
+ return Holder.instance;
+ }
+
+ /**
+ * Acts like a builder that can be used to configure {@link Idempotency}
+ *
+ * @return a new instance of {@link Config}
+ */
+ public static Config config() {
+ return new Config();
+ }
+
+ public static class Config {
+
+ private IdempotencyConfig config;
+ private BasePersistenceStore store;
+
+ /**
+ * Use this method after configuring persistence layer (mandatory) and idem potency configuration (optional)
+ */
+ public void configure() {
+ if (store == null) {
+ throw new IllegalStateException("Persistence Layer is null, configure one with 'withPersistenceStore()'");
+ }
+ if (config == null) {
+ config = IdempotencyConfig.builder().build();
+ }
+ Idempotency.getInstance().setConfig(config);
+ Idempotency.getInstance().setPersistenceStore(store);
+ }
+
+ public Config withPersistenceStore(BasePersistenceStore persistenceStore) {
+ this.store = persistenceStore;
+ return this;
+ }
+
+ public Config withConfig(IdempotencyConfig config) {
+ this.config = config;
+ return this;
+ }
+ }
+
+
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java
new file mode 100644
index 000000000..4089d3ed8
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency;
+
+import software.amazon.lambda.powertools.idempotency.internal.cache.LRUCache;
+
+import java.time.Duration;
+
+/**
+ * Configuration of the idempotency feature. Use the {@link Builder} to create an instance.
+ */
+public class IdempotencyConfig {
+ private final int localCacheMaxItems;
+ private final boolean useLocalCache;
+ private final long expirationInSeconds;
+ private final String eventKeyJMESPath;
+ private final String payloadValidationJMESPath;
+ private final boolean throwOnNoIdempotencyKey;
+ private final String hashFunction;
+
+ private IdempotencyConfig(String eventKeyJMESPath, String payloadValidationJMESPath, boolean throwOnNoIdempotencyKey, boolean useLocalCache, int localCacheMaxItems, long expirationInSeconds, String hashFunction) {
+ this.localCacheMaxItems = localCacheMaxItems;
+ this.useLocalCache = useLocalCache;
+ this.expirationInSeconds = expirationInSeconds;
+ this.eventKeyJMESPath = eventKeyJMESPath;
+ this.payloadValidationJMESPath = payloadValidationJMESPath;
+ this.throwOnNoIdempotencyKey = throwOnNoIdempotencyKey;
+ this.hashFunction = hashFunction;
+ }
+
+ public int getLocalCacheMaxItems() {
+ return localCacheMaxItems;
+ }
+
+ public boolean useLocalCache() {
+ return useLocalCache;
+ }
+
+ public long getExpirationInSeconds() {
+ return expirationInSeconds;
+ }
+
+ public String getEventKeyJMESPath() {
+ return eventKeyJMESPath;
+ }
+
+ public String getPayloadValidationJMESPath() {
+ return payloadValidationJMESPath;
+ }
+
+ public boolean throwOnNoIdempotencyKey() {
+ return throwOnNoIdempotencyKey;
+ }
+
+ public String getHashFunction() {
+ return hashFunction;
+ }
+
+
+ /**
+ * Create a builder that can be used to configure and create a {@link IdempotencyConfig}.
+ *
+ * @return a new instance of {@link IdempotencyConfig.Builder}
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+
+ private int localCacheMaxItems = 256;
+ private boolean useLocalCache = false;
+ private long expirationInSeconds = 60 * 60; // 1 hour
+ private String eventKeyJMESPath;
+ private String payloadValidationJMESPath;
+ private boolean throwOnNoIdempotencyKey = false;
+ private String hashFunction = "MD5";
+
+ /**
+ * Initialize and return an instance of {@link IdempotencyConfig}.
+ * Example:
+ *
+ * IdempotencyConfig.builder().withUseLocalCache().build();
+ *
+ * This instance must then be passed to the {@link Idempotency.Config}:
+ *
+ * Idempotency.config().withConfig(config).configure();
+ *
+ * @return an instance of {@link IdempotencyConfig}.
+ */
+ public IdempotencyConfig build() {
+ return new IdempotencyConfig(
+ eventKeyJMESPath,
+ payloadValidationJMESPath,
+ throwOnNoIdempotencyKey,
+ useLocalCache,
+ localCacheMaxItems,
+ expirationInSeconds,
+ hashFunction);
+ }
+
+ /**
+ * A JMESPath expression to extract the idempotency key from the event record.
+ * See https://jmespath.org/ for more details.
+ * Common paths are:
+ * powertools_json(body)
for APIGatewayProxyRequestEvent and APIGatewayV2HTTPEvent
+ * Records[*].powertools_json(body)
for SQSEvent
+ * Records[0].Sns.Message | powertools_json(@)
for SNSEvent
+ * detail
for ScheduledEvent (EventBridge / CloudWatch events)
+ * Records[*].kinesis.powertools_json(powertools_base64(data))
for KinesisEvent
+ * Records[*].powertools_json(powertools_base64(data))
for KinesisFirehoseEvent
+ * - ...
+ *
+ *
+ *
+ * @param eventKeyJMESPath path of the key in the Lambda event
+ * @return the instance of the builder (to chain operations)
+ */
+ public Builder withEventKeyJMESPath(String eventKeyJMESPath) {
+ this.eventKeyJMESPath = eventKeyJMESPath;
+ return this;
+ }
+
+ /**
+ * Set the maximum number of items to store in local cache, by default 256
+ *
+ * @param localCacheMaxItems maximum number of items to store in local cache
+ * @return the instance of the builder (to chain operations)
+ */
+ public Builder withLocalCacheMaxItems(int localCacheMaxItems) {
+ this.localCacheMaxItems = localCacheMaxItems;
+ return this;
+ }
+
+ /**
+ * Whether to locally cache idempotency results, by default false
+ *
+ * @param useLocalCache boolean that indicate if a local cache must be used in addition to the persistence store.
+ * If set to true, will use the {@link LRUCache}
+ * @return the instance of the builder (to chain operations)
+ */
+ public Builder withUseLocalCache(boolean useLocalCache) {
+ this.useLocalCache = useLocalCache;
+ return this;
+ }
+
+ /**
+ * The number of seconds to wait before a record is expired
+ *
+ * @param expiration expiration of the record in the store
+ * @return the instance of the builder (to chain operations)
+ */
+ public Builder withExpiration(Duration expiration) {
+ this.expirationInSeconds = expiration.getSeconds();
+ return this;
+ }
+
+ /**
+ * A JMESPath expression to extract the payload to be validated from the event record.
+ * See https://jmespath.org/ for more details.
+ *
+ * @param payloadValidationJMESPath JMES Path of a part of the payload to be used for validation
+ * @return the instance of the builder (to chain operations)
+ */
+ public Builder withPayloadValidationJMESPath(String payloadValidationJMESPath) {
+ this.payloadValidationJMESPath = payloadValidationJMESPath;
+ return this;
+ }
+
+ /**
+ * Whether to throw an exception if no idempotency key was found in the request, by default false
+ *
+ * @param throwOnNoIdempotencyKey boolean to indicate if we must throw an Exception when
+ * idempotency key could not be found in the payload.
+ * @return the instance of the builder (to chain operations)
+ */
+ public Builder withThrowOnNoIdempotencyKey(boolean throwOnNoIdempotencyKey) {
+ this.throwOnNoIdempotencyKey = throwOnNoIdempotencyKey;
+ return this;
+ }
+
+ /**
+ * Throw an exception if no idempotency key was found in the request.
+ * Shortcut for {@link #withThrowOnNoIdempotencyKey(boolean)}, forced as true
+ *
+ * @return the instance of the builder (to chain operations)
+ */
+ public Builder withThrowOnNoIdempotencyKey() {
+ return withThrowOnNoIdempotencyKey(true);
+ }
+
+ /**
+ * Function to use for calculating hashes, by default MD5.
+ *
+ * @param hashFunction Can be any algorithm supported by {@link java.security.MessageDigest}, most commons are
+ * - MD5
+ * - SHA-1
+ * - SHA-256
+ * @return the instance of the builder (to chain operations)
+ */
+ public Builder withHashFunction(String hashFunction) {
+ this.hashFunction = hashFunction;
+ return this;
+ }
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyKey.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyKey.java
new file mode 100644
index 000000000..92a0a3d49
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyKey.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * @IdempotencyKey is used to signal that a method parameter is used as a key for idempotency.
+ * Must be used in conjunction with the @Idempotency annotation.
+ * Example:
+ *
+ * @Idempotent
+ * private MyObject subMethod(String param1, @IdempotencyKey String param2) {
+ * // ...
+ * return something;
+ * }
+ *
+ * Note: This annotation is not needed when the method only has one parameter.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.PARAMETER)
+public @interface IdempotencyKey {
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/Idempotent.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/Idempotent.java
new file mode 100644
index 000000000..e7cace1fb
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/Idempotent.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency;
+
+import com.amazonaws.services.lambda.runtime.Context;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * @Idempotent is used to signal that the annotated method is idempotent:
+ * Calling this method one or multiple times with the same parameter will always return the same result.
+ * This annotation can be placed on the
+ * {@link com.amazonaws.services.lambda.runtime.RequestHandler#handleRequest(Object, Context)}
+ * method of a Lambda function:
+ *
+ * @Idempotent
+ * public String handleRequest(String event, Context ctx) {
+ * // ...
+ * return something;
+ * }
+ *
+ *
+ * It can also be placed on another method. In that case you may need to use the @{@link IdempotencyKey}
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface Idempotent {
+
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyAlreadyInProgressException.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyAlreadyInProgressException.java
new file mode 100644
index 000000000..3d5ee93c5
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyAlreadyInProgressException.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.exceptions;
+
+/**
+ * This exception is thrown when the same payload is sent
+ * while the previous one was not yet fully stored in the persistence layer (marked as COMPLETED).
+ */
+public class IdempotencyAlreadyInProgressException extends RuntimeException {
+ private static final long serialVersionUID = 7229475093418832265L;
+
+ public IdempotencyAlreadyInProgressException(String msg) {
+ super(msg);
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyConfigurationException.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyConfigurationException.java
new file mode 100644
index 000000000..0d3844641
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyConfigurationException.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.exceptions;
+
+/**
+ * Exception thrown when Idempotency is not well configured:
+ *
+ * - An annotated method does not return anything
+ * - An annotated method does not have parameters or more than one without
+ * the {@link software.amazon.lambda.powertools.idempotency.IdempotencyKey} annotation
+ *
+ */
+public class IdempotencyConfigurationException extends RuntimeException {
+ private static final long serialVersionUID = 560587720373305487L;
+
+ public IdempotencyConfigurationException(String message) {
+ super(message);
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyInconsistentStateException.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyInconsistentStateException.java
new file mode 100644
index 000000000..c6fe38d23
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyInconsistentStateException.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.exceptions;
+
+/**
+ * IdempotencyInconsistentStateException can happen under rare but expected cases
+ * when persistent state changes in the small-time between put & get requests.
+ */
+public class IdempotencyInconsistentStateException extends RuntimeException {
+ private static final long serialVersionUID = -4293951999802300672L;
+
+ public IdempotencyInconsistentStateException(String msg, Exception e) {
+ super(msg, e);
+ }
+
+ public IdempotencyInconsistentStateException(String msg) {
+ super(msg);
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyItemAlreadyExistsException.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyItemAlreadyExistsException.java
new file mode 100644
index 000000000..088db59c0
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyItemAlreadyExistsException.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.exceptions;
+
+/**
+ * Exception thrown when trying to store an item which already exists.
+ */
+public class IdempotencyItemAlreadyExistsException extends RuntimeException {
+ private static final long serialVersionUID = 9027152772149436500L;
+
+ public IdempotencyItemAlreadyExistsException() {
+ super();
+ }
+
+ public IdempotencyItemAlreadyExistsException(String msg, Throwable e) {
+ super(msg, e);
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyItemNotFoundException.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyItemNotFoundException.java
new file mode 100644
index 000000000..afae2554e
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyItemNotFoundException.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.exceptions;
+
+/**
+ * Exception thrown when the item was not found in the persistence store.
+ */
+public class IdempotencyItemNotFoundException extends RuntimeException{
+ private static final long serialVersionUID = 4818288566747993032L;
+
+ public IdempotencyItemNotFoundException(String idempotencyKey) {
+ super("Item with idempotency key "+ idempotencyKey + " not found");
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyKeyException.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyKeyException.java
new file mode 100644
index 000000000..7259dff0f
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyKeyException.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.exceptions;
+
+/**
+ * Exception thrown only when using {@link software.amazon.lambda.powertools.idempotency.IdempotencyConfig#throwOnNoIdempotencyKey()},
+ * and if a key could not be found in the event (for example when having a bad JMESPath configured)
+ */
+public class IdempotencyKeyException extends RuntimeException {
+ private static final long serialVersionUID = -8514965705001281773L;
+
+ public IdempotencyKeyException(String message) {
+ super(message);
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyPersistenceLayerException.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyPersistenceLayerException.java
new file mode 100644
index 000000000..fa49b746c
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyPersistenceLayerException.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.exceptions;
+
+/**
+ * Exception thrown when a technical error occurred with the persistence layer (eg. insertion, deletion, ... in database)
+ */
+public class IdempotencyPersistenceLayerException extends RuntimeException {
+ private static final long serialVersionUID = 6781832947434168547L;
+
+ public IdempotencyPersistenceLayerException(String msg, Exception e) {
+ super(msg, e);
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyValidationException.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyValidationException.java
new file mode 100644
index 000000000..5aee228eb
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/exceptions/IdempotencyValidationException.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.exceptions;
+
+import software.amazon.lambda.powertools.idempotency.IdempotencyConfig;
+
+/**
+ * Exception thrown only when using {@link IdempotencyConfig#getPayloadValidationJMESPath()} is configured
+ * and the payload changed between two calls (but with the same idempotency key).
+ */
+public class IdempotencyValidationException extends RuntimeException {
+ private static final long serialVersionUID = -4218652810664634761L;
+
+ public IdempotencyValidationException() {
+ super();
+ }
+
+ public IdempotencyValidationException(String message) {
+ super(message);
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyHandler.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyHandler.java
new file mode 100644
index 000000000..1f3724919
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyHandler.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.internal;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.lambda.powertools.idempotency.Idempotency;
+import software.amazon.lambda.powertools.idempotency.exceptions.*;
+import software.amazon.lambda.powertools.idempotency.persistence.BasePersistenceStore;
+import software.amazon.lambda.powertools.idempotency.persistence.DataRecord;
+import software.amazon.lambda.powertools.utilities.JsonConfig;
+
+import java.time.Instant;
+
+import static software.amazon.lambda.powertools.idempotency.persistence.DataRecord.Status.EXPIRED;
+import static software.amazon.lambda.powertools.idempotency.persistence.DataRecord.Status.INPROGRESS;
+
+/**
+ * Internal class that will handle the Idempotency, and use the {@link software.amazon.lambda.powertools.idempotency.persistence.PersistenceStore}
+ * to store the result of previous calls.
+ */
+public class IdempotencyHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(IdempotencyHandler.class);
+ private static final int MAX_RETRIES = 2;
+
+ private final ProceedingJoinPoint pjp;
+ private final JsonNode data;
+ private final BasePersistenceStore persistenceStore;
+
+ public IdempotencyHandler(ProceedingJoinPoint pjp, String functionName, JsonNode payload) {
+ this.pjp = pjp;
+ this.data = payload;
+ persistenceStore = Idempotency.getInstance().getPersistenceStore();
+ persistenceStore.configure(Idempotency.getInstance().getConfig(), functionName);
+ }
+
+ /**
+ * Main entry point for handling idempotent execution of a function.
+ *
+ * @return function response
+ */
+ public Object handle() throws Throwable {
+ // IdempotencyInconsistentStateException can happen under rare but expected cases
+ // when persistent state changes in the small time between put & get requests.
+ // In most cases we can retry successfully on this exception.
+ for (int i = 0; true; i++) {
+ try {
+ return processIdempotency();
+ } catch (IdempotencyInconsistentStateException e) {
+ if (i == MAX_RETRIES) {
+ throw e;
+ }
+ }
+ }
+ }
+
+ /**
+ * Process the function with idempotency
+ *
+ * @return function response
+ */
+ private Object processIdempotency() throws Throwable {
+ try {
+ // We call saveInProgress first as an optimization for the most common case where no idempotent record
+ // already exists. If it succeeds, there's no need to call getRecord.
+ persistenceStore.saveInProgress(data, Instant.now());
+ } catch (IdempotencyItemAlreadyExistsException iaee) {
+ DataRecord record = getIdempotencyRecord();
+ return handleForStatus(record);
+ } catch (IdempotencyKeyException ike) {
+ throw ike;
+ } catch (Exception e) {
+ throw new IdempotencyPersistenceLayerException("Failed to save in progress record to idempotency store. If you believe this is a powertools bug, please open an issue.", e);
+ }
+ return getFunctionResponse();
+ }
+
+ /**
+ * Retrieve the idempotency record from the persistence layer.
+ *
+ * @return the record if available
+ */
+ private DataRecord getIdempotencyRecord() {
+ try {
+ return persistenceStore.getRecord(data, Instant.now());
+ } catch (IdempotencyItemNotFoundException e) {
+ // This code path will only be triggered if the record is removed between saveInProgress and getRecord
+ LOG.debug("An existing idempotency record was deleted before we could fetch it");
+ throw new IdempotencyInconsistentStateException("saveInProgress and getRecord return inconsistent results", e);
+ } catch (IdempotencyValidationException | IdempotencyKeyException vke) {
+ throw vke;
+ } catch (Exception e) {
+ throw new IdempotencyPersistenceLayerException("Failed to get record from idempotency store. If you believe this is a powertools bug, please open an issue.", e);
+ }
+ }
+
+ /**
+ * Take appropriate action based on data_record's status
+ *
+ * @param record DataRecord
+ * @return Function's response previously used for this idempotency key, if it has successfully executed already.
+ */
+ private Object handleForStatus(DataRecord record) {
+ // This code path will only be triggered if the record becomes expired between the saveInProgress call and here
+ if (EXPIRED.equals(record.getStatus())) {
+ throw new IdempotencyInconsistentStateException("saveInProgress and getRecord return inconsistent results");
+ }
+
+ if (INPROGRESS.equals(record.getStatus())) {
+ throw new IdempotencyAlreadyInProgressException("Execution already in progress with idempotency key: " + record.getIdempotencyKey());
+ }
+
+ Class> returnType = ((MethodSignature) pjp.getSignature()).getReturnType();
+ try {
+ LOG.debug("Response for key '{}' retrieved from idempotency store, skipping the function", record.getIdempotencyKey());
+ return JsonConfig.get().getObjectMapper().reader().readValue(record.getResponseData(), returnType);
+ } catch (Exception e) {
+ throw new IdempotencyPersistenceLayerException("Unable to get function response as " + returnType.getSimpleName(), e);
+ }
+ }
+
+ private Object getFunctionResponse() throws Throwable {
+ Object response;
+ try {
+ response = pjp.proceed(pjp.getArgs());
+ } catch (Throwable handlerException) {
+ // We need these nested blocks to preserve function's exception in case the persistence store operation
+ // also raises an exception
+ try {
+ persistenceStore.deleteRecord(data, handlerException);
+ } catch (IdempotencyKeyException ke) {
+ throw ke;
+ } catch (Exception e) {
+ throw new IdempotencyPersistenceLayerException("Failed to delete record from idempotency store. If you believe this is a powertools bug, please open an issue.", e);
+ }
+ throw handlerException;
+ }
+
+ try {
+ persistenceStore.saveSuccess(data, response, Instant.now());
+ } catch (Exception e) {
+ throw new IdempotencyPersistenceLayerException("Failed to update record state to success in idempotency store. If you believe this is a powertools bug, please open an issue.", e);
+ }
+ return response;
+ }
+
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotentAspect.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotentAspect.java
new file mode 100644
index 000000000..b372a34a4
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/internal/IdempotentAspect.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.internal;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import software.amazon.lambda.powertools.idempotency.Constants;
+import software.amazon.lambda.powertools.idempotency.IdempotencyKey;
+import software.amazon.lambda.powertools.idempotency.Idempotent;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyConfigurationException;
+import software.amazon.lambda.powertools.utilities.JsonConfig;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+
+import static software.amazon.lambda.powertools.core.internal.LambdaHandlerProcessor.isHandlerMethod;
+import static software.amazon.lambda.powertools.core.internal.LambdaHandlerProcessor.placedOnRequestHandler;
+
+/**
+ * Aspect that handles the {@link Idempotent} annotation.
+ * It uses the {@link IdempotencyHandler} to actually do the job.
+ */
+@Aspect
+public class IdempotentAspect {
+ @SuppressWarnings({"EmptyMethod"})
+ @Pointcut("@annotation(idempotent)")
+ public void callAt(Idempotent idempotent) {
+ }
+
+ @Around(value = "callAt(idempotent) && execution(@Idempotent * *.*(..))", argNames = "pjp,idempotent")
+ public Object around(ProceedingJoinPoint pjp,
+ Idempotent idempotent) throws Throwable {
+
+ String idempotencyDisabledEnv = System.getenv().get(Constants.IDEMPOTENCY_DISABLED_ENV);
+ if (idempotencyDisabledEnv != null && !idempotencyDisabledEnv.equals("false")) {
+ return pjp.proceed(pjp.getArgs());
+ }
+
+ Method method = ((MethodSignature) pjp.getSignature()).getMethod();
+ if (method.getReturnType().equals(void.class)) {
+ throw new IdempotencyConfigurationException("The annotated method doesn't return anything. Unable to perform idempotency on void return type");
+ }
+
+ JsonNode payload = getPayload(pjp, method);
+ if (payload == null) {
+ throw new IdempotencyConfigurationException("Unable to get payload from the method. Ensure there is at least one parameter or that you use @IdempotencyKey");
+ }
+
+ IdempotencyHandler idempotencyHandler = new IdempotencyHandler(pjp, method.getName(), payload);
+ return idempotencyHandler.handle();
+ }
+
+ /**
+ * Retrieve the payload from the annotated method parameters
+ * @param pjp joinPoint
+ * @param method the annotated method
+ * @return the payload used for idempotency
+ */
+ private JsonNode getPayload(ProceedingJoinPoint pjp, Method method) {
+ JsonNode payload = null;
+ // handleRequest or method with one parameter: get the first one
+ if ((isHandlerMethod(pjp) && placedOnRequestHandler(pjp))
+ || pjp.getArgs().length == 1) {
+ payload = JsonConfig.get().getObjectMapper().valueToTree(pjp.getArgs()[0]);
+ } else {
+ // Look for a parameter annotated with @IdempotencyKey
+ Annotation[][] annotations = method.getParameterAnnotations();
+ for (int i = 0; i < annotations.length && payload == null; i++) {
+ Annotation[] annotationsRow = annotations[i];
+ for (int j = 0; j < annotationsRow.length && payload == null; j++) {
+ if (annotationsRow[j].annotationType().equals(IdempotencyKey.class)) {
+ payload = JsonConfig.get().getObjectMapper().valueToTree(pjp.getArgs()[i]);
+ }
+ }
+ }
+ }
+ return payload;
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java
new file mode 100644
index 000000000..a017c211a
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCache.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.internal.cache;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Implementation of a simple LRU Cache based on a {@link LinkedHashMap}
+ * See here.
+ * @param Type of the keys
+ * @param Types of the values
+ */
+public class LRUCache extends LinkedHashMap {
+
+ private static final long serialVersionUID = 3108262622672699228L;
+ private final int capacity;
+
+ public LRUCache(int capacity) {
+ super(capacity * 4 / 3, 0.75f, true);
+ this.capacity = capacity;
+ }
+
+ @Override
+ protected boolean removeEldestEntry(Map.Entry entry) {
+ return (size() > this.capacity);
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/BasePersistenceStore.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/BasePersistenceStore.java
new file mode 100644
index 000000000..a65b4c193
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/BasePersistenceStore.java
@@ -0,0 +1,350 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.persistence;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectWriter;
+import io.burt.jmespath.Expression;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.utils.StringUtils;
+import software.amazon.lambda.powertools.idempotency.Constants;
+import software.amazon.lambda.powertools.idempotency.IdempotencyConfig;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemAlreadyExistsException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemNotFoundException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyKeyException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyValidationException;
+import software.amazon.lambda.powertools.idempotency.internal.cache.LRUCache;
+import software.amazon.lambda.powertools.utilities.JsonConfig;
+
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Map;
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+/**
+ * Persistence layer that will store the idempotency result.
+ * Base implementation. See {@link DynamoDBPersistenceStore} for an implementation (default one)
+ * Extends this class to use your own implementation (DocumentDB, Elasticache, ...)
+ */
+public abstract class BasePersistenceStore implements PersistenceStore {
+
+ private static final Logger LOG = LoggerFactory.getLogger(BasePersistenceStore.class);
+
+ private String functionName = "";
+ private boolean configured = false;
+ private long expirationInSeconds = 60 * 60; // 1 hour default
+ private boolean useLocalCache = false;
+ private LRUCache cache;
+ private String eventKeyJMESPath;
+ private Expression eventKeyCompiledJMESPath;
+ protected boolean payloadValidationEnabled = false;
+ private Expression validationKeyJMESPath;
+ private boolean throwOnNoIdempotencyKey = false;
+ private MessageDigest hashAlgorithm;
+
+ /**
+ * Initialize the base persistence layer from the configuration settings
+ *
+ * @param config Idempotency configuration settings
+ * @param functionName The name of the function being decorated
+ */
+ public void configure(IdempotencyConfig config, String functionName) {
+ String funcEnv = System.getenv(Constants.LAMBDA_FUNCTION_NAME_ENV);
+ this.functionName = funcEnv != null ? funcEnv : "testFunction";
+ if (!StringUtils.isEmpty(functionName)) {
+ this.functionName += "." + functionName;
+ }
+
+ if (configured) {
+ // prevent being reconfigured multiple times
+ return;
+ }
+
+ eventKeyJMESPath = config.getEventKeyJMESPath();
+ if (eventKeyJMESPath != null) {
+ eventKeyCompiledJMESPath = JsonConfig.get().getJmesPath().compile(eventKeyJMESPath);
+ }
+ if (config.getPayloadValidationJMESPath() != null) {
+ validationKeyJMESPath = JsonConfig.get().getJmesPath().compile(config.getPayloadValidationJMESPath());
+ payloadValidationEnabled = true;
+ }
+ throwOnNoIdempotencyKey = config.throwOnNoIdempotencyKey();
+
+ useLocalCache = config.useLocalCache();
+ if (useLocalCache) {
+ cache = new LRUCache<>(config.getLocalCacheMaxItems());
+ }
+ expirationInSeconds = config.getExpirationInSeconds();
+
+ try {
+ hashAlgorithm = MessageDigest.getInstance(config.getHashFunction());
+ } catch (NoSuchAlgorithmException e) {
+ LOG.warn("Error instantiating {} hash function, trying with MD5", config.getHashFunction());
+ try {
+ hashAlgorithm = MessageDigest.getInstance("MD5");
+ } catch (NoSuchAlgorithmException ex) {
+ throw new RuntimeException("Unable to instantiate MD5 digest", ex);
+ }
+ }
+ configured = true;
+ }
+
+ /**
+ * Save record of function's execution completing successfully
+ *
+ * @param data Payload
+ * @param result the response from the function
+ */
+ public void saveSuccess(JsonNode data, Object result, Instant now) {
+ ObjectWriter writer = JsonConfig.get().getObjectMapper().writer();
+ try {
+ String responseJson = writer.writeValueAsString(result);
+ DataRecord record = new DataRecord(
+ getHashedIdempotencyKey(data),
+ DataRecord.Status.COMPLETED,
+ getExpiryEpochSecond(now),
+ responseJson,
+ getHashedPayload(data)
+ );
+ LOG.debug("Function successfully executed. Saving record to persistence store with idempotency key: {}", record.getIdempotencyKey());
+ updateRecord(record);
+ saveToCache(record);
+ } catch (JsonProcessingException e) {
+ // TODO : throw ?
+ throw new RuntimeException("Error while serializing the response", e);
+ }
+ }
+
+ /**
+ * Save record of function's execution being in progress
+ *
+ * @param data Payload
+ * @param now
+ */
+ public void saveInProgress(JsonNode data, Instant now) throws IdempotencyItemAlreadyExistsException {
+ String idempotencyKey = getHashedIdempotencyKey(data);
+
+ if (retrieveFromCache(idempotencyKey, now) != null) {
+ throw new IdempotencyItemAlreadyExistsException();
+ }
+
+ DataRecord record = new DataRecord(
+ idempotencyKey,
+ DataRecord.Status.INPROGRESS,
+ getExpiryEpochSecond(now),
+ null,
+ getHashedPayload(data)
+ );
+ LOG.debug("saving in progress record for idempotency key: {}", record.getIdempotencyKey());
+ putRecord(record, now);
+ }
+
+ /**
+ * Delete record from the persistence store
+ *
+ * @param data Payload
+ * @param throwable The throwable thrown by the function
+ */
+ public void deleteRecord(JsonNode data, Throwable throwable) {
+ String idemPotencyKey = getHashedIdempotencyKey(data);
+
+ LOG.debug("Function raised an exception {}. " +
+ "Clearing in progress record in persistence store for idempotency key: {}",
+ throwable.getClass(),
+ idemPotencyKey);
+
+ deleteRecord(idemPotencyKey);
+ deleteFromCache(idemPotencyKey);
+ }
+
+ /**
+ * Retrieve idempotency key for data provided, fetch from persistence store, and convert to DataRecord.
+ *
+ * @param data Payload
+ * @return DataRecord representation of existing record found in persistence store
+ * @throws IdempotencyValidationException Payload doesn't match the stored record for the given idempotency key
+ * @throws IdempotencyItemNotFoundException Exception thrown if no record exists in persistence store with the idempotency key
+ */
+ public DataRecord getRecord(JsonNode data, Instant now) throws IdempotencyValidationException, IdempotencyItemNotFoundException {
+ String idemPotencyKey = getHashedIdempotencyKey(data);
+
+ DataRecord cachedRecord = retrieveFromCache(idemPotencyKey, now);
+ if (cachedRecord != null) {
+ LOG.debug("Idempotency record found in cache with idempotency key: {}", idemPotencyKey);
+ validatePayload(data, cachedRecord);
+ return cachedRecord;
+ }
+
+ DataRecord record = getRecord(idemPotencyKey);
+ saveToCache(record);
+ validatePayload(data, record);
+ return record;
+ }
+
+ /**
+ * Extract idempotency key and return a hashed representation
+ *
+ * @param data incoming data
+ * @return Hashed representation of the data extracted by the jmespath expression
+ */
+ private String getHashedIdempotencyKey(JsonNode data) {
+ JsonNode node = data;
+
+ if (eventKeyJMESPath != null) {
+ node = eventKeyCompiledJMESPath.search(data);
+ }
+
+ if (isMissingIdemPotencyKey(node)) {
+ if (throwOnNoIdempotencyKey) {
+ throw new IdempotencyKeyException("No data found to create a hashed idempotency key");
+ }
+ LOG.warn("No data found to create a hashed idempotency key. JMESPath: {}", eventKeyJMESPath);
+ }
+
+ String hash = generateHash(node);
+ return functionName + "#" + hash;
+ }
+
+ private boolean isMissingIdemPotencyKey(JsonNode data) {
+ if (data.isContainerNode()) {
+ Stream> stream = StreamSupport.stream(Spliterators.spliteratorUnknownSize(data.fields(), Spliterator.ORDERED), false);
+ return stream.allMatch(e -> e.getValue().isNull());
+ }
+ return data.isNull();
+ }
+
+ /**
+ * Extract payload using validation key jmespath and return a hashed representation
+ *
+ * @param data Payload
+ * @return Hashed representation of the data extracted by the jmespath expression
+ */
+ private String getHashedPayload(JsonNode data) {
+ if (!payloadValidationEnabled) {
+ return "";
+ }
+ JsonNode object = validationKeyJMESPath.search(data);
+ return generateHash(object);
+ }
+
+ /**
+ * Generate a hash value from the provided data
+ *
+ * @param data data to hash
+ * @return Hashed representation of the provided data
+ */
+ String generateHash(JsonNode data) {
+ Object node;
+ // if array or object, use the json string representation, otherwise get the real value
+ if (data.isContainerNode()) {
+ node = data.toString();
+ } else if (data.isTextual()) {
+ node = data.asText();
+ } else if (data.isInt()) {
+ node = data.asInt();
+ } else if (data.isLong()) {
+ node = data.asLong();
+ } else if (data.isDouble()) {
+ node = data.asDouble();
+ } else if (data.isFloat()) {
+ node = data.floatValue();
+ } else if (data.isBigInteger()) {
+ node = data.bigIntegerValue();
+ } else if (data.isBigDecimal()) {
+ node = data.decimalValue();
+ } else if (data.isBoolean()) {
+ node = data.asBoolean();
+ } else node = data; // anything else
+ byte[] digest = hashAlgorithm.digest(node.toString().getBytes(StandardCharsets.UTF_8));
+ return String.format("%032x", new BigInteger(1, digest));
+ }
+
+ /**
+ * Validate that the hashed payload matches data provided and stored data record
+ *
+ * @param data Payload
+ * @param dataRecord DataRecord instance
+ */
+ private void validatePayload(JsonNode data, DataRecord dataRecord) throws IdempotencyValidationException {
+ if (payloadValidationEnabled) {
+ String dataHash = getHashedPayload(data);
+ if (!StringUtils.equals(dataHash, dataRecord.getPayloadHash())) {
+ throw new IdempotencyValidationException("Payload does not match stored record for this event key");
+ }
+ }
+ }
+
+ /**
+ * @param now
+ * @return unix timestamp of expiry date for idempotency record
+ */
+ private long getExpiryEpochSecond(Instant now) {
+ return now.plus(expirationInSeconds, ChronoUnit.SECONDS).getEpochSecond();
+ }
+
+ /**
+ * Save data_record to local cache except when status is "INPROGRESS"
+ *
+ * NOTE: We can't cache "INPROGRESS" records as we have no way to reflect updates that can happen outside of the
+ * execution environment
+ *
+ * @param dataRecord DataRecord to save in cache
+ */
+ private void saveToCache(DataRecord dataRecord) {
+ if (!useLocalCache)
+ return;
+ if (dataRecord.getStatus().equals(DataRecord.Status.INPROGRESS))
+ return;
+
+ cache.put(dataRecord.getIdempotencyKey(), dataRecord);
+ }
+
+ private DataRecord retrieveFromCache(String idempotencyKey, Instant now) {
+ if (!useLocalCache)
+ return null;
+
+ DataRecord record = cache.get(idempotencyKey);
+ if (record != null) {
+ if (!record.isExpired(now)) {
+ return record;
+ }
+ LOG.debug("Removing expired local cache record for idempotency key: {}", idempotencyKey);
+ deleteFromCache(idempotencyKey);
+ }
+ return null;
+ }
+
+ private void deleteFromCache(String idempotencyKey) {
+ if (!useLocalCache)
+ return;
+ cache.remove(idempotencyKey);
+ }
+
+ /**
+ * For test purpose only (adding a cache to mock)
+ */
+ void configure(IdempotencyConfig config, String functionName, LRUCache cache) {
+ this.configure(config, functionName);
+ this.cache = cache;
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/DataRecord.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/DataRecord.java
new file mode 100644
index 000000000..b4f58a73d
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/DataRecord.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.persistence;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Data Class for idempotency records. This is actually the item that will be stored in the persistence layer.
+ */
+public class DataRecord {
+ private final String idempotencyKey;
+ private final String status;
+ private final long expiryTimestamp;
+ private final String responseData;
+ private final String payloadHash;
+
+ public DataRecord(String idempotencyKey, Status status, long expiryTimestamp, String responseData, String payloadHash) {
+ this.idempotencyKey = idempotencyKey;
+ this.status = status.toString();
+ this.expiryTimestamp = expiryTimestamp;
+ this.responseData = responseData;
+ this.payloadHash = payloadHash;
+ }
+
+ public String getIdempotencyKey() {
+ return idempotencyKey;
+ }
+
+ /**
+ * Check if data record is expired (based on expiration configured in the {@link software.amazon.lambda.powertools.idempotency.IdempotencyConfig})
+ *
+ * @return Whether the record is currently expired or not
+ */
+ public boolean isExpired(Instant now) {
+ return expiryTimestamp != 0 && now.isAfter(Instant.ofEpochSecond(expiryTimestamp));
+ }
+
+ public Status getStatus() {
+ Instant now = Instant.now();
+ if (isExpired(now)) {
+ return Status.EXPIRED;
+ } else {
+ return Status.valueOf(status);
+ }
+ }
+
+ public long getExpiryTimestamp() {
+ return expiryTimestamp;
+ }
+
+ public String getResponseData() {
+ return responseData;
+ }
+
+ public String getPayloadHash() {
+ return payloadHash;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ DataRecord record = (DataRecord) o;
+ return expiryTimestamp == record.expiryTimestamp
+ && idempotencyKey.equals(record.idempotencyKey)
+ && status.equals(record.status)
+ && Objects.equals(responseData, record.responseData)
+ && Objects.equals(payloadHash, record.payloadHash);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(idempotencyKey, status, expiryTimestamp, responseData, payloadHash);
+ }
+
+ /**
+ * Status of the record:
+ *
+ * - INPROGRESS: record initialized when function starts
+ * - COMPLETED: record updated with the result of the function when it ends
+ * - EXPIRED: record expired, idempotency will not happen
+ *
+ */
+ public enum Status {
+ INPROGRESS("INPROGRESS"), COMPLETED("COMPLETED"), EXPIRED("EXPIRED");
+
+ private final String status;
+
+ Status(String status) {
+ this.status = status;
+ }
+
+ public String toString() {
+ return status;
+ }
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/DynamoDBPersistenceStore.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/DynamoDBPersistenceStore.java
new file mode 100644
index 000000000..6e36c6dc6
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/DynamoDBPersistenceStore.java
@@ -0,0 +1,357 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.persistence;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider;
+import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClientBuilder;
+import software.amazon.awssdk.services.dynamodb.model.*;
+import software.amazon.awssdk.utils.StringUtils;
+import software.amazon.lambda.powertools.idempotency.Constants;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemAlreadyExistsException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemNotFoundException;
+
+import java.time.Instant;
+import java.util.AbstractMap;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static software.amazon.lambda.powertools.idempotency.Constants.AWS_REGION_ENV;
+
+/**
+ * DynamoDB version of the {@link PersistenceStore}. Will store idempotency data in DynamoDB.
+ * Use the {@link Builder} to create a new instance.
+ */
+public class DynamoDBPersistenceStore extends BasePersistenceStore implements PersistenceStore {
+
+ private static final Logger LOG = LoggerFactory.getLogger(DynamoDBPersistenceStore.class);
+
+ private final String tableName;
+ private final String keyAttr;
+ private final String staticPkValue;
+ private final String sortKeyAttr;
+ private final String expiryAttr;
+ private final String statusAttr;
+ private final String dataAttr;
+ private final String validationAttr;
+ private final DynamoDbClient dynamoDbClient;
+
+ /**
+ * Private: use the {@link Builder} to instantiate a new {@link DynamoDBPersistenceStore}
+ */
+ private DynamoDBPersistenceStore(String tableName,
+ String keyAttr,
+ String staticPkValue,
+ String sortKeyAttr,
+ String expiryAttr,
+ String statusAttr,
+ String dataAttr,
+ String validationAttr,
+ DynamoDbClient client) {
+ this.tableName = tableName;
+ this.keyAttr = keyAttr;
+ this.staticPkValue = staticPkValue;
+ this.sortKeyAttr = sortKeyAttr;
+ this.expiryAttr = expiryAttr;
+ this.statusAttr = statusAttr;
+ this.dataAttr = dataAttr;
+ this.validationAttr = validationAttr;
+
+ if (client != null) {
+ this.dynamoDbClient = client;
+ } else {
+ DynamoDbClientBuilder ddbBuilder = DynamoDbClient.builder()
+ .credentialsProvider(EnvironmentVariableCredentialsProvider.create())
+ .httpClient(UrlConnectionHttpClient.builder().build())
+ .region(Region.of(System.getenv(AWS_REGION_ENV)));
+ this.dynamoDbClient = ddbBuilder.build();
+ }
+ }
+
+ @Override
+ public DataRecord getRecord(String idempotencyKey) throws IdempotencyItemNotFoundException {
+ GetItemResponse response = dynamoDbClient.getItem(
+ GetItemRequest.builder()
+ .tableName(tableName)
+ .key(getKey(idempotencyKey))
+ .consistentRead(true)
+ .build()
+ );
+
+ if (!response.hasItem()) {
+ throw new IdempotencyItemNotFoundException(idempotencyKey);
+ }
+
+ return itemToRecord(response.item());
+ }
+
+ @Override
+ public void putRecord(DataRecord record, Instant now) throws IdempotencyItemAlreadyExistsException {
+ Map item = new HashMap<>(getKey(record.getIdempotencyKey()));
+ item.put(this.expiryAttr, AttributeValue.builder().n(String.valueOf(record.getExpiryTimestamp())).build());
+ item.put(this.statusAttr, AttributeValue.builder().s(record.getStatus().toString()).build());
+ if (this.payloadValidationEnabled) {
+ item.put(this.validationAttr, AttributeValue.builder().s(record.getPayloadHash()).build());
+ }
+
+ try {
+ LOG.debug("Putting record for idempotency key: {}", record.getIdempotencyKey());
+
+ Map expressionAttributeNames = Stream.of(
+ new AbstractMap.SimpleEntry<>("#id", this.keyAttr),
+ new AbstractMap.SimpleEntry<>("#expiry", this.expiryAttr))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+ dynamoDbClient.putItem(
+ PutItemRequest.builder()
+ .tableName(tableName)
+ .item(item)
+ .conditionExpression("attribute_not_exists(#id) OR #expiry < :now")
+ .expressionAttributeNames(expressionAttributeNames)
+ .expressionAttributeValues(Collections.singletonMap(":now", AttributeValue.builder().n(String.valueOf(now.getEpochSecond())).build()))
+ .build()
+ );
+ } catch (ConditionalCheckFailedException e) {
+ LOG.debug("Failed to put record for already existing idempotency key: {}", record.getIdempotencyKey());
+ throw new IdempotencyItemAlreadyExistsException("Failed to put record for already existing idempotency key: " + record.getIdempotencyKey(), e);
+ }
+ }
+
+ @Override
+ public void updateRecord(DataRecord record) {
+ LOG.debug("Updating record for idempotency key: {}", record.getIdempotencyKey());
+ String updateExpression = "SET #response_data = :response_data, #expiry = :expiry, #status = :status";
+
+ Map expressionAttributeNames = Stream.of(
+ new AbstractMap.SimpleEntry<>("#response_data", this.dataAttr),
+ new AbstractMap.SimpleEntry<>("#expiry", this.expiryAttr),
+ new AbstractMap.SimpleEntry<>("#status", this.statusAttr))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+ Map expressionAttributeValues = Stream.of(
+ new AbstractMap.SimpleEntry<>(":response_data", AttributeValue.builder().s(record.getResponseData()).build()),
+ new AbstractMap.SimpleEntry<>(":expiry", AttributeValue.builder().n(String.valueOf(record.getExpiryTimestamp())).build()),
+ new AbstractMap.SimpleEntry<>(":status", AttributeValue.builder().s(record.getStatus().toString()).build()))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+ if (payloadValidationEnabled) {
+ updateExpression += ", #validation_key = :validation_key";
+ expressionAttributeNames.put("#validation_key", this.validationAttr);
+ expressionAttributeValues.put(":validation_key", AttributeValue.builder().s(record.getPayloadHash()).build());
+ }
+
+ dynamoDbClient.updateItem(UpdateItemRequest.builder()
+ .tableName(tableName)
+ .key(getKey(record.getIdempotencyKey()))
+ .updateExpression(updateExpression)
+ .expressionAttributeNames(expressionAttributeNames)
+ .expressionAttributeValues(expressionAttributeValues)
+ .build()
+ );
+ }
+
+ @Override
+ public void deleteRecord(String idempotencyKey) {
+ LOG.debug("Deleting record for idempotency key: {}", idempotencyKey);
+ dynamoDbClient.deleteItem(DeleteItemRequest.builder()
+ .tableName(tableName)
+ .key(getKey(idempotencyKey))
+ .build()
+ );
+ }
+
+ /**
+ * Get the key to use for requests (depending on if we have a sort key or not)
+ *
+ * @param idempotencyKey
+ * @return
+ */
+ private Map getKey(String idempotencyKey) {
+ Map key = new HashMap<>();
+ if (this.sortKeyAttr != null) {
+ key.put(this.keyAttr, AttributeValue.builder().s(this.staticPkValue).build());
+ key.put(this.sortKeyAttr, AttributeValue.builder().s(idempotencyKey).build());
+ } else {
+ key.put(this.keyAttr, AttributeValue.builder().s(idempotencyKey).build());
+ }
+ return key;
+ }
+
+ /**
+ * Translate raw item records from DynamoDB to DataRecord
+ *
+ * @param item Item from dynamodb response
+ * @return DataRecord instance
+ */
+ private DataRecord itemToRecord(Map item) {
+ // data and validation payload may be null
+ AttributeValue data = item.get(this.dataAttr);
+ AttributeValue validation = item.get(this.validationAttr);
+
+ return new DataRecord(item.get(sortKeyAttr != null ? sortKeyAttr: keyAttr).s(),
+ DataRecord.Status.valueOf(item.get(this.statusAttr).s()),
+ Long.parseLong(item.get(this.expiryAttr).n()),
+ data != null ? data.s() : null,
+ validation != null ? validation.s() : null);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Use this builder to get an instance of {@link DynamoDBPersistenceStore}.
+ * With this builder you can configure the characteristics of the DynamoDB Table
+ * (name, key, sort key, and other field names).
+ * You can also set a custom {@link DynamoDbClient} for further tuning.
+ */
+ public static class Builder {
+ private static final String funcEnv = System.getenv(Constants.LAMBDA_FUNCTION_NAME_ENV);
+
+ private String tableName;
+ private String keyAttr = "id";
+ private String staticPkValue = String.format("idempotency#%s", funcEnv != null ? funcEnv : "");
+ private String sortKeyAttr;
+ private String expiryAttr = "expiration";
+ private String statusAttr = "status";
+ private String dataAttr = "data";
+ private String validationAttr = "validation";
+ private DynamoDbClient dynamoDbClient;
+
+ /**
+ * Initialize and return a new instance of {@link DynamoDBPersistenceStore}.
+ * Example:
+ *
+ * DynamoDBPersistenceStore.builder().withTableName("idempotency_store").build();
+ *
+ *
+ * @return an instance of the {@link DynamoDBPersistenceStore}
+ */
+ public DynamoDBPersistenceStore build() {
+ if (StringUtils.isEmpty(tableName)) {
+ throw new IllegalArgumentException("Table name is not specified");
+ }
+ return new DynamoDBPersistenceStore(tableName, keyAttr, staticPkValue, sortKeyAttr, expiryAttr, statusAttr, dataAttr, validationAttr, dynamoDbClient);
+ }
+
+ /**
+ * Name of the table to use for storing execution records (mandatory)
+ *
+ * @param tableName Name of the DynamoDB table
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withTableName(String tableName) {
+ this.tableName = tableName;
+ return this;
+ }
+
+ /**
+ * DynamoDB attribute name for partition key (optional), by default "id"
+ *
+ * @param keyAttr name of the key attribute in the table
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withKeyAttr(String keyAttr) {
+ this.keyAttr = keyAttr;
+ return this;
+ }
+
+ /**
+ * DynamoDB attribute value for partition key (optional), by default "idempotency#[function-name]".
+ * This will be used if the {@link #sortKeyAttr} is set.
+ *
+ * @param staticPkValue name of the partition key attribute in the table
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withStaticPkValue(String staticPkValue) {
+ this.staticPkValue = staticPkValue;
+ return this;
+ }
+
+ /**
+ * DynamoDB attribute name for the sort key (optional)
+ *
+ * @param sortKeyAttr name of the sort key attribute in the table
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withSortKeyAttr(String sortKeyAttr) {
+ this.sortKeyAttr = sortKeyAttr;
+ return this;
+ }
+
+ /**
+ * DynamoDB attribute name for expiry timestamp (optional), by default "expiration"
+ *
+ * @param expiryAttr name of the expiry attribute in the table
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withExpiryAttr(String expiryAttr) {
+ this.expiryAttr = expiryAttr;
+ return this;
+ }
+
+ /**
+ * DynamoDB attribute name for status (optional), by default "status"
+ *
+ * @param statusAttr name of the status attribute in the table
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withStatusAttr(String statusAttr) {
+ this.statusAttr = statusAttr;
+ return this;
+ }
+
+ /**
+ * DynamoDB attribute name for response data (optional), by default "data"
+ *
+ * @param dataAttr name of the data attribute in the table
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withDataAttr(String dataAttr) {
+ this.dataAttr = dataAttr;
+ return this;
+ }
+
+ /**
+ * DynamoDB attribute name for validation (optional), by default "validation"
+ *
+ * @param validationAttr name of the validation attribute in the table
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withValidationAttr(String validationAttr) {
+ this.validationAttr = validationAttr;
+ return this;
+ }
+
+ /**
+ * Custom {@link DynamoDbClient} used to query DynamoDB (optional).
+ * The default one uses {@link UrlConnectionHttpClient} as a http client and
+ * add com.amazonaws.xray.interceptors.TracingInterceptor (X-Ray) if available in the classpath.
+ *
+ * @param dynamoDbClient the {@link DynamoDbClient} instance to use
+ * @return the builder instance (to chain operations)
+ */
+ public Builder withDynamoDbClient(DynamoDbClient dynamoDbClient) {
+ this.dynamoDbClient = dynamoDbClient;
+ return this;
+ }
+ }
+}
diff --git a/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/PersistenceStore.java b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/PersistenceStore.java
new file mode 100644
index 000000000..d199c99b5
--- /dev/null
+++ b/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/PersistenceStore.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.persistence;
+
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemAlreadyExistsException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemNotFoundException;
+
+import java.time.Instant;
+
+/**
+ * Persistence layer that will store the idempotency result.
+ * In order to provide another implementation, extends {@link BasePersistenceStore}.
+ */
+public interface PersistenceStore {
+
+ /**
+ * Retrieve item from persistence store using idempotency key and return it as a DataRecord instance.
+ * @param idempotencyKey the key of the record
+ * @return DataRecord representation of existing record found in persistence store
+ * @throws IdempotencyItemNotFoundException Exception thrown if no record exists in persistence store with the idempotency key
+ */
+ DataRecord getRecord(String idempotencyKey) throws IdempotencyItemNotFoundException;
+
+ /**
+ * Add a DataRecord to persistence store if it does not already exist with that key
+ * @param record DataRecord instance
+ * @param now
+ * @throws IdempotencyItemAlreadyExistsException if a non-expired entry already exists.
+ */
+ void putRecord(DataRecord record, Instant now) throws IdempotencyItemAlreadyExistsException;
+
+ /**
+ * Update item in persistence store
+ * @param record DataRecord instance
+ */
+ void updateRecord(DataRecord record);
+
+ /**
+ * Remove item from persistence store
+ * @param idempotencyKey the key of the record
+ */
+ void deleteRecord(String idempotencyKey);
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/DynamoDBConfig.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/DynamoDBConfig.java
new file mode 100644
index 000000000..38678322c
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/DynamoDBConfig.java
@@ -0,0 +1,79 @@
+package software.amazon.lambda.powertools.idempotency;
+
+import com.amazonaws.services.dynamodbv2.local.main.ServerRunner;
+import com.amazonaws.services.dynamodbv2.local.server.DynamoDBProxyServer;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.awssdk.services.dynamodb.model.*;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.URI;
+
+public class DynamoDBConfig {
+ protected static final String TABLE_NAME = "idempotency_table";
+ protected static DynamoDBProxyServer dynamoProxy;
+ protected static DynamoDbClient client;
+
+ @BeforeAll
+ public static void setupDynamo() {
+ int port = getFreePort();
+ try {
+ dynamoProxy = ServerRunner.createServerFromCommandLineArgs(new String[]{
+ "-inMemory",
+ "-port",
+ Integer.toString(port)
+ });
+ dynamoProxy.start();
+ } catch (Exception e) {
+ throw new RuntimeException();
+ }
+
+ client = DynamoDbClient.builder()
+ .httpClient(UrlConnectionHttpClient.builder().build())
+ .region(Region.EU_WEST_1)
+ .endpointOverride(URI.create("http://localhost:" + port))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create("FAKE", "FAKE")))
+ .build();
+
+ client.createTable(CreateTableRequest.builder()
+ .tableName(TABLE_NAME)
+ .keySchema(KeySchemaElement.builder().keyType(KeyType.HASH).attributeName("id").build())
+ .attributeDefinitions(
+ AttributeDefinition.builder().attributeName("id").attributeType(ScalarAttributeType.S).build()
+ )
+ .billingMode(BillingMode.PAY_PER_REQUEST)
+ .build());
+
+ DescribeTableResponse response = client.describeTable(DescribeTableRequest.builder().tableName(TABLE_NAME).build());
+ if (response == null) {
+ throw new RuntimeException("Table was not created within expected time");
+ }
+ }
+
+ @AfterAll
+ public static void teardownDynamo() {
+ try {
+ dynamoProxy.stop();
+ } catch (Exception e) {
+ throw new RuntimeException();
+ }
+ }
+
+ private static int getFreePort() {
+ try {
+ ServerSocket socket = new ServerSocket(0);
+ int port = socket.getLocalPort();
+ socket.close();
+ return port;
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/IdempotencyTest.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/IdempotencyTest.java
new file mode 100644
index 000000000..a782d9613
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/IdempotencyTest.java
@@ -0,0 +1,43 @@
+package software.amazon.lambda.powertools.idempotency;
+
+
+import com.amazonaws.services.lambda.runtime.Context;
+import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
+import com.amazonaws.services.lambda.runtime.tests.EventLoader;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
+import software.amazon.lambda.powertools.idempotency.handlers.IdempotencyFunction;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class IdempotencyTest extends DynamoDBConfig {
+
+ @Mock
+ private Context context;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ }
+
+ @Test
+ public void endToEndTest() {
+ IdempotencyFunction function = new IdempotencyFunction(client);
+
+ APIGatewayProxyResponseEvent response = function.handleRequest(EventLoader.loadApiGatewayRestEvent("apigw_event2.json"), context);
+ assertThat(function.handlerExecuted).isTrue();
+
+ function.handlerExecuted = false;
+
+ APIGatewayProxyResponseEvent response2 = function.handleRequest(EventLoader.loadApiGatewayRestEvent("apigw_event2.json"), context);
+ assertThat(function.handlerExecuted).isFalse();
+
+ assertThat(response).isEqualTo(response2);
+ assertThat(response2.getBody()).contains("hello world");
+
+ assertThat(client.scan(ScanRequest.builder().tableName(TABLE_NAME).build()).count()).isEqualTo(1);
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyEnabledFunction.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyEnabledFunction.java
new file mode 100644
index 000000000..6c39dc6de
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyEnabledFunction.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.handlers;
+
+import com.amazonaws.services.lambda.runtime.Context;
+import com.amazonaws.services.lambda.runtime.RequestHandler;
+import software.amazon.lambda.powertools.idempotency.Idempotent;
+import software.amazon.lambda.powertools.idempotency.model.Basket;
+import software.amazon.lambda.powertools.idempotency.model.Product;
+
+/**
+ * Simple Lambda function with @{@link Idempotent} annotation on handleRequest method
+ */
+public class IdempotencyEnabledFunction implements RequestHandler {
+
+ private boolean handlerCalled = false;
+
+ public boolean handlerCalled() {
+ return handlerCalled;
+ }
+
+ @Override
+ @Idempotent
+ public Basket handleRequest(Product input, Context context) {
+ handlerCalled = true;
+ Basket b = new Basket();
+ b.add(input);
+ return b;
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyFunction.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyFunction.java
new file mode 100644
index 000000000..c60336b81
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyFunction.java
@@ -0,0 +1,79 @@
+package software.amazon.lambda.powertools.idempotency.handlers;
+
+import com.amazonaws.services.lambda.runtime.Context;
+import com.amazonaws.services.lambda.runtime.RequestHandler;
+import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
+import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.lambda.powertools.idempotency.Idempotency;
+import software.amazon.lambda.powertools.idempotency.IdempotencyConfig;
+import software.amazon.lambda.powertools.idempotency.Idempotent;
+import software.amazon.lambda.powertools.idempotency.persistence.DynamoDBPersistenceStore;
+import software.amazon.lambda.powertools.utilities.JsonConfig;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public class IdempotencyFunction implements RequestHandler {
+ private final static Logger LOG = LogManager.getLogger();
+
+ public boolean handlerExecuted = false;
+
+ public IdempotencyFunction(DynamoDbClient client) {
+ // we need to initialize idempotency configuration before the handleRequest method is called
+ Idempotency.config().withConfig(
+ IdempotencyConfig.builder()
+ .withEventKeyJMESPath("powertools_json(body).address")
+ .build())
+ .withPersistenceStore(
+ DynamoDBPersistenceStore.builder()
+ .withTableName("idempotency_table")
+ .withDynamoDbClient(client)
+ .build()
+ ).configure();
+ }
+
+ @Idempotent
+ public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) {
+ handlerExecuted = true;
+ Map headers = new HashMap<>();
+
+ headers.put("Content-Type", "application/json");
+ headers.put("Access-Control-Allow-Origin", "*");
+ headers.put("Access-Control-Allow-Methods", "GET, OPTIONS");
+ headers.put("Access-Control-Allow-Headers", "*");
+
+ APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent()
+ .withHeaders(headers);
+ try {
+ String address = JsonConfig.get().getObjectMapper().readTree(input.getBody()).get("address").asText();
+ final String pageContents = this.getPageContents(address);
+ String output = String.format("{ \"message\": \"hello world\", \"location\": \"%s\" }", pageContents);
+
+ LOG.debug("ip is {}", pageContents);
+ return response
+ .withStatusCode(200)
+ .withBody(output);
+
+ } catch (IOException e) {
+ return response
+ .withBody("{}")
+ .withStatusCode(500);
+ }
+ }
+
+ // we could actually also put the @Idempotent annotation here
+ private String getPageContents(String address) throws IOException {
+ URL url = new URL(address);
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(url.openStream()))) {
+ return br.lines().collect(Collectors.joining(System.lineSeparator()));
+ }
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunction.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunction.java
new file mode 100644
index 000000000..549d9e7ed
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunction.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2020 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.handlers;
+
+import com.amazonaws.services.lambda.runtime.Context;
+import com.amazonaws.services.lambda.runtime.RequestHandler;
+import software.amazon.lambda.powertools.idempotency.IdempotencyKey;
+import software.amazon.lambda.powertools.idempotency.Idempotent;
+import software.amazon.lambda.powertools.idempotency.model.Basket;
+import software.amazon.lambda.powertools.idempotency.model.Product;
+
+/**
+ * Simple Lambda function with @{@link Idempotent} annotation on a sub method (not the handleRequest one)
+ */
+public class IdempotencyInternalFunction implements RequestHandler {
+
+ private boolean called = false;
+
+ @Override
+ public Basket handleRequest(Product input, Context context) {
+ return createBasket("fake", input);
+ }
+
+ @Idempotent
+ private Basket createBasket(@IdempotencyKey String magicProduct, Product p) {
+ called = true;
+ Basket b = new Basket(p);
+ b.add(new Product(0, magicProduct, 0));
+ return b;
+ }
+
+ public boolean subMethodCalled() {
+ return called;
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunctionInternalKey.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunctionInternalKey.java
new file mode 100644
index 000000000..566db6727
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunctionInternalKey.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2020 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.handlers;
+
+import com.amazonaws.services.lambda.runtime.Context;
+import com.amazonaws.services.lambda.runtime.RequestHandler;
+import software.amazon.lambda.powertools.idempotency.Idempotent;
+import software.amazon.lambda.powertools.idempotency.model.Basket;
+import software.amazon.lambda.powertools.idempotency.model.Product;
+
+/**
+ * Simple Lambda function with @{@link Idempotent} annotation on a sub method (not the handleRequest one)
+ */
+public class IdempotencyInternalFunctionInternalKey implements RequestHandler {
+
+ @Override
+ public Basket handleRequest(Product input, Context context) {
+ return createBasket(input);
+ }
+
+ @Idempotent
+ private Basket createBasket(Product p) {
+ return new Basket(p);
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunctionInvalid.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunctionInvalid.java
new file mode 100644
index 000000000..4c82bff15
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunctionInvalid.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.handlers;
+
+import com.amazonaws.services.lambda.runtime.Context;
+import com.amazonaws.services.lambda.runtime.RequestHandler;
+import software.amazon.lambda.powertools.idempotency.Idempotent;
+import software.amazon.lambda.powertools.idempotency.model.Basket;
+import software.amazon.lambda.powertools.idempotency.model.Product;
+
+/**
+ * Simple Lambda function with @{@link Idempotent} annotation a sub method.
+ * This one is invalid as there are two parameters and @IdempotencyKey
+ * is not used to specify which one will be used as a key for persistence.
+ */
+public class IdempotencyInternalFunctionInvalid implements RequestHandler {
+
+ @Override
+ public Basket handleRequest(Product input, Context context) {
+ return createBasket("fake", input);
+ }
+
+ @Idempotent
+ private Basket createBasket(String magicProduct, Product p) {
+ Basket b = new Basket(p);
+ b.add(new Product(0, magicProduct, 0));
+ return b;
+ }
+
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunctionVoid.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunctionVoid.java
new file mode 100644
index 000000000..a6b89fc8d
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyInternalFunctionVoid.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.handlers;
+
+import com.amazonaws.services.lambda.runtime.Context;
+import com.amazonaws.services.lambda.runtime.RequestHandler;
+import software.amazon.lambda.powertools.idempotency.IdempotencyKey;
+import software.amazon.lambda.powertools.idempotency.Idempotent;
+import software.amazon.lambda.powertools.idempotency.model.Basket;
+import software.amazon.lambda.powertools.idempotency.model.Product;
+
+/**
+ * Simple Lambda function with @{@link Idempotent} annotation a sub method.
+ * This one is invalid because the annotated method return type is void, thus we cannot store any response.
+ */
+public class IdempotencyInternalFunctionVoid implements RequestHandler {
+
+ @Override
+ public Basket handleRequest(Product input, Context context) {
+ Basket b = new Basket(input);
+ addProduct("fake", b);
+ return b;
+ }
+
+ @Idempotent
+ private void addProduct(@IdempotencyKey String productName, Basket b) {
+ b.add(new Product(0, productName, 0));
+ }
+
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyWithErrorFunction.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyWithErrorFunction.java
new file mode 100644
index 000000000..1444d8a5f
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/handlers/IdempotencyWithErrorFunction.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.handlers;
+
+import com.amazonaws.services.lambda.runtime.Context;
+import com.amazonaws.services.lambda.runtime.RequestHandler;
+import software.amazon.lambda.powertools.idempotency.Idempotent;
+import software.amazon.lambda.powertools.idempotency.model.Basket;
+import software.amazon.lambda.powertools.idempotency.model.Product;
+
+/**
+ * Simple Lambda function with @{@link Idempotent} annotation on handleRequest method.
+ * This function throws an exception.
+ */
+public class IdempotencyWithErrorFunction implements RequestHandler {
+
+ @Override
+ @Idempotent
+ public Basket handleRequest(Product input, Context context) {
+ throw new IndexOutOfBoundsException("Fake exception");
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java
new file mode 100644
index 000000000..fc91c6c61
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/internal/IdempotencyAspectTest.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.internal;
+
+import com.amazonaws.services.lambda.runtime.Context;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junitpioneer.jupiter.SetEnvironmentVariable;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import software.amazon.lambda.powertools.idempotency.Constants;
+import software.amazon.lambda.powertools.idempotency.Idempotency;
+import software.amazon.lambda.powertools.idempotency.IdempotencyConfig;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyAlreadyInProgressException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyConfigurationException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemAlreadyExistsException;
+import software.amazon.lambda.powertools.idempotency.handlers.*;
+import software.amazon.lambda.powertools.idempotency.model.Basket;
+import software.amazon.lambda.powertools.idempotency.model.Product;
+import software.amazon.lambda.powertools.idempotency.persistence.BasePersistenceStore;
+import software.amazon.lambda.powertools.idempotency.persistence.DataRecord;
+import software.amazon.lambda.powertools.utilities.JsonConfig;
+
+import java.time.Instant;
+
+import static java.time.temporal.ChronoUnit.SECONDS;
+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.*;
+
+public class IdempotencyAspectTest {
+
+ @Mock
+ private Context context;
+
+ @Mock
+ private BasePersistenceStore store;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ }
+
+ @Test
+ public void firstCall_shouldPutInStore() {
+ Idempotency.config()
+ .withPersistenceStore(store)
+ .withConfig(IdempotencyConfig.builder()
+ .withEventKeyJMESPath("id")
+ .build()
+ ).configure();
+
+ IdempotencyEnabledFunction function = new IdempotencyEnabledFunction();
+
+ Product p = new Product(42, "fake product", 12);
+ Basket basket = function.handleRequest(p, context);
+ assertThat(basket.getProducts()).hasSize(1);
+ assertThat(function.handlerCalled()).isTrue();
+
+ ArgumentCaptor nodeCaptor = ArgumentCaptor.forClass(JsonNode.class);
+ verify(store).saveInProgress(nodeCaptor.capture(), any());
+ assertThat(nodeCaptor.getValue().get("id").asLong()).isEqualTo(p.getId());
+ assertThat(nodeCaptor.getValue().get("name").asText()).isEqualTo(p.getName());
+ assertThat(nodeCaptor.getValue().get("price").asDouble()).isEqualTo(p.getPrice());
+
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(Basket.class);
+ verify(store).saveSuccess(any(), resultCaptor.capture(), any());
+ assertThat(resultCaptor.getValue()).isEqualTo(basket);
+ }
+
+ @Test
+ public void secondCall_notExpired_shouldGetFromStore() throws JsonProcessingException {
+ // GIVEN
+ Idempotency.config()
+ .withPersistenceStore(store)
+ .withConfig(IdempotencyConfig.builder()
+ .withEventKeyJMESPath("id")
+ .build()
+ ).configure();
+
+ doThrow(IdempotencyItemAlreadyExistsException.class).when(store).saveInProgress(any(), any());
+
+ Product p = new Product(42, "fake product", 12);
+ Basket b = new Basket(p);
+ DataRecord record = new DataRecord(
+ "42",
+ DataRecord.Status.COMPLETED,
+ Instant.now().plus(356, SECONDS).getEpochSecond(),
+ JsonConfig.get().getObjectMapper().writer().writeValueAsString(b),
+ null);
+ doReturn(record).when(store).getRecord(any(), any());
+
+ // WHEN
+ IdempotencyEnabledFunction function = new IdempotencyEnabledFunction();
+ Basket basket = function.handleRequest(p, context);
+
+ // THEN
+ assertThat(basket).isEqualTo(b);
+ assertThat(function.handlerCalled()).isFalse();
+ }
+
+ @Test
+ public void secondCall_inProgress_shouldThrowIdempotencyAlreadyInProgressException() throws JsonProcessingException {
+ // GIVEN
+ Idempotency.config()
+ .withPersistenceStore(store)
+ .withConfig(IdempotencyConfig.builder()
+ .withEventKeyJMESPath("id")
+ .build()
+ ).configure();
+
+ doThrow(IdempotencyItemAlreadyExistsException.class).when(store).saveInProgress(any(), any());
+
+ Product p = new Product(42, "fake product", 12);
+ Basket b = new Basket(p);
+ DataRecord record = new DataRecord(
+ "42",
+ DataRecord.Status.INPROGRESS,
+ Instant.now().plus(356, SECONDS).getEpochSecond(),
+ JsonConfig.get().getObjectMapper().writer().writeValueAsString(b),
+ null);
+ doReturn(record).when(store).getRecord(any(), any());
+
+ // THEN
+ IdempotencyEnabledFunction function = new IdempotencyEnabledFunction();
+ assertThatThrownBy(() -> function.handleRequest(p, context)).isInstanceOf(IdempotencyAlreadyInProgressException.class);
+ }
+
+ @Test
+ public void functionThrowException_shouldDeleteRecord_andThrowFunctionException() {
+ // GIVEN
+ Idempotency.config()
+ .withPersistenceStore(store)
+ .withConfig(IdempotencyConfig.builder()
+ .withEventKeyJMESPath("id")
+ .build()
+ ).configure();
+
+ // WHEN / THEN
+ IdempotencyWithErrorFunction function = new IdempotencyWithErrorFunction();
+
+ Product p = new Product(42, "fake product", 12);
+ assertThatThrownBy(() -> function.handleRequest(p, context))
+ .isInstanceOf(IndexOutOfBoundsException.class);
+
+ verify(store).deleteRecord(any(), any(IndexOutOfBoundsException.class));
+ }
+
+ @Test
+ @SetEnvironmentVariable(key = Constants.IDEMPOTENCY_DISABLED_ENV, value = "true")
+ public void testIdempotencyDisabled_shouldJustRunTheFunction() {
+ // GIVEN
+ Idempotency.config()
+ .withPersistenceStore(store)
+ .withConfig(IdempotencyConfig.builder()
+ .withEventKeyJMESPath("id")
+ .build()
+ ).configure();
+
+ // WHEN
+ IdempotencyEnabledFunction function = new IdempotencyEnabledFunction();
+ Product p = new Product(42, "fake product", 12);
+ Basket basket = function.handleRequest(p, context);
+
+ // THEN
+ verifyNoInteractions(store);
+ assertThat(basket.getProducts()).hasSize(1);
+ assertThat(function.handlerCalled()).isTrue();
+ }
+
+ @Test
+ public void idempotencyOnSubMethodAnnotated_firstCall_shouldPutInStore() {
+ Idempotency.config()
+ .withPersistenceStore(store)
+ .configure();
+
+ // WHEN
+ IdempotencyInternalFunction function = new IdempotencyInternalFunction();
+ Product p = new Product(42, "fake product", 12);
+ Basket basket = function.handleRequest(p, context);
+
+ // THEN
+ assertThat(basket.getProducts()).hasSize(2);
+ assertThat(function.subMethodCalled()).isTrue();
+
+ ArgumentCaptor nodeCaptor = ArgumentCaptor.forClass(JsonNode.class);
+ verify(store).saveInProgress(nodeCaptor.capture(), any());
+ assertThat(nodeCaptor.getValue().asText()).isEqualTo("fake");
+
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(Basket.class);
+ verify(store).saveSuccess(any(), resultCaptor.capture(), any());
+ assertThat(resultCaptor.getValue().getProducts()).contains(basket.getProducts().get(0), new Product(0, "fake", 0));
+ }
+
+ @Test
+ public void idempotencyOnSubMethodAnnotated_secondCall_notExpired_shouldGetFromStore() throws JsonProcessingException {
+ // GIVEN
+ Idempotency.config()
+ .withPersistenceStore(store)
+ .configure();
+
+ doThrow(IdempotencyItemAlreadyExistsException.class).when(store).saveInProgress(any(), any());
+
+ Product p = new Product(42, "fake product", 12);
+ Basket b = new Basket(p);
+ DataRecord record = new DataRecord(
+ "fake",
+ DataRecord.Status.COMPLETED,
+ Instant.now().plus(356, SECONDS).getEpochSecond(),
+ JsonConfig.get().getObjectMapper().writer().writeValueAsString(b),
+ null);
+ doReturn(record).when(store).getRecord(any(), any());
+
+ // WHEN
+ IdempotencyInternalFunction function = new IdempotencyInternalFunction();
+ Basket basket = function.handleRequest(p, context);
+
+ // THEN
+ assertThat(basket).isEqualTo(b);
+ assertThat(function.subMethodCalled()).isFalse();
+ }
+
+ @Test
+ public void idempotencyOnSubMethodAnnotated_keyJMESPath_shouldPutInStoreWithKey() {
+ BasePersistenceStore persistenceStore = spy(BasePersistenceStore.class);
+
+ Idempotency.config()
+ .withPersistenceStore(persistenceStore)
+ .withConfig(IdempotencyConfig.builder().withEventKeyJMESPath("id").build())
+ .configure();
+
+ // WHEN
+ IdempotencyInternalFunctionInternalKey function = new IdempotencyInternalFunctionInternalKey();
+ Product p = new Product(42, "fake product", 12);
+ function.handleRequest(p, context);
+
+ // THEN
+ ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(DataRecord.class);
+ verify(persistenceStore).putRecord(recordCaptor.capture(), any());
+ // a1d0c6e83f027327d8461063f4ac58a6 = MD5(42)
+ assertThat(recordCaptor.getValue().getIdempotencyKey()).isEqualTo("testFunction.createBasket#a1d0c6e83f027327d8461063f4ac58a6");
+ }
+
+ @Test
+ public void idempotencyOnSubMethodNotAnnotated_shouldThrowException() {
+ Idempotency.config()
+ .withPersistenceStore(store)
+ .withConfig(IdempotencyConfig.builder().build()
+ ).configure();
+
+ // WHEN
+ IdempotencyInternalFunctionInvalid function = new IdempotencyInternalFunctionInvalid();
+ Product p = new Product(42, "fake product", 12);
+
+ // THEN
+ assertThatThrownBy(() -> function.handleRequest(p, context)).isInstanceOf(IdempotencyConfigurationException.class);
+ }
+
+ @Test
+ public void idempotencyOnSubMethodVoid_shouldThrowException() {
+ Idempotency.config()
+ .withPersistenceStore(store)
+ .withConfig(IdempotencyConfig.builder().build()
+ ).configure();
+
+ // WHEN
+ IdempotencyInternalFunctionVoid function = new IdempotencyInternalFunctionVoid();
+ Product p = new Product(42, "fake product", 12);
+
+ // THEN
+ assertThatThrownBy(() -> function.handleRequest(p, context)).isInstanceOf(IdempotencyConfigurationException.class);
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCacheTest.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCacheTest.java
new file mode 100644
index 000000000..3d2f7c7e3
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/internal/cache/LRUCacheTest.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.internal.cache;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class LRUCacheTest {
+
+ @Test
+ public void testLRUCache_shouldRemoveEldestEntry() {
+ LRUCache cache = new LRUCache<>(3);
+ cache.put("key1", "value1");
+ cache.put("key2", "value2");
+ cache.put("key3", "value3");
+ cache.put("key4", "value4");
+ cache.put("key5", "value5");
+
+ assertThat(cache).hasSize(3).doesNotContainKeys("key1", "key2");
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/model/Basket.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/model/Basket.java
new file mode 100644
index 000000000..304fd3810
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/model/Basket.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.model;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+public class Basket {
+ private List products = new ArrayList<>();
+
+ public List getProducts() {
+ return products;
+ }
+
+ public void setProducts(List products) {
+ this.products = products;
+ }
+
+ public Basket() {
+ }
+
+ public Basket( Product ...p){
+ products.addAll(Arrays.asList(p));
+ }
+
+ public void add(Product product) {
+ products.add(product);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Basket basket = (Basket) o;
+ return products.equals(basket.products);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(products);
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/model/Product.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/model/Product.java
new file mode 100644
index 000000000..1c66c584d
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/model/Product.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.model;
+
+import java.util.Objects;
+
+public class Product {
+ private long id;
+
+ private String name;
+
+ private double price;
+
+ public Product() {
+ }
+
+ public Product(long id, String name, double price) {
+ this.id = id;
+ this.name = name;
+ this.price = price;
+ }
+
+ public long getId() {
+ return id;
+ }
+
+ public void setId(long id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public double getPrice() {
+ return price;
+ }
+
+ public void setPrice(double price) {
+ this.price = price;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Product product = (Product) o;
+ return id == product.id && Double.compare(product.price, price) == 0 && Objects.equals(name, product.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name, price);
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/BasePersistenceStoreTest.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/BasePersistenceStoreTest.java
new file mode 100644
index 000000000..dac9a9288
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/BasePersistenceStoreTest.java
@@ -0,0 +1,365 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.persistence;
+
+import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
+import com.amazonaws.services.lambda.runtime.tests.EventLoader;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.node.DoubleNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import software.amazon.lambda.powertools.idempotency.IdempotencyConfig;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemAlreadyExistsException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemNotFoundException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyKeyException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyValidationException;
+import software.amazon.lambda.powertools.idempotency.internal.cache.LRUCache;
+import software.amazon.lambda.powertools.idempotency.model.Product;
+import software.amazon.lambda.powertools.utilities.JsonConfig;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class BasePersistenceStoreTest {
+
+ private DataRecord dr;
+ private BasePersistenceStore persistenceStore;
+ private int status = 0;
+ private String validationHash;
+
+ @BeforeEach
+ public void setup() {
+ validationHash = null;
+ dr = null;
+ status = -1;
+ persistenceStore = new BasePersistenceStore() {
+ @Override
+ public DataRecord getRecord(String idempotencyKey) throws IdempotencyItemNotFoundException {
+ status = 0;
+ return new DataRecord(idempotencyKey, DataRecord.Status.INPROGRESS, Instant.now().plus(3600, ChronoUnit.SECONDS).getEpochSecond(), "Response", validationHash);
+ }
+
+ @Override
+ public void putRecord(DataRecord record, Instant now) throws IdempotencyItemAlreadyExistsException {
+ dr = record;
+ status = 1;
+ }
+
+ @Override
+ public void updateRecord(DataRecord record) {
+ dr = record;
+ status = 2;
+ }
+
+ @Override
+ public void deleteRecord(String idempotencyKey) {
+ dr = null;
+ status = 3;
+ }
+ };
+ }
+
+ // =================================================================
+ //
+ @Test
+ public void saveInProgress_defaultConfig() {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ persistenceStore.configure(IdempotencyConfig.builder().build(), null);
+
+ Instant now = Instant.now();
+ persistenceStore.saveInProgress(JsonConfig.get().getObjectMapper().valueToTree(event), now);
+ assertThat(dr.getStatus()).isEqualTo(DataRecord.Status.INPROGRESS);
+ assertThat(dr.getExpiryTimestamp()).isEqualTo(now.plus(3600, ChronoUnit.SECONDS).getEpochSecond());
+ assertThat(dr.getResponseData()).isNull();
+ assertThat(dr.getIdempotencyKey()).isEqualTo("testFunction#47261bd5b456f400f8d191cfb3a7482f");
+ assertThat(dr.getPayloadHash()).isEqualTo("");
+ assertThat(status).isEqualTo(1);
+ }
+
+ @Test
+ public void saveInProgress_jmespath() {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ persistenceStore.configure(IdempotencyConfig.builder()
+ .withEventKeyJMESPath("powertools_json(body).id")
+ .build(), "myfunc");
+
+ Instant now = Instant.now();
+ persistenceStore.saveInProgress(JsonConfig.get().getObjectMapper().valueToTree(event), now);
+ assertThat(dr.getStatus()).isEqualTo(DataRecord.Status.INPROGRESS);
+ assertThat(dr.getExpiryTimestamp()).isEqualTo(now.plus(3600, ChronoUnit.SECONDS).getEpochSecond());
+ assertThat(dr.getResponseData()).isNull();
+ assertThat(dr.getIdempotencyKey()).isEqualTo("testFunction.myfunc#2fef178cc82be5ce3da6c5e0466a6182");
+ assertThat(dr.getPayloadHash()).isEqualTo("");
+ assertThat(status).isEqualTo(1);
+ }
+
+ @Test
+ public void saveInProgress_jmespath_NotFound_shouldThrowException() {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ persistenceStore.configure(IdempotencyConfig.builder()
+ .withEventKeyJMESPath("unavailable")
+ .withThrowOnNoIdempotencyKey(true) // should throw
+ .build(), "");
+ Instant now = Instant.now();
+ assertThatThrownBy(() -> persistenceStore.saveInProgress(JsonConfig.get().getObjectMapper().valueToTree(event), now))
+ .isInstanceOf(IdempotencyKeyException.class)
+ .hasMessageContaining("No data found to create a hashed idempotency key");
+ assertThat(status).isEqualTo(-1);
+ }
+
+ @Test
+ public void saveInProgress_jmespath_NotFound_shouldNotThrowException() {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ persistenceStore.configure(IdempotencyConfig.builder()
+ .withEventKeyJMESPath("unavailable")
+ .build(), "");
+ Instant now = Instant.now();
+ persistenceStore.saveInProgress(JsonConfig.get().getObjectMapper().valueToTree(event), now);
+ assertThat(dr.getStatus()).isEqualTo(DataRecord.Status.INPROGRESS);
+ assertThat(status).isEqualTo(1);
+ }
+
+ @Test
+ public void saveInProgress_withLocalCache_NotExpired_ShouldThrowException() {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ LRUCache cache = new LRUCache<>(2);
+ persistenceStore.configure(IdempotencyConfig.builder()
+ .withUseLocalCache(true)
+ .withEventKeyJMESPath("powertools_json(body).id")
+ .build(), null, cache);
+ Instant now = Instant.now();
+ cache.put("testFunction#2fef178cc82be5ce3da6c5e0466a6182",
+ new DataRecord(
+ "testFunction#2fef178cc82be5ce3da6c5e0466a6182",
+ DataRecord.Status.INPROGRESS,
+ now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(),
+ null, null)
+ );
+ assertThatThrownBy(() -> persistenceStore.saveInProgress(JsonConfig.get().getObjectMapper().valueToTree(event), now))
+ .isInstanceOf(IdempotencyItemAlreadyExistsException.class);
+ assertThat(status).isEqualTo(-1);
+ }
+
+ @Test
+ public void saveInProgress_withLocalCache_Expired_ShouldRemoveFromCache() {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ LRUCache cache = new LRUCache<>(2);
+ persistenceStore.configure(IdempotencyConfig.builder()
+ .withEventKeyJMESPath("powertools_json(body).id")
+ .withUseLocalCache(true)
+ .withExpiration(Duration.of(2, ChronoUnit.SECONDS))
+ .build(), null, cache);
+ Instant now = Instant.now();
+ cache.put("testFunction#2fef178cc82be5ce3da6c5e0466a6182",
+ new DataRecord(
+ "testFunction#2fef178cc82be5ce3da6c5e0466a6182",
+ DataRecord.Status.INPROGRESS,
+ now.minus(3, ChronoUnit.SECONDS).getEpochSecond(),
+ null, null)
+ );
+ persistenceStore.saveInProgress(JsonConfig.get().getObjectMapper().valueToTree(event), now);
+ assertThat(dr.getStatus()).isEqualTo(DataRecord.Status.INPROGRESS);
+ assertThat(cache).isEmpty();
+ assertThat(status).isEqualTo(1);
+ }
+ //
+ // =================================================================
+
+ // =================================================================
+ //
+
+ @Test
+ public void saveSuccess_shouldUpdateRecord() throws JsonProcessingException {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ LRUCache cache = new LRUCache<>(2);
+ persistenceStore.configure(IdempotencyConfig.builder().build(), null, cache);
+
+ Product product = new Product(34543, "product", 42);
+ Instant now = Instant.now();
+ persistenceStore.saveSuccess(JsonConfig.get().getObjectMapper().valueToTree(event), product, now);
+
+ assertThat(dr.getStatus()).isEqualTo(DataRecord.Status.COMPLETED);
+ assertThat(dr.getExpiryTimestamp()).isEqualTo(now.plus(3600, ChronoUnit.SECONDS).getEpochSecond());
+ assertThat(dr.getResponseData()).isEqualTo(JsonConfig.get().getObjectMapper().writeValueAsString(product));
+ assertThat(dr.getIdempotencyKey()).isEqualTo("testFunction#47261bd5b456f400f8d191cfb3a7482f");
+ assertThat(dr.getPayloadHash()).isEqualTo("");
+ assertThat(status).isEqualTo(2);
+ assertThat(cache).isEmpty();
+ }
+
+ @Test
+ public void saveSuccess_withCacheEnabled_shouldSaveInCache() throws JsonProcessingException {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ LRUCache cache = new LRUCache<>(2);
+ persistenceStore.configure(IdempotencyConfig.builder()
+ .withUseLocalCache(true).build(), null, cache);
+
+ Product product = new Product(34543, "product", 42);
+ Instant now = Instant.now();
+ persistenceStore.saveSuccess(JsonConfig.get().getObjectMapper().valueToTree(event), product, now);
+
+ assertThat(status).isEqualTo(2);
+ assertThat(cache).hasSize(1);
+ DataRecord record = cache.get("testFunction#47261bd5b456f400f8d191cfb3a7482f");
+ assertThat(record.getStatus()).isEqualTo(DataRecord.Status.COMPLETED);
+ assertThat(record.getExpiryTimestamp()).isEqualTo(now.plus(3600, ChronoUnit.SECONDS).getEpochSecond());
+ assertThat(record.getResponseData()).isEqualTo(JsonConfig.get().getObjectMapper().writeValueAsString(product));
+ assertThat(record.getIdempotencyKey()).isEqualTo("testFunction#47261bd5b456f400f8d191cfb3a7482f");
+ assertThat(record.getPayloadHash()).isEqualTo("");
+ }
+
+ //
+ // =================================================================
+
+ // =================================================================
+ //
+
+ @Test
+ public void getRecord_shouldReturnRecordFromPersistence() throws IdempotencyItemNotFoundException, IdempotencyValidationException {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ LRUCache cache = new LRUCache<>(2);
+ persistenceStore.configure(IdempotencyConfig.builder().build(), "myfunc", cache);
+
+ Instant now = Instant.now();
+ DataRecord record = persistenceStore.getRecord(JsonConfig.get().getObjectMapper().valueToTree(event), now);
+ assertThat(record.getIdempotencyKey()).isEqualTo("testFunction.myfunc#47261bd5b456f400f8d191cfb3a7482f");
+ assertThat(record.getStatus()).isEqualTo(DataRecord.Status.INPROGRESS);
+ assertThat(record.getResponseData()).isEqualTo("Response");
+ assertThat(status).isEqualTo(0);
+ }
+
+ @Test
+ public void getRecord_cacheEnabledNotExpired_shouldReturnRecordFromCache() throws IdempotencyItemNotFoundException, IdempotencyValidationException {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ LRUCache cache = new LRUCache<>(2);
+ persistenceStore.configure(IdempotencyConfig.builder()
+ .withUseLocalCache(true).build(), "myfunc", cache);
+
+ Instant now = Instant.now();
+ DataRecord dr = new DataRecord(
+ "testFunction.myfunc#47261bd5b456f400f8d191cfb3a7482f",
+ DataRecord.Status.COMPLETED,
+ now.plus(3600, ChronoUnit.SECONDS).getEpochSecond(),
+ "result of the function",
+ null);
+ cache.put("testFunction.myfunc#47261bd5b456f400f8d191cfb3a7482f", dr);
+
+ DataRecord record = persistenceStore.getRecord(JsonConfig.get().getObjectMapper().valueToTree(event), now);
+ assertThat(record.getIdempotencyKey()).isEqualTo("testFunction.myfunc#47261bd5b456f400f8d191cfb3a7482f");
+ assertThat(record.getStatus()).isEqualTo(DataRecord.Status.COMPLETED);
+ assertThat(record.getResponseData()).isEqualTo("result of the function");
+ assertThat(status).isEqualTo(-1); // getRecord must not be called (retrieve from cache)
+ }
+
+ @Test
+ public void getRecord_cacheEnabledExpired_shouldReturnRecordFromPersistence() throws IdempotencyItemNotFoundException, IdempotencyValidationException {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ LRUCache cache = new LRUCache<>(2);
+ persistenceStore.configure(IdempotencyConfig.builder()
+ .withUseLocalCache(true).build(), "myfunc", cache);
+
+ Instant now = Instant.now();
+ DataRecord dr = new DataRecord(
+ "testFunction.myfunc#47261bd5b456f400f8d191cfb3a7482f",
+ DataRecord.Status.COMPLETED,
+ now.minus(3, ChronoUnit.SECONDS).getEpochSecond(),
+ "result of the function",
+ null);
+ cache.put("testFunction.myfunc#47261bd5b456f400f8d191cfb3a7482f", dr);
+
+ DataRecord record = persistenceStore.getRecord(JsonConfig.get().getObjectMapper().valueToTree(event), now);
+ assertThat(record.getIdempotencyKey()).isEqualTo("testFunction.myfunc#47261bd5b456f400f8d191cfb3a7482f");
+ assertThat(record.getStatus()).isEqualTo(DataRecord.Status.INPROGRESS);
+ assertThat(record.getResponseData()).isEqualTo("Response");
+ assertThat(status).isEqualTo(0);
+ assertThat(cache).isEmpty();
+ }
+
+ @Test
+ public void getRecord_invalidPayload_shouldThrowValidationException() {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ persistenceStore.configure(IdempotencyConfig.builder()
+ .withEventKeyJMESPath("powertools_json(body).id")
+ .withPayloadValidationJMESPath("powertools_json(body).message")
+ .build(),
+ "myfunc");
+
+ this.validationHash = "different hash"; // "Lambda rocks" ==> 70c24d88041893f7fbab4105b76fd9e1
+
+ assertThatThrownBy(() -> persistenceStore.getRecord(JsonConfig.get().getObjectMapper().valueToTree(event), Instant.now()))
+ .isInstanceOf(IdempotencyValidationException.class);
+ }
+
+ //
+ // =================================================================
+
+ // =================================================================
+ //
+
+ @Test
+ public void deleteRecord_shouldDeleteRecordFromPersistence() {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ persistenceStore.configure(IdempotencyConfig.builder().build(), null);
+
+ persistenceStore.deleteRecord(JsonConfig.get().getObjectMapper().valueToTree(event), new ArithmeticException());
+ assertThat(status).isEqualTo(3);
+ }
+
+ @Test
+ public void deleteRecord_cacheEnabled_shouldDeleteRecordFromCache() {
+ APIGatewayProxyRequestEvent event = EventLoader.loadApiGatewayRestEvent("apigw_event.json");
+ LRUCache cache = new LRUCache<>(2);
+ persistenceStore.configure(IdempotencyConfig.builder()
+ .withUseLocalCache(true).build(), null, cache);
+
+ cache.put("testFunction#47261bd5b456f400f8d191cfb3a7482f",
+ new DataRecord("testFunction#47261bd5b456f400f8d191cfb3a7482f", DataRecord.Status.COMPLETED, 123, null, null));
+ persistenceStore.deleteRecord(JsonConfig.get().getObjectMapper().valueToTree(event), new ArithmeticException());
+ assertThat(status).isEqualTo(3);
+ assertThat(cache).isEmpty();
+ }
+
+ //
+ // =================================================================
+
+ @Test
+ public void generateHashString_shouldGenerateMd5ofString() {
+ persistenceStore.configure(IdempotencyConfig.builder().build(), null);
+ String expectedHash = "70c24d88041893f7fbab4105b76fd9e1"; // MD5(Lambda rocks)
+ String generatedHash = persistenceStore.generateHash(new TextNode("Lambda rocks"));
+ assertThat(generatedHash).isEqualTo(expectedHash);
+ }
+
+ @Test
+ public void generateHashObject_shouldGenerateMd5ofJsonObject() {
+ persistenceStore.configure(IdempotencyConfig.builder().build(), null);
+ Product product = new Product(42, "Product", 12);
+ String expectedHash = "e71c41727848ed68050d82740894c29b"; // MD5({"id":42,"name":"Product","price":12.0})
+ String generatedHash = persistenceStore.generateHash(JsonConfig.get().getObjectMapper().valueToTree(product));
+ assertThat(generatedHash).isEqualTo(expectedHash);
+ }
+
+ @Test
+ public void generateHashDouble_shouldGenerateMd5ofDouble() {
+ persistenceStore.configure(IdempotencyConfig.builder().build(), null);
+ String expectedHash = "bb84c94278119c8838649706df4db42b"; // MD5(256.42)
+ String generatedHash = persistenceStore.generateHash(new DoubleNode(256.42));
+ assertThat(generatedHash).isEqualTo(expectedHash);
+ }
+}
diff --git a/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/DynamoDBPersistenceStoreTest.java b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/DynamoDBPersistenceStoreTest.java
new file mode 100644
index 000000000..ecf8ad3e0
--- /dev/null
+++ b/powertools-idempotency/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/DynamoDBPersistenceStoreTest.java
@@ -0,0 +1,278 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.idempotency.persistence;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.services.dynamodb.model.*;
+import software.amazon.lambda.powertools.idempotency.DynamoDBConfig;
+import software.amazon.lambda.powertools.idempotency.IdempotencyConfig;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemAlreadyExistsException;
+import software.amazon.lambda.powertools.idempotency.exceptions.IdempotencyItemNotFoundException;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * These test are using DynamoDBLocal and sqlite, see https://nickolasfisher.com/blog/Configuring-an-In-Memory-DynamoDB-instance-with-Java-for-Integration-Testing
+ * NOTE: on a Mac with Apple Chipset, you need to use the Oracle JDK x86 64-bit
+ */
+public class DynamoDBPersistenceStoreTest extends DynamoDBConfig {
+ protected static final String TABLE_NAME_CUSTOM = "idempotency_table_custom";
+ private Map key;
+ private DynamoDBPersistenceStore dynamoDBPersistenceStore;
+
+ // =================================================================
+ //
+ @Test
+ public void putRecord_shouldCreateRecordInDynamoDB() throws IdempotencyItemAlreadyExistsException {
+ Instant now = Instant.now();
+ long expiry = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond();
+ dynamoDBPersistenceStore.putRecord(new DataRecord("key", DataRecord.Status.COMPLETED, expiry, null, null), now);
+
+ key = Collections.singletonMap("id", AttributeValue.builder().s("key").build());
+ Map item = client.getItem(GetItemRequest.builder().tableName(TABLE_NAME).key(key).build()).item();
+ assertThat(item).isNotNull();
+ assertThat(item.get("status").s()).isEqualTo("COMPLETED");
+ assertThat(item.get("expiration").n()).isEqualTo(String.valueOf(expiry));
+ }
+
+ @Test
+ public void putRecord_shouldThrowIdempotencyItemAlreadyExistsException_IfRecordAlreadyExist() {
+ key = Collections.singletonMap("id", AttributeValue.builder().s("key").build());
+
+ // GIVEN: Insert a fake item with same id
+ Map item = new HashMap<>(key);
+ Instant now = Instant.now();
+ long expiry = now.plus(30, ChronoUnit.SECONDS).getEpochSecond();
+ item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build());
+ item.put("status", AttributeValue.builder().s(DataRecord.Status.COMPLETED.toString()).build());
+ item.put("data", AttributeValue.builder().s("Fake Data").build());
+ client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build());
+
+ // WHEN: call putRecord
+ long expiry2 = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond();
+ assertThatThrownBy(() -> dynamoDBPersistenceStore.putRecord(
+ new DataRecord("key",
+ DataRecord.Status.INPROGRESS,
+ expiry2,
+ null,
+ null
+ ), now)
+ ).isInstanceOf(IdempotencyItemAlreadyExistsException.class);
+
+ // THEN: item was not updated, retrieve the initial one
+ Map itemInDb = client.getItem(GetItemRequest.builder().tableName(TABLE_NAME).key(key).build()).item();
+ assertThat(itemInDb).isNotNull();
+ assertThat(itemInDb.get("status").s()).isEqualTo("COMPLETED");
+ assertThat(itemInDb.get("expiration").n()).isEqualTo(String.valueOf(expiry));
+ assertThat(itemInDb.get("data").s()).isEqualTo("Fake Data");
+ }
+
+ //
+ // =================================================================
+
+ // =================================================================
+ //
+
+ @Test
+ public void getRecord_shouldReturnExistingRecord() throws IdempotencyItemNotFoundException {
+ key = Collections.singletonMap("id", AttributeValue.builder().s("key").build());
+
+ // GIVEN: Insert a fake item with same id
+ Map item = new HashMap<>(key);
+ Instant now = Instant.now();
+ long expiry = now.plus(30, ChronoUnit.SECONDS).getEpochSecond();
+ item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build());
+ item.put("status", AttributeValue.builder().s(DataRecord.Status.COMPLETED.toString()).build());
+ item.put("data", AttributeValue.builder().s("Fake Data").build());
+ client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build());
+
+ // WHEN
+ DataRecord record = dynamoDBPersistenceStore.getRecord("key");
+
+ // THEN
+ assertThat(record.getIdempotencyKey()).isEqualTo("key");
+ assertThat(record.getStatus()).isEqualTo(DataRecord.Status.COMPLETED);
+ assertThat(record.getResponseData()).isEqualTo("Fake Data");
+ assertThat(record.getExpiryTimestamp()).isEqualTo(expiry);
+ }
+
+ @Test
+ public void getRecord_shouldThrowException_whenRecordIsAbsent() {
+ assertThatThrownBy(() -> dynamoDBPersistenceStore.getRecord("key")).isInstanceOf(IdempotencyItemNotFoundException.class);
+ }
+
+ //
+ // =================================================================
+
+ // =================================================================
+ //
+
+ @Test
+ public void updateRecord_shouldUpdateRecord() {
+ // GIVEN: Insert a fake item with same id
+ key = Collections.singletonMap("id", AttributeValue.builder().s("key").build());
+ Map item = new HashMap<>(key);
+ Instant now = Instant.now();
+ long expiry = now.plus(360, ChronoUnit.SECONDS).getEpochSecond();
+ item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build());
+ item.put("status", AttributeValue.builder().s(DataRecord.Status.INPROGRESS.toString()).build());
+ client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build());
+ // enable payload validation
+ dynamoDBPersistenceStore.configure(IdempotencyConfig.builder().withPayloadValidationJMESPath("path").build(), null);
+
+ // WHEN
+ expiry = now.plus(3600, ChronoUnit.SECONDS).getEpochSecond();
+ DataRecord record = new DataRecord("key", DataRecord.Status.COMPLETED, expiry, "Fake result", "hash");
+ dynamoDBPersistenceStore.updateRecord(record);
+
+ // THEN
+ Map itemInDb = client.getItem(GetItemRequest.builder().tableName(TABLE_NAME).key(key).build()).item();
+ assertThat(itemInDb.get("status").s()).isEqualTo("COMPLETED");
+ assertThat(itemInDb.get("expiration").n()).isEqualTo(String.valueOf(expiry));
+ assertThat(itemInDb.get("data").s()).isEqualTo("Fake result");
+ assertThat(itemInDb.get("validation").s()).isEqualTo("hash");
+ }
+
+ //
+ // =================================================================
+
+ // =================================================================
+ //
+
+ @Test
+ public void deleteRecord_shouldDeleteRecord() {
+ // GIVEN: Insert a fake item with same id
+ key = Collections.singletonMap("id", AttributeValue.builder().s("key").build());
+ Map item = new HashMap<>(key);
+ Instant now = Instant.now();
+ long expiry = now.plus(360, ChronoUnit.SECONDS).getEpochSecond();
+ item.put("expiration", AttributeValue.builder().n(String.valueOf(expiry)).build());
+ item.put("status", AttributeValue.builder().s(DataRecord.Status.INPROGRESS.toString()).build());
+ client.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build());
+ assertThat(client.scan(ScanRequest.builder().tableName(TABLE_NAME).build()).count()).isEqualTo(1);
+
+ // WHEN
+ dynamoDBPersistenceStore.deleteRecord("key");
+
+ // THEN
+ assertThat(client.scan(ScanRequest.builder().tableName(TABLE_NAME).build()).count()).isEqualTo(0);
+ }
+
+ //
+ // =================================================================
+
+ @Test
+ public void endToEndWithCustomAttrNamesAndSortKey() throws IdempotencyItemNotFoundException {
+ try {
+ client.createTable(CreateTableRequest.builder()
+ .tableName(TABLE_NAME_CUSTOM)
+ .keySchema(
+ KeySchemaElement.builder().keyType(KeyType.HASH).attributeName("key").build(),
+ KeySchemaElement.builder().keyType(KeyType.RANGE).attributeName("sortkey").build()
+ )
+ .attributeDefinitions(
+ AttributeDefinition.builder().attributeName("key").attributeType(ScalarAttributeType.S).build(),
+ AttributeDefinition.builder().attributeName("sortkey").attributeType(ScalarAttributeType.S).build()
+ )
+ .billingMode(BillingMode.PAY_PER_REQUEST)
+ .build());
+
+ DynamoDBPersistenceStore persistenceStore = DynamoDBPersistenceStore.builder()
+ .withTableName(TABLE_NAME_CUSTOM)
+ .withDynamoDbClient(client)
+ .withDataAttr("result")
+ .withExpiryAttr("expiry")
+ .withKeyAttr("key")
+ .withSortKeyAttr("sortkey")
+ .withStaticPkValue("pk")
+ .withStatusAttr("state")
+ .withValidationAttr("valid")
+ .build();
+
+ Instant now = Instant.now();
+ DataRecord record = new DataRecord(
+ "mykey",
+ DataRecord.Status.INPROGRESS,
+ now.plus(400, ChronoUnit.SECONDS).getEpochSecond(),
+ null,
+ null
+ );
+ // PUT
+ persistenceStore.putRecord(record, now);
+
+ Map customKey = new HashMap<>();
+ customKey.put("key", AttributeValue.builder().s("pk").build());
+ customKey.put("sortkey", AttributeValue.builder().s("mykey").build());
+
+ Map itemInDb = client.getItem(GetItemRequest.builder().tableName(TABLE_NAME_CUSTOM).key(customKey).build()).item();
+
+ // GET
+ DataRecord recordInDb = persistenceStore.getRecord("mykey");
+
+ assertThat(itemInDb).isNotNull();
+ assertThat(itemInDb.get("key").s()).isEqualTo("pk");
+ assertThat(itemInDb.get("sortkey").s()).isEqualTo(recordInDb.getIdempotencyKey());
+ assertThat(itemInDb.get("state").s()).isEqualTo(recordInDb.getStatus().toString());
+ assertThat(itemInDb.get("expiry").n()).isEqualTo(String.valueOf(recordInDb.getExpiryTimestamp()));
+
+ // UPDATE
+ DataRecord updatedRecord = new DataRecord(
+ "mykey",
+ DataRecord.Status.COMPLETED,
+ now.plus(500, ChronoUnit.SECONDS).getEpochSecond(),
+ "response",
+ null
+ );
+ persistenceStore.updateRecord(updatedRecord);
+ recordInDb = persistenceStore.getRecord("mykey");
+ assertThat(recordInDb).isEqualTo(updatedRecord);
+
+ // DELETE
+ persistenceStore.deleteRecord("mykey");
+ assertThat(client.scan(ScanRequest.builder().tableName(TABLE_NAME_CUSTOM).build()).count()).isEqualTo(0);
+
+ } finally {
+ try {
+ client.deleteTable(DeleteTableRequest.builder().tableName(TABLE_NAME_CUSTOM).build());
+ } catch (Exception e) {
+ // OK
+ }
+ }
+ }
+
+ @BeforeEach
+ public void setup() {
+ dynamoDBPersistenceStore = DynamoDBPersistenceStore.builder()
+ .withTableName(TABLE_NAME)
+ .withDynamoDbClient(client)
+ .build();
+ }
+
+ @AfterEach
+ public void emptyDB() {
+ if (key != null) {
+ client.deleteItem(DeleteItemRequest.builder().tableName(TABLE_NAME).key(key).build());
+ key = null;
+ }
+ }
+}
diff --git a/powertools-idempotency/src/test/resources/apigw_event.json b/powertools-idempotency/src/test/resources/apigw_event.json
new file mode 100644
index 000000000..4f5f95db0
--- /dev/null
+++ b/powertools-idempotency/src/test/resources/apigw_event.json
@@ -0,0 +1,62 @@
+{
+ "body": "{\"message\": \"Lambda rocks\", \"id\": 43876123454654}",
+ "resource": "/{proxy+}",
+ "path": "/path/to/resource",
+ "httpMethod": "POST",
+ "isBase64Encoded": false,
+ "queryStringParameters": {
+ "foo": "bar"
+ },
+ "pathParameters": {
+ "proxy": "/path/to/resource"
+ },
+ "stageVariables": {
+ "baz": "qux"
+ },
+ "headers": {
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
+ "Accept-Encoding": "gzip, deflate, sdch",
+ "Accept-Language": "en-US,en;q=0.8",
+ "Cache-Control": "max-age=0",
+ "CloudFront-Forwarded-Proto": "https",
+ "CloudFront-Is-Desktop-Viewer": "true",
+ "CloudFront-Is-Mobile-Viewer": "false",
+ "CloudFront-Is-SmartTV-Viewer": "false",
+ "CloudFront-Is-Tablet-Viewer": "false",
+ "CloudFront-Viewer-Country": "US",
+ "Host": "1234567890.execute-api.us-east-1.amazonaws.com",
+ "Upgrade-Insecure-Requests": "1",
+ "User-Agent": "Custom User Agent String",
+ "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
+ "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==",
+ "X-Forwarded-For": "127.0.0.1, 127.0.0.2",
+ "X-Forwarded-Port": "443",
+ "X-Forwarded-Proto": "https"
+ },
+ "requestContext": {
+ "accountId": "123456789012",
+ "resourceId": "123456",
+ "stage": "prod",
+ "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
+ "requestTime": "09/Apr/2015:12:34:56 +0000",
+ "requestTimeEpoch": 1428582896000,
+ "identity": {
+ "cognitoIdentityPoolId": null,
+ "accountId": null,
+ "cognitoIdentityId": null,
+ "caller": null,
+ "accessKey": null,
+ "sourceIp": "127.0.0.1",
+ "cognitoAuthenticationType": null,
+ "cognitoAuthenticationProvider": null,
+ "userArn": null,
+ "userAgent": "Custom User Agent String",
+ "user": null
+ },
+ "path": "/prod/path/to/resource",
+ "resourcePath": "/{proxy+}",
+ "httpMethod": "POST",
+ "apiId": "1234567890",
+ "protocol": "HTTP/1.1"
+ }
+}
diff --git a/powertools-idempotency/src/test/resources/apigw_event2.json b/powertools-idempotency/src/test/resources/apigw_event2.json
new file mode 100644
index 000000000..a313815c1
--- /dev/null
+++ b/powertools-idempotency/src/test/resources/apigw_event2.json
@@ -0,0 +1,62 @@
+{
+ "body": "{\"address\": \"https://checkip.amazonaws.com\"}",
+ "resource": "/{proxy+}",
+ "path": "/path/to/resource",
+ "httpMethod": "POST",
+ "isBase64Encoded": false,
+ "queryStringParameters": {
+ "foo": "bar"
+ },
+ "pathParameters": {
+ "proxy": "/path/to/resource"
+ },
+ "stageVariables": {
+ "baz": "qux"
+ },
+ "headers": {
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
+ "Accept-Encoding": "gzip, deflate, sdch",
+ "Accept-Language": "en-US,en;q=0.8",
+ "Cache-Control": "max-age=0",
+ "CloudFront-Forwarded-Proto": "https",
+ "CloudFront-Is-Desktop-Viewer": "true",
+ "CloudFront-Is-Mobile-Viewer": "false",
+ "CloudFront-Is-SmartTV-Viewer": "false",
+ "CloudFront-Is-Tablet-Viewer": "false",
+ "CloudFront-Viewer-Country": "US",
+ "Host": "1234567890.execute-api.us-east-1.amazonaws.com",
+ "Upgrade-Insecure-Requests": "1",
+ "User-Agent": "Custom User Agent String",
+ "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
+ "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==",
+ "X-Forwarded-For": "127.0.0.1, 127.0.0.2",
+ "X-Forwarded-Port": "443",
+ "X-Forwarded-Proto": "https"
+ },
+ "requestContext": {
+ "accountId": "123456789012",
+ "resourceId": "123456",
+ "stage": "prod",
+ "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
+ "requestTime": "09/Apr/2015:12:34:56 +0000",
+ "requestTimeEpoch": 1428582896000,
+ "identity": {
+ "cognitoIdentityPoolId": null,
+ "accountId": null,
+ "cognitoIdentityId": null,
+ "caller": null,
+ "accessKey": null,
+ "sourceIp": "127.0.0.1",
+ "cognitoAuthenticationType": null,
+ "cognitoAuthenticationProvider": null,
+ "userArn": null,
+ "userAgent": "Custom User Agent String",
+ "user": null
+ },
+ "path": "/prod/path/to/resource",
+ "resourcePath": "/{proxy+}",
+ "httpMethod": "POST",
+ "apiId": "1234567890",
+ "protocol": "HTTP/1.1"
+ }
+}
diff --git a/powertools-serialization/pom.xml b/powertools-serialization/pom.xml
new file mode 100644
index 000000000..e4192d66a
--- /dev/null
+++ b/powertools-serialization/pom.xml
@@ -0,0 +1,75 @@
+
+
+ 4.0.0
+
+
+ powertools-parent
+ software.amazon.lambda
+ 1.10.3
+
+
+ powertools-serialization
+ jar
+
+ AWS Lambda Powertools Java library Serialization Utilities
+
+
+
+ https://aws.amazon.com/lambda/
+
+ GitHub Issues
+ https://github.com/awslabs/aws-lambda-powertools-java/issues
+
+
+ https://github.com/awslabs/aws-lambda-powertools-java.git
+
+
+
+ AWS Lambda Powertools team
+ Amazon Web Services
+ https://aws.amazon.com/
+
+
+
+
+
+ ossrh
+ https://aws.oss.sonatype.org/content/repositories/snapshots
+
+
+
+
+
+ io.burt
+ jmespath-jackson
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+
+
+
+ org.codehaus.mojo
+ aspectj-maven-plugin
+ ${aspectj-maven-plugin.version}
+
+ true
+
+
+
+
+
+
\ No newline at end of file
diff --git a/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java
new file mode 100644
index 000000000..c3a5fc865
--- /dev/null
+++ b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/JsonConfig.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.utilities;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.burt.jmespath.JmesPath;
+import io.burt.jmespath.RuntimeConfiguration;
+import io.burt.jmespath.function.BaseFunction;
+import io.burt.jmespath.function.FunctionRegistry;
+import io.burt.jmespath.jackson.JacksonRuntime;
+import software.amazon.lambda.powertools.utilities.jmespath.Base64Function;
+import software.amazon.lambda.powertools.utilities.jmespath.Base64GZipFunction;
+import software.amazon.lambda.powertools.utilities.jmespath.JsonFunction;
+
+import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;
+
+public class JsonConfig {
+ private JsonConfig() {
+ }
+
+ private static class ConfigHolder {
+ private final static JsonConfig instance = new JsonConfig();
+ }
+
+ public static JsonConfig get() {
+ return ConfigHolder.instance;
+ }
+
+ private static final ThreadLocal om = ThreadLocal.withInitial(() -> {
+ ObjectMapper objectMapper = new ObjectMapper();
+ objectMapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
+ return objectMapper;
+ });
+
+ private final FunctionRegistry defaultFunctions = FunctionRegistry.defaultRegistry();
+ private final FunctionRegistry customFunctions = defaultFunctions.extend(
+ new Base64Function(),
+ new Base64GZipFunction(),
+ new JsonFunction()
+ );
+ private final RuntimeConfiguration configuration = new RuntimeConfiguration.Builder()
+ .withFunctionRegistry(customFunctions)
+ .build();
+ private JmesPath jmesPath = new JacksonRuntime(configuration, getObjectMapper());
+
+ /**
+ * Return an Object Mapper. Use this to customize (de)serialization config.
+ *
+ * @return the {@link ObjectMapper} to serialize / deserialize JSON
+ */
+ public ObjectMapper getObjectMapper() {
+ return om.get();
+ }
+
+ /**
+ * Return the JmesPath used to select sub node of Json
+ *
+ * @return the {@link JmesPath}
+ */
+ public JmesPath getJmesPath() {
+ return jmesPath;
+ }
+
+ /**
+ * Add a custom {@link io.burt.jmespath.function.Function} to JMESPath
+ * {@link Base64Function} and {@link Base64GZipFunction} are already built-in.
+ *
+ * @param function the function to add
+ * @param Must extends {@link BaseFunction}
+ */
+ public void addFunction(T function) {
+ FunctionRegistry functionRegistryWithExtendedFunctions = configuration.functionRegistry().extend(function);
+
+ RuntimeConfiguration updatedConfig = new RuntimeConfiguration.Builder()
+ .withFunctionRegistry(functionRegistryWithExtendedFunctions)
+ .build();
+
+ jmesPath = new JacksonRuntime(updatedConfig, getObjectMapper());
+ }
+}
diff --git a/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/jmespath/Base64Function.java b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/jmespath/Base64Function.java
similarity index 94%
rename from powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/jmespath/Base64Function.java
rename to powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/jmespath/Base64Function.java
index c5693f8a7..737d96835 100644
--- a/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/jmespath/Base64Function.java
+++ b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/jmespath/Base64Function.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 Amazon.com, Inc. or its affiliates.
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
* 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
@@ -11,11 +11,7 @@
* limitations under the License.
*
*/
-package software.amazon.lambda.powertools.validation.jmespath;
-
-import java.nio.ByteBuffer;
-import java.util.Base64;
-import java.util.List;
+package software.amazon.lambda.powertools.utilities.jmespath;
import io.burt.jmespath.Adapter;
import io.burt.jmespath.JmesPathType;
@@ -23,6 +19,10 @@
import io.burt.jmespath.function.BaseFunction;
import io.burt.jmespath.function.FunctionArgument;
+import java.nio.ByteBuffer;
+import java.util.Base64;
+import java.util.List;
+
import static java.nio.charset.StandardCharsets.UTF_8;
/**
diff --git a/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/jmespath/Base64GZipFunction.java b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/jmespath/Base64GZipFunction.java
similarity index 92%
rename from powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/jmespath/Base64GZipFunction.java
rename to powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/jmespath/Base64GZipFunction.java
index bd4b338c4..6b097af62 100644
--- a/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/jmespath/Base64GZipFunction.java
+++ b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/jmespath/Base64GZipFunction.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 Amazon.com, Inc. or its affiliates.
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
* 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
@@ -11,7 +11,13 @@
* limitations under the License.
*
*/
-package software.amazon.lambda.powertools.validation.jmespath;
+package software.amazon.lambda.powertools.utilities.jmespath;
+
+import io.burt.jmespath.Adapter;
+import io.burt.jmespath.JmesPathType;
+import io.burt.jmespath.function.ArgumentConstraints;
+import io.burt.jmespath.function.BaseFunction;
+import io.burt.jmespath.function.FunctionArgument;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
@@ -21,14 +27,8 @@
import java.util.List;
import java.util.zip.GZIPInputStream;
-import io.burt.jmespath.Adapter;
-import io.burt.jmespath.JmesPathType;
-import io.burt.jmespath.function.ArgumentConstraints;
-import io.burt.jmespath.function.BaseFunction;
-import io.burt.jmespath.function.FunctionArgument;
-
import static java.nio.charset.StandardCharsets.UTF_8;
-import static software.amazon.lambda.powertools.validation.jmespath.Base64Function.decode;
+import static software.amazon.lambda.powertools.utilities.jmespath.Base64Function.decode;
/**
* Function used by JMESPath to decode a Base64 encoded GZipped String into a decoded String
diff --git a/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/jmespath/JsonFunction.java b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/jmespath/JsonFunction.java
new file mode 100644
index 000000000..584b544bf
--- /dev/null
+++ b/powertools-serialization/src/main/java/software/amazon/lambda/powertools/utilities/jmespath/JsonFunction.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2022 Amazon.com, Inc. or its affiliates.
+ * 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
+ * http://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 software.amazon.lambda.powertools.utilities.jmespath;
+
+import io.burt.jmespath.Adapter;
+import io.burt.jmespath.JmesPathType;
+import io.burt.jmespath.function.ArgumentConstraints;
+import io.burt.jmespath.function.BaseFunction;
+import io.burt.jmespath.function.FunctionArgument;
+
+import java.util.List;
+
+public class JsonFunction extends BaseFunction {
+
+ public JsonFunction() {
+ super("powertools_json", ArgumentConstraints.typeOf(JmesPathType.STRING));
+ }
+
+ @Override
+ protected T callFunction(Adapter runtime, List> arguments) {
+ T value = arguments.get(0).value();
+ String jsonString = runtime.toString(value);
+ return runtime.parseString(jsonString);
+ }
+}
diff --git a/powertools-validation/src/test/java/software/amazon/lambda/powertools/validation/Base64FunctionTest.java b/powertools-serialization/src/test/java/software/amazon/lambda/powertools/utilities/jmespath/Base64FunctionTest.java
similarity index 77%
rename from powertools-validation/src/test/java/software/amazon/lambda/powertools/validation/Base64FunctionTest.java
rename to powertools-serialization/src/test/java/software/amazon/lambda/powertools/utilities/jmespath/Base64FunctionTest.java
index b9bbd6f88..5f243537c 100644
--- a/powertools-validation/src/test/java/software/amazon/lambda/powertools/validation/Base64FunctionTest.java
+++ b/powertools-serialization/src/test/java/software/amazon/lambda/powertools/utilities/jmespath/Base64FunctionTest.java
@@ -11,12 +11,13 @@
* limitations under the License.
*
*/
-package software.amazon.lambda.powertools.validation;
+package software.amazon.lambda.powertools.utilities.jmespath;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import io.burt.jmespath.Expression;
import org.junit.jupiter.api.Test;
+import software.amazon.lambda.powertools.utilities.JsonConfig;
import java.io.IOException;
@@ -26,8 +27,8 @@ public class Base64FunctionTest {
@Test
public void testPowertoolsBase64() throws IOException {
- JsonNode event = ValidationConfig.get().getObjectMapper().readTree(this.getClass().getResourceAsStream("/custom_event.json"));
- Expression expression = ValidationConfig.get().getJmesPath().compile("basket.powertools_base64(hiddenProduct)");
+ JsonNode event = JsonConfig.get().getObjectMapper().readTree(this.getClass().getResourceAsStream("/custom_event.json"));
+ Expression expression = JsonConfig.get().getJmesPath().compile("basket.powertools_base64(hiddenProduct)");
JsonNode result = expression.search(event);
assertThat(result.getNodeType()).isEqualTo(JsonNodeType.STRING);
assertThat(result.asText()).isEqualTo("{\n" +
diff --git a/powertools-validation/src/test/java/software/amazon/lambda/powertools/validation/Base64GZipFunctionTest.java b/powertools-serialization/src/test/java/software/amazon/lambda/powertools/utilities/jmespath/Base64GZipFunctionTest.java
similarity index 75%
rename from powertools-validation/src/test/java/software/amazon/lambda/powertools/validation/Base64GZipFunctionTest.java
rename to powertools-serialization/src/test/java/software/amazon/lambda/powertools/utilities/jmespath/Base64GZipFunctionTest.java
index 4fc0e57c5..8c617a634 100644
--- a/powertools-validation/src/test/java/software/amazon/lambda/powertools/validation/Base64GZipFunctionTest.java
+++ b/powertools-serialization/src/test/java/software/amazon/lambda/powertools/utilities/jmespath/Base64GZipFunctionTest.java
@@ -11,12 +11,13 @@
* limitations under the License.
*
*/
-package software.amazon.lambda.powertools.validation;
+package software.amazon.lambda.powertools.utilities.jmespath;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import io.burt.jmespath.Expression;
import org.junit.jupiter.api.Test;
+import software.amazon.lambda.powertools.utilities.JsonConfig;
import java.io.IOException;
@@ -26,8 +27,8 @@ public class Base64GZipFunctionTest {
@Test
public void testPowertoolsGzip() throws IOException {
- JsonNode event = ValidationConfig.get().getObjectMapper().readTree(this.getClass().getResourceAsStream("/custom_event_gzip.json"));
- Expression expression = ValidationConfig.get().getJmesPath().compile("basket.powertools_base64_gzip(hiddenProduct)");
+ JsonNode event = JsonConfig.get().getObjectMapper().readTree(this.getClass().getResourceAsStream("/custom_event_gzip.json"));
+ Expression expression = JsonConfig.get().getJmesPath().compile("basket.powertools_base64_gzip(hiddenProduct)");
JsonNode result = expression.search(event);
assertThat(result.getNodeType()).isEqualTo(JsonNodeType.STRING);
assertThat(result.asText()).isEqualTo("{ \"id\": 43242, \"name\": \"FooBar XY\", \"price\": 258}");
diff --git a/powertools-serialization/src/test/java/software/amazon/lambda/powertools/utilities/jmespath/JsonFunctionTest.java b/powertools-serialization/src/test/java/software/amazon/lambda/powertools/utilities/jmespath/JsonFunctionTest.java
new file mode 100644
index 000000000..4ea4eed35
--- /dev/null
+++ b/powertools-serialization/src/test/java/software/amazon/lambda/powertools/utilities/jmespath/JsonFunctionTest.java
@@ -0,0 +1,34 @@
+package software.amazon.lambda.powertools.utilities.jmespath;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.JsonNodeType;
+import io.burt.jmespath.Expression;
+import org.junit.jupiter.api.Test;
+import software.amazon.lambda.powertools.utilities.JsonConfig;
+
+import java.io.IOException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class JsonFunctionTest {
+
+ @Test
+ public void testJsonFunction() throws IOException {
+ JsonNode event = JsonConfig.get().getObjectMapper().readTree(this.getClass().getResourceAsStream("/custom_event_json.json"));
+ Expression expression = JsonConfig.get().getJmesPath().compile("powertools_json(body)");
+ JsonNode result = expression.search(event);
+ assertThat(result.getNodeType()).isEqualTo(JsonNodeType.OBJECT);
+ assertThat(result.get("message").asText()).isEqualTo("Lambda rocks");
+ assertThat(result.get("list").isArray()).isTrue();
+ assertThat(result.get("list").size()).isEqualTo(2);
+ }
+
+ @Test
+ public void testJsonFunctionChild() throws IOException {
+ JsonNode event = JsonConfig.get().getObjectMapper().readTree(this.getClass().getResourceAsStream("/custom_event_json.json"));
+ Expression expression = JsonConfig.get().getJmesPath().compile("powertools_json(body).list[0].item");
+ JsonNode result = expression.search(event);
+ assertThat(result.getNodeType()).isEqualTo(JsonNodeType.STRING);
+ assertThat(result.asText()).isEqualTo("4gh345h");
+ }
+}
diff --git a/powertools-serialization/src/test/resources/custom_event.json b/powertools-serialization/src/test/resources/custom_event.json
new file mode 100644
index 000000000..13103c434
--- /dev/null
+++ b/powertools-serialization/src/test/resources/custom_event.json
@@ -0,0 +1,12 @@
+{
+ "basket": {
+ "products" : [
+ {
+ "id": 43242,
+ "name": "FooBar XY",
+ "price": 258
+ }
+ ],
+ "hiddenProduct": "ewogICJpZCI6IDQzMjQyLAogICJuYW1lIjogIkZvb0JhciBYWSIsCiAgInByaWNlIjogMjU4Cn0="
+ }
+}
\ No newline at end of file
diff --git a/powertools-serialization/src/test/resources/custom_event_gzip.json b/powertools-serialization/src/test/resources/custom_event_gzip.json
new file mode 100644
index 000000000..d212052d0
--- /dev/null
+++ b/powertools-serialization/src/test/resources/custom_event_gzip.json
@@ -0,0 +1,12 @@
+{
+ "basket": {
+ "products" : [
+ {
+ "id": 43242,
+ "name": "FooBar XY",
+ "price": 258
+ }
+ ],
+ "hiddenProduct": "H4sIAAAAAAAA/6vmUlBQykxRslIwMTYyMdIBcfMSc1OBAkpu+flOiUUKEZFKYOGCosxkkLiRqQVXLQDnWo6bOAAAAA=="
+ }
+}
\ No newline at end of file
diff --git a/powertools-serialization/src/test/resources/custom_event_json.json b/powertools-serialization/src/test/resources/custom_event_json.json
new file mode 100644
index 000000000..edc8fa298
--- /dev/null
+++ b/powertools-serialization/src/test/resources/custom_event_json.json
@@ -0,0 +1,3 @@
+{
+ "body": "{\"message\": \"Lambda rocks\", \"list\":[{\"item\":\"4gh345h\", \"price\":42}, {\"item\":\"45jk6h46\", \"price\":24}]}"
+}
diff --git a/powertools-validation/pom.xml b/powertools-validation/pom.xml
index ba0723e41..aaabe88b7 100644
--- a/powertools-validation/pom.xml
+++ b/powertools-validation/pom.xml
@@ -46,6 +46,10 @@
software.amazon.lambda
powertools-core
+