diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 240752a55..a6da0e37e 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -55,7 +55,7 @@ times with the same parameters**. This makes idempotent operations safe to retry software.amazon.lambda - powertools-idempotency-dynamodb + powertools-idempotency-core @@ -584,6 +584,7 @@ IdempotencyConfig.builder() .withUseLocalCache(true) .withLocalCacheMaxItems(432) .withHashFunction("SHA-256") + .withResponseHook((responseData, dataRecord) -> responseData) .build() ``` @@ -591,13 +592,14 @@ These are the available options for further configuration: | Parameter | Default | Description | |---------------------------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------| -| **EventKeyJMESPath** | `""` | JMESPath expression to extract the idempotency key from the event record. See available [built-in functions](serialization) | +| **EventKeyJMESPath** | `""` | JMESPath expression to extract the idempotency key from the event record. See available [built-in functions](serialization) | | **PayloadValidationJMESPath** | `""` | JMESPath expression to validate whether certain parameters have changed in the event | -| **ThrowOnNoIdempotencyKey** | `False` | Throw exception if no idempotency key was found in the request | +| **ThrowOnNoIdempotencyKey** | `false` | Throw exception if no idempotency key was found in the request | | **ExpirationInSeconds** | 3600 | The number of seconds to wait before a record is expired | | **UseLocalCache** | `false` | Whether to locally cache idempotency results (LRU cache) | | **LocalCacheMaxItems** | 256 | Max number of items to store in local cache | | **HashFunction** | `MD5` | Algorithm to use for calculating hashes, as supported by `java.security.MessageDigest` (eg. SHA-1, SHA-256, ...) | +| **ResponseHook** | `null` | Response hook to apply modifications to idempotent responses | These features are detailed below. @@ -855,6 +857,58 @@ You can extend the `BasePersistenceStore` class and implement the abstract metho For example, the `putRecord` method needs to throw an exception if a non-expired record already exists in the data store with a matching key. +### Manipulating the Idempotent Response + +You can set up a response hook in the Idempotency configuration to manipulate the returned data when an operation is idempotent. The hook function will be called with the current de-serialized response `Object` and the Idempotency `DataRecord`. + +The example below shows how to append an HTTP header to an `APIGatewayProxyResponseEvent`. + +=== "Using an Idempotent Response Hook" + + ```java hl_lines="3-20" + Idempotency.config().withConfig( + IdempotencyConfig.builder() + .withResponseHook((responseData, dataRecord) -> { + if (responseData instanceof APIGatewayProxyResponseEvent) { + APIGatewayProxyResponseEvent proxyResponse = + (APIGatewayProxyResponseEvent) responseData; + final Map headers = new HashMap<>(); + headers.putAll(proxyResponse.getHeaders()); + // Append idempotency headers + headers.put("x-idempotency-response", "true"); + headers.put("x-idempotency-expiration", + String.valueOf(dataRecord.getExpiryTimestamp())); + + proxyResponse.setHeaders(headers); + + return proxyResponse; + } + + return responseData; + }) + .build()) + .withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("IDEMPOTENCY_TABLE")) + .build()) + .configure(); + ``` + +???+ info "Info: Using custom de-serialization?" + + The response hook is called after de-serialization so the payload you process will be the de-serialized Java object. + +#### Being a good citizen + +When using response hooks to manipulate returned data from idempotent operations, it's important to follow best practices to avoid introducing complexity or issues. Keep these guidelines in mind: + +1. **Response hook works exclusively when operations are idempotent.** The hook will not be called when an operation is not idempotent, or when the idempotent logic fails. + +2. **Catch and Handle Exceptions.** Your response hook code should catch and handle any exceptions that may arise from your logic. Unhandled exceptions will cause the Lambda function to fail unexpectedly. + +3. **Keep Hook Logic Simple** Response hooks should consist of minimal and straightforward logic for manipulating response data. Avoid complex conditional branching and aim for hooks that are easy to reason about. + + ## Compatibility with other utilities ### Validation utility diff --git a/examples/powertools-examples-idempotency/src/main/java/helloworld/App.java b/examples/powertools-examples-idempotency/src/main/java/helloworld/App.java index 0c4693230..029877c73 100644 --- a/examples/powertools-examples-idempotency/src/main/java/helloworld/App.java +++ b/examples/powertools-examples-idempotency/src/main/java/helloworld/App.java @@ -45,20 +45,36 @@ public App() { public App(DynamoDbClient client) { Idempotency.config().withConfig( - IdempotencyConfig.builder() - .withEventKeyJMESPath( - "powertools_json(body).address") // will retrieve the address field in the body which is a string transformed to json with `powertools_json` - .build()) + IdempotencyConfig.builder() + .withEventKeyJMESPath("powertools_json(body).address") + .withResponseHook((responseData, dataRecord) -> { + if (responseData instanceof APIGatewayProxyResponseEvent) { + APIGatewayProxyResponseEvent proxyResponse = (APIGatewayProxyResponseEvent) responseData; + final Map headers = new HashMap<>(); + headers.putAll(proxyResponse.getHeaders()); + headers.put("x-idempotency-response", "true"); + headers.put("x-idempotency-expiration", + String.valueOf(dataRecord.getExpiryTimestamp())); + + proxyResponse.setHeaders(headers); + + return proxyResponse; + } + + return responseData; + }) + .build()) .withPersistenceStore( DynamoDBPersistenceStore.builder() .withDynamoDbClient(client) .withTableName(System.getenv("IDEMPOTENCY_TABLE")) - .build() - ).configure(); + .build()) + .configure(); } /** - * This is our Lambda event handler. It accepts HTTP POST requests from API gateway and returns the contents of the given URL. Requests are made idempotent + * This is your Lambda event handler. It accepts HTTP POST requests from API gateway and returns the contents of the + * given URL. Requests are made idempotent * by the idempotency library, and results are cached for the default 1h expiry time. *

* You can test the endpoint like this: @@ -67,8 +83,10 @@ public App(DynamoDbClient client) { * curl -X POST https://[REST-API-ID].execute-api.[REGION].amazonaws.com/Prod/helloidem/ -H "Content-Type: application/json" -d '{"address": "https://checkip.amazonaws.com"}' * *

*/ @Idempotent // The magic is here! @@ -101,14 +119,14 @@ public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEv } } - /** * Helper to retrieve the contents of the given URL and return them as a string. *

* We could also put the @Idempotent annotation here if we only wanted this sub-operation to be idempotent. Putting * it on the handler, however, reduces total execution time and saves us time! * - * @param address The URL to fetch + * @param address + * The URL to fetch * @return The contents of the given URL * @throws IOException */ @@ -118,4 +136,4 @@ private String getPageContents(String address) throws IOException { return br.lines().collect(Collectors.joining(System.lineSeparator())); } } -} \ No newline at end of file +} diff --git a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java index 2b22cac51..9d5c66cac 100644 --- a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java +++ b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/IdempotencyConfig.java @@ -14,10 +14,13 @@ package software.amazon.lambda.powertools.idempotency; +import java.time.Duration; +import java.util.function.BiFunction; + import com.amazonaws.services.lambda.runtime.Context; -import software.amazon.lambda.powertools.idempotency.internal.cache.LRUCache; -import java.time.Duration; +import software.amazon.lambda.powertools.idempotency.internal.cache.LRUCache; +import software.amazon.lambda.powertools.idempotency.persistence.DataRecord; /** * Configuration of the idempotency feature. Use the {@link Builder} to create an instance. @@ -30,11 +33,12 @@ public class IdempotencyConfig { private final String payloadValidationJMESPath; private final boolean throwOnNoIdempotencyKey; private final String hashFunction; + private final BiFunction responseHook; private Context lambdaContext; private IdempotencyConfig(String eventKeyJMESPath, String payloadValidationJMESPath, - boolean throwOnNoIdempotencyKey, boolean useLocalCache, int localCacheMaxItems, - long expirationInSeconds, String hashFunction) { + boolean throwOnNoIdempotencyKey, boolean useLocalCache, int localCacheMaxItems, + long expirationInSeconds, String hashFunction, BiFunction responseHook) { this.localCacheMaxItems = localCacheMaxItems; this.useLocalCache = useLocalCache; this.expirationInSeconds = expirationInSeconds; @@ -42,6 +46,7 @@ private IdempotencyConfig(String eventKeyJMESPath, String payloadValidationJMESP this.payloadValidationJMESPath = payloadValidationJMESPath; this.throwOnNoIdempotencyKey = throwOnNoIdempotencyKey; this.hashFunction = hashFunction; + this.responseHook = responseHook; } /** @@ -89,6 +94,10 @@ public void setLambdaContext(Context lambdaContext) { this.lambdaContext = lambdaContext; } + public BiFunction getResponseHook() { + return responseHook; + } + public static class Builder { private int localCacheMaxItems = 256; @@ -98,14 +107,18 @@ public static class Builder { private String payloadValidationJMESPath; private boolean throwOnNoIdempotencyKey = false; private String hashFunction = "MD5"; + private BiFunction responseHook; /** * 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();
          * 
@@ -120,13 +133,15 @@ public IdempotencyConfig build() { useLocalCache, localCacheMaxItems, expirationInSeconds, - hashFunction); + hashFunction, + responseHook); } /** * A JMESPath expression to extract the idempotency key from the event record.
* See https://jmespath.org/ for more details.
- * Common paths are: