diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e59564080..eea26d905 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,10 +7,12 @@ on: paths: - 'powertools-cloudformation/**' - 'powertools-core/**' + - 'powertools-serialization/**' - 'powertools-logging/**' - 'powertools-sqs/**' - 'powertools-tracing/**' - 'powertools-validation/**' + - 'powertools-idempotency/**' - 'powertools-parameters/**' - 'powertools-metrics/**' - 'powertools-test-suite/**' @@ -22,10 +24,12 @@ on: paths: - 'powertools-cloudformation/**' - 'powertools-core/**' + - 'powertools-serialization/**' - 'powertools-logging/**' - 'powertools-sqs/**' - 'powertools-tracing/**' - 'powertools-validation/**' + - 'powertools-idempotency/**' - 'powertools-parameters/**' - 'powertools-metrics/**' - 'powertools-test-suite/**' diff --git a/.github/workflows/spotbugs.yml b/.github/workflows/spotbugs.yml index 8976c5042..7955f4533 100644 --- a/.github/workflows/spotbugs.yml +++ b/.github/workflows/spotbugs.yml @@ -7,11 +7,13 @@ on: paths: - 'powertools-cloudformation/**' - 'powertools-core/**' + - 'powertools-serialization/**' - 'powertools-logging/**' - 'powertools-sqs/**' - 'powertools-tracing/**' - 'powertools-validation/**' - 'powertools-parameters/**' + - 'powertools-idempotency/**' - 'powertools-metrics/**' - 'powertools-test-suite/**' - 'pom.xml' diff --git a/.gitignore b/.gitignore index 20f4c17fa..12a60ce4d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ hs_err_pid* # Maven build target/ +native-libs/ ###################### # IntelliJ diff --git a/docs/media/idempotent_sequence.png b/docs/media/idempotent_sequence.png new file mode 100644 index 000000000..92593184a Binary files /dev/null and b/docs/media/idempotent_sequence.png differ diff --git a/docs/media/idempotent_sequence_exception.png b/docs/media/idempotent_sequence_exception.png new file mode 100644 index 000000000..4cf065993 Binary files /dev/null and b/docs/media/idempotent_sequence_exception.png differ diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md new file mode 100644 index 000000000..56b9acb78 --- /dev/null +++ b/docs/utilities/idempotency.md @@ -0,0 +1,1006 @@ +--- +title: Idempotency +description: Utility +--- + +The idempotency utility provides a simple solution to convert your Lambda functions into idempotent operations which +are safe to retry. + +## Terminology + +The property of idempotency means that an operation does not cause additional side effects if it is called more than +once with the same input parameters. + +**Idempotent operations will return the same result when they are called multiple +times with the same parameters**. This makes idempotent operations safe to retry. [Read more](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/) about idempotency. + +**Idempotency key** is a hash representation of either the entire event or a specific configured subset of the event, and invocation results are **JSON serialized** and stored in your persistence storage layer. + +## Key features + +* Prevent Lambda handler function from executing more than once on the same event payload during a time window +* Ensure Lambda handler returns the same result when called with the same payload +* Select a subset of the event as the idempotency key using JMESPath expressions +* Set a time window in which records with the same payload should be considered duplicates + +## Getting started + +### Installation +=== "Maven" + ```xml hl_lines="3-7 24-27" + + ... + + software.amazon.lambda + powertools-idempotency + {{ powertools.version }} + + ... + + + + + + ... + + org.codehaus.mojo + aspectj-maven-plugin + 1.14.0 + + 1.8 + 1.8 + 1.8 + + + software.amazon.lambda + powertools-idempotency + + ... + + + + + + compile + + + + + ... + + + ``` + +### Required resources + +Before getting started, you need to create a persistent storage layer where the idempotency utility can store its state - your Lambda functions will need read and write access to it. + +As of now, Amazon DynamoDB is the only supported persistent storage layer, so you'll need to create a table first. + +**Default table configuration** + +If you're not [changing the default configuration for the DynamoDB persistence layer](#dynamodbpersistencestore), this is the expected default configuration: + +| Configuration | Value | Notes | +|--------------------|--------------|-------------------------------------------------------------------------------------| +| Partition key | `id` | | +| TTL attribute name | `expiration` | This can only be configured after your table is created if you're using AWS Console | + +!!! Tip "Tip: You can share a single state table for all functions" + You can reuse the same DynamoDB table to store idempotency state. We add your function name in addition to the idempotency key as a hash key. + +```yaml hl_lines="5-13 21-23 26" title="AWS Serverless Application Model (SAM) example" +Resources: + IdempotencyTable: + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: id + AttributeType: S + KeySchema: + - AttributeName: id + KeyType: HASH + TimeToLiveSpecification: + AttributeName: expiration + Enabled: true + BillingMode: PAY_PER_REQUEST + + IdempotencyFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: Function + Handler: helloworld.App::handleRequest + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref IdempotencyTable + Environment: + Variables: + IDEMPOTENCY_TABLE: !Ref IdempotencyTable +``` + +!!! warning "Warning: Large responses with DynamoDB persistence layer" + When using this utility with DynamoDB, your function's responses must be [smaller than 400KB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-items). + Larger items cannot be written to DynamoDB and will cause exceptions. + +!!! info "Info: DynamoDB" + Each function invocation will generally make 2 requests to DynamoDB. If the + result returned by your Lambda is less than 1kb, you can expect 2 WCUs per invocation. For retried invocations, you will + see 1WCU and 1RCU. Review the [DynamoDB pricing documentation](https://aws.amazon.com/dynamodb/pricing/) to + estimate the cost. + +### Idempotent annotation + +You can quickly start by initializing the `DynamoDBPersistenceStore` and using it with the `@Idempotent` annotation on your Lambda handler. + +!!! warning "Important" + Initialization and configuration of the `DynamoDBPersistenceStore` must be performed outside the handler, preferably in the constructor. + +=== "App.java" + + ```java hl_lines="5-9 12 19" + public class App implements RequestHandler { + + public App() { + // we need to initialize idempotency store before the handleRequest method is called + Idempotency.config().withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("TABLE_NAME")) + .build() + ).configure(); + } + + @Idempotent + public SubscriptionResult handleRequest(final Subscription event, final Context context) { + SubscriptionPayment payment = createSubscriptionPayment( + event.getUsername(), + event.getProductId() + ); + + return new SubscriptionResult(payment.getId(), "success", 200); + } + } + + ``` + +=== "Example event" + + ```json + { + "username": "xyz", + "product_id": "123456789" + } + ``` + +#### Idempotent annotation on another method + +You can use the `@Idempotent` annotation for any synchronous Java function, not only the `handleRequest` one. + +When using `@Idempotent` annotation on another method, you must tell which parameter in the method signature has the data we should use: + + - If the method only has one parameter, it will be used by default. + - If there are 2 or more parameters, you must set the `@IdempotencyKey` on the parameter to use. + +!!! info "The parameter must be serializable in JSON. We use Jackson internally to (de)serialize objects" + +=== "AppSqsEvent.java" + + This example also demonstrates how you can integrate with [Batch utility](batch.md), so you can process each record in an idempotent manner. + + ```java hl_lines="19 23-25 30-31" + public class AppSqsEvent implements RequestHandler { + + public AppSqsEvent() { + Idempotency.config() + .withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("TABLE_NAME")) + .build() + ).withConfig( + IdempotencyConfig.builder() + .withEventKeyJMESPath("messageId") // see Choosing a payload subset section + .build() + ).configure(); + } + + @Override + @SqsBatch(SampleMessageHandler.class) + public String handleRequest(SQSEvent input, Context context) { + dummy("hello", "world"); + return "{\"statusCode\": 200}"; + } + + @Idempotent + private String dummy(String argOne, @IdempotencyKey String argTwo) { + return "something"; + } + + public static class SampleMessageHandler implements SqsMessageHandler { + @Override + @Idempotent + // no need to use @IdempotencyKey as there is only one parameter + public String process(SQSMessage message) { + String returnVal = doSomething(message.getBody()); + return returnVal; + } + } + } + ``` + +=== "Batch event" + + ```json hl_lines="4" + { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": "Test message.", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185" + }, + "messageAttributes": { + "testAttr": { + "stringValue": "100", + "binaryValue": "base64Str", + "dataType": "Number" + } + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2" + } + ] + } + ``` + +### Choosing a payload subset for idempotency + +!!! tip "Tip: Dealing with always changing payloads" + When dealing with an elaborate payload (API Gateway request for example), where parts of the payload always change, you should configure the **`EventKeyJMESPath`**. + +Use [`IdempotencyConfig`](#customizing-the-default-behavior) to instruct the Idempotent annotation to only use a portion of your payload to verify whether a request is idempotent, and therefore it should not be retried. + +> **Payment scenario** + +In this example, we have a Lambda handler that creates a payment for a user subscribing to a product. We want to ensure that we don't accidentally charge our customer by subscribing them more than once. + +Imagine the function executes successfully, but the client never receives the response due to a connection issue. It is safe to retry in this instance, as the idempotent decorator will return a previously saved response. + +!!! warning "Warning: Idempotency for JSON payloads" + The payload extracted by the `EventKeyJMESPath` is treated as a string by default, so will be sensitive to differences in whitespace even when the JSON payload itself is identical. + + To alter this behaviour, you can use the [JMESPath built-in function](utilities.md#powertools_json-function) `powertools_json()` to treat the payload as a JSON object rather than a string. + +=== "PaymentFunction.java" + + ```java hl_lines="5-7 16 29-31" + public class PaymentFunction implements RequestHandler { + + public PaymentFunction() { + Idempotency.config() + .withConfig( + IdempotencyConfig.builder() + .withEventKeyJMESPath("powertools_json(body)") + .build()) + .withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("TABLE_NAME")) + .build()) + .configure(); + } + + @Idempotent + public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent event, final Context context) { + APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent(); + + try { + Subscription subscription = JsonConfig.get().getObjectMapper().readValue(event.getBody(), Subscription.class); + + SubscriptionPayment payment = createSubscriptionPayment( + subscription.getUsername(), + subscription.getProductId() + ); + + return response + .withStatusCode(200) + .withBody(String.format("{\"paymentId\":\"%s\"}", payment.getId())); + + } catch (JsonProcessingException e) { + return response.withStatusCode(500); + } + } + ``` + +=== "Example event" + + ```json hl_lines="3" + { + "version":"2.0", + "body":"{\"username\":\"xyz\",\"productId\":\"123456789\"}", + "routeKey":"ANY /createpayment", + "rawPath":"/createpayment", + "rawQueryString":"", + "headers": { + "Header1": "value1", + "Header2": "value2" + }, + "requestContext":{ + "accountId":"123456789012", + "apiId":"api-id", + "domainName":"id.execute-api.us-east-1.amazonaws.com", + "domainPrefix":"id", + "http":{ + "method":"POST", + "path":"/createpayment", + "protocol":"HTTP/1.1", + "sourceIp":"ip", + "userAgent":"agent" + }, + "requestId":"id", + "routeKey":"ANY /createpayment", + "stage":"$default", + "time":"10/Feb/2021:13:40:43 +0000", + "timeEpoch":1612964443723 + }, + "isBase64Encoded":false + } + ``` + + +### Idempotency request flow + +This sequence diagram shows an example flow of what happens in the payment scenario: + +![Idempotent sequence](../media/idempotent_sequence.png) + +The client was successful in receiving the result after the retry. Since the Lambda handler was only executed once, our customer hasn't been charged twice. + +!!! note + Bear in mind that the entire Lambda handler is treated as a single idempotent operation. If your Lambda handler can cause multiple side effects, consider splitting it into separate functions. + +### Handling exceptions + +If you are using the `@Idempotent` annotation on your Lambda handler or any other method, any unhandled exceptions that are thrown during the code execution will cause **the record in the persistence layer to be deleted**. +This means that new invocations will execute your code again despite having the same payload. If you don't want the record to be deleted, you need to catch exceptions within the idempotent function and return a successful response. + +![Idempotent sequence exception](../media/idempotent_sequence_exception.png) + +If an Exception is raised _outside_ the scope of a decorated method and after your method has been called, the persistent record will not be affected. In this case, idempotency will be maintained for your decorated function. Example: + +```java hl_lines="2-4 8-10" title="Exception not affecting idempotency record sample" + public SubscriptionResult handleRequest(final Subscription event, final Context context) { + // If an exception is thrown here, no idempotent record will ever get created as the + // idempotent function does not get called + doSomeStuff(); + + result = idempotentMethod(event); + + // This exception will not cause the idempotent record to be deleted, since it + // happens after the decorated function has been successfully called + throw new Exception(); + } + + @Idempotent + private String idempotentMethod(final Subscription subscription) { + // perform some operation with no exception thrown + } +``` + +!!! warning + **We will throw an `IdempotencyPersistenceLayerException`** if any of the calls to the persistence layer fail unexpectedly. + + As this happens outside the scope of your decorated function, you are not able to catch it. + +### Persistence stores + +#### DynamoDBPersistenceStore + +This persistence store is built-in, and you can either use an existing DynamoDB table or create a new one dedicated for idempotency state (recommended). + +Use the builder to customize the table structure: +```java hl_lines="3-7" title="Customizing DynamoDBPersistenceStore to suit your table structure" +DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("TABLE_NAME")) + .withKeyAttr("idempotency_key") + .withExpiryAttr("expires_at") + .withStatusAttr("current_status") + .withDataAttr("result_data") + .withValidationAttr("validation_key") + .build() +``` + +When using DynamoDB as a persistence layer, you can alter the attribute names by passing these parameters when initializing the persistence layer: + +| Parameter | Required | Default | Description | +|--------------------|----------|--------------------------------------|--------------------------------------------------------------------------------------------------------| +| **TableName** | Y | | Table name to store state | +| **KeyAttr** | | `id` | Partition key of the table. Hashed representation of the payload (unless **SortKeyAttr** is specified) | +| **ExpiryAttr** | | `expiration` | Unix timestamp of when record expires | +| **StatusAttr** | | `status` | Stores status of the Lambda execution during and after invocation | +| **DataAttr** | | `data` | Stores results of successfully idempotent methods | +| **ValidationAttr** | | `validation` | Hashed representation of the parts of the event used for validation | +| **SortKeyAttr** | | | Sort key of the table (if table is configured with a sort key). | +| **StaticPkValue** | | `idempotency#{LAMBDA_FUNCTION_NAME}` | Static value to use as the partition key. Only used when **SortKeyAttr** is set. | + +## Advanced + +### Customizing the default behavior + +Idempotency behavior can be further configured with **`IdempotencyConfig`** using a builder: + +```java hl_lines="2-8" title="Customizing IdempotencyConfig" +IdempotencyConfig.builder() + .withEventKeyJMESPath("id") + .withPayloadValidationJMESPath("paymentId") + .withThrowOnNoIdempotencyKey(true) + .withExpiration(Duration.of(5, ChronoUnit.MINUTES)) + .withUseLocalCache(true) + .withLocalCacheMaxItems(432) + .withHashFunction("SHA-256") + .build() +``` + +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) | +| **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 | +| **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, ...) | + +These features are detailed below. + +### Handling concurrent executions with the same payload + +This utility will throw an **`IdempotencyAlreadyInProgressException`** if we receive **multiple invocations with the same payload while the first invocation hasn't completed yet**. + +!!! info + If you receive `IdempotencyAlreadyInProgressException`, you can safely retry the operation. + +This is a locking mechanism for correctness. Since we don't know the result from the first invocation yet, we can't safely allow another concurrent execution. + +### Using in-memory cache + +**By default, in-memory local caching is disabled**, to avoid using memory in an unpredictable way. + +!!! warning Memory configuration of your function + Be sure to configure the Lambda memory according to the number of records and the potential size of each record. + +You can enable it as seen before with: +```java title="Enable local cache" + IdempotencyConfig.builder() + .withUseLocalCache(true) + .build() +``` +When enabled, we cache a maximum of 256 records in each Lambda execution environment - You can change it with the **`LocalCacheMaxItems`** parameter. + +!!! note "Note: This in-memory cache is local to each Lambda execution environment" + This means it will be effective in cases where your function's concurrency is low in comparison to the number of "retry" invocations with the same payload, because cache might be empty. + + +### Expiring idempotency records + +!!! note + By default, we expire idempotency records after **an hour** (3600 seconds). + +In most cases, it is not desirable to store the idempotency records forever. Rather, you want to guarantee that the same payload won't be executed within a period of time. + +You can change this window with the **`ExpirationInSeconds`** parameter: +```java title="Customizing expiration time" +IdempotencyConfig.builder() + .withExpiration(Duration.of(5, ChronoUnit.MINUTES)) + .build() +``` + +Records older than 5 minutes will be marked as expired, and the Lambda handler will be executed normally even if it is invoked with a matching payload. + +!!! note "Note: DynamoDB time-to-live field" + This utility uses **`expiration`** as the TTL field in DynamoDB, as [demonstrated in the SAM example earlier](#required-resources). + +### Payload validation + +!!! question "Question: What if your function is invoked with the same payload except some outer parameters have changed?" + Example: A payment transaction for a given productID was requested twice for the same customer, **however the amount to be paid has changed in the second transaction**. + +By default, we will return the same result as it returned before, however in this instance it may be misleading; we provide a fail fast payload validation to address this edge case. + +With **`PayloadValidationJMESPath`**, you can provide an additional JMESPath expression to specify which part of the event body should be validated against previous idempotent invocations + +=== "App.java" + + ```java hl_lines="8 13 20 26" + public App() { + Idempotency.config() + .withPersistenceStore(DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("TABLE_NAME")) + .build()) + .withConfig(IdempotencyConfig.builder() + .withEventKeyJMESPath("[userDetail, productId]") + .withPayloadValidationJMESPath("amount") + .build()) + .configure(); + } + + @Idempotent + public SubscriptionResult handleRequest(final Subscription input, final Context context) { + // Creating a subscription payment is a side + // effect of calling this function! + SubscriptionPayment payment = createSubscriptionPayment( + input.getUserDetail().getUsername(), + input.getProductId(), + input.getAmount() + ) + // ... + return new SubscriptionResult( + "success", 200, + payment.getId(), + payment.getAmount() + ); + } + ``` + +=== "Example Event 1" + + ```json hl_lines="8" + { + "userDetail": { + "username": "User1", + "user_email": "user@example.com" + }, + "productId": 1500, + "charge_type": "subscription", + "amount": 500 + } + ``` + +=== "Example Event 2" + + ```json hl_lines="8" + { + "userDetail": { + "username": "User1", + "user_email": "user@example.com" + }, + "productId": 1500, + "charge_type": "subscription", + "amount": 1 + } + ``` + +In this example, the **`userDetail`** and **`productId`** keys are used as the payload to generate the idempotency key, as per **`EventKeyJMESPath`** parameter. + +!!! note + If we try to send the same request but with a different amount, we will raise **`IdempotencyValidationException`**. + +Without payload validation, we would have returned the same result as we did for the initial request. Since we're also returning an amount in the response, this could be quite confusing for the client. + +By using **`withPayloadValidationJMESPath("amount")`**, we prevent this potentially confusing behavior and instead throw an Exception. + +### Making idempotency key required + +If you want to enforce that an idempotency key is required, you can set **`ThrowOnNoIdempotencyKey`** to `True`. + +This means that we will throw **`IdempotencyKeyException`** if the evaluation of **`EventKeyJMESPath`** is `null`. + +=== "App.java" + + ```java hl_lines="9-10 13" + public App() { + Idempotency.config() + .withPersistenceStore(DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("TABLE_NAME")) + .build()) + .withConfig(IdempotencyConfig.builder() + // Requires "user"."uid" and "orderId" to be present + .withEventKeyJMESPath("[user.uid, orderId]") + .withThrowOnNoIdempotencyKey(true) + .build()) + .configure(); + } + + @Idempotent + public OrderResult handleRequest(final Order input, final Context context) { + // ... + } + ``` + +=== "Success Event" + + ```json hl_lines="3 6" + { + "user": { + "uid": "BB0D045C-8878-40C8-889E-38B3CB0A61B1", + "name": "Foo" + }, + "orderId": 10000 + } + ``` + +=== "Failure Event" + + Notice that `orderId` is now accidentally within `user` key + + ```json hl_lines="3 5" + { + "user": { + "uid": "DE0D000E-1234-10D1-991E-EAC1DD1D52C8", + "name": "Joe Bloggs", + "orderId": 10000 + }, + } + ``` + +### Customizing DynamoDB configuration + +When creating the `DynamoDBPersistenceStore`, you can set a custom [`DynamoDbClient`](https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/services/dynamodb/DynamoDbClient.html) if you need to customize the configuration: + +=== "Custom DynamoDbClient with X-Ray interceptor" + + ```java hl_lines="2-8 13" + public App() { + DynamoDbClient customClient = DynamoDbClient.builder() + .region(Region.US_WEST_2) + .overrideConfiguration(ClientOverrideConfiguration.builder() + .addExecutionInterceptor(new TracingInterceptor()) + .build() + ) + .build(); + + Idempotency.config().withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("TABLE_NAME")) + .withDynamoDbClient(customClient) + .build() + ).configure(); + } + ``` + +!!! info "Default configuration is the following:" + + ```java + DynamoDbClient.builder() + .credentialsProvider(EnvironmentVariableCredentialsProvider.create()) + .httpClient(UrlConnectionHttpClient.builder().build()) + .region(Region.of(System.getenv(AWS_REGION_ENV))) + .build(); + ``` + +### Using a DynamoDB table with a composite primary key + +When using a composite primary key table (hash+range key), use `SortKeyAttr` parameter when initializing your persistence store. + +With this setting, we will save the idempotency key in the sort key instead of the primary key. By default, the primary key will now be set to `idempotency#{LAMBDA_FUNCTION_NAME}`. + +You can optionally set a static value for the partition key using the `StaticPkValue` parameter. + +```java hl_lines="5" title="Reusing a DynamoDB table that uses a composite primary key" +Idempotency.config().withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("TABLE_NAME")) + .withSortKeyAttr("sort_key") + .build()) + .configure(); +``` + +Data would then be stored in DynamoDB like this: + +| id | sort_key | expiration | status | data | +|------------------------------|----------------------------------|------------|-------------|--------------------------------------| +| idempotency#MyLambdaFunction | 1e956ef7da78d0cb890be999aecc0c9e | 1636549553 | COMPLETED | {"id": 12391, "message": "success"} | +| idempotency#MyLambdaFunction | 2b2cdb5f86361e97b4383087c1ffdf27 | 1636549571 | COMPLETED | {"id": 527212, "message": "success"} | +| idempotency#MyLambdaFunction | f091d2527ad1c78f05d54cc3f363be80 | 1636549585 | IN_PROGRESS | | + +### Bring your own persistent store + +This utility provides an abstract base class, so that you can implement your choice of persistent storage layer. + +You can extend the `BasePersistenceStore` class and implement the abstract methods `getRecord`, `putRecord`, +`updateRecord` and `deleteRecord`. You can have a look at [`DynamoDBPersistenceStore`](https://github.com/awslabs/aws-lambda-powertools-java/blob/master/powertools-idempotency/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/DynamoDBPersistenceStore.java) as an implementation reference. + +!!! danger + Pay attention to the documentation for each method - you may need to perform additional checks inside these methods to ensure the idempotency guarantees remain intact. + + 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. + +## Compatibility with other utilities + +### Validation utility + +The idempotency utility can be used with the `@Validation` annotation from the [validation module](validation.md). Ensure that idempotency is the innermost annotation. + +```java hl_lines="1 2" title="Using Idempotency with JSONSchema Validation utility" +@Validation(inboundSchema = "classpath:/schema_in.json") +@Idempotent +public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) { + // ... +} +``` + +!!! tip "Tip: JMESPath Powertools functions are also available" + Built-in functions like `powertools_json`, `powertools_base64`, `powertools_base64_gzip` are also available to use in this utility. See [JMESPath Powertools functions](serialization.md) + + +## Testing your code + +The idempotency utility provides several routes to test your code. + +### Disabling the idempotency utility +When testing your code, you may wish to disable the idempotency logic altogether and focus on testing your business logic. To do this, you can set the environment variable `POWERTOOLS_IDEMPOTENCY_DISABLED` to true. +If you prefer setting this for specific tests, and are using JUnit 5, you can use [junit-pioneer](https://junit-pioneer.org/docs/environment-variables/) library: + +=== "MyFunctionTest.java" + + ```java hl_lines="2" + @Test + @SetEnvironmentVariable(key = Constants.IDEMPOTENCY_DISABLED_ENV, value = "true") + public void testIdempotencyDisabled_shouldJustRunTheFunction() { + MyFunction func = new MyFunction(); + func.handleRequest(someInput, mockedContext); + } + ``` + +You can also disable the idempotency for all tests using `maven-surefire-plugin` and adding the environment variable: + +=== "pom.xml" +```xml hl_lines="5-7" + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + +``` + +### Testing with DynamoDB Local + +#### Unit tests + +To unit test your function with DynamoDB Local, you can refer to this guide to [setup with Maven](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html#apache-maven). + +=== "pom.xml" + + ```xml hl_lines="4-6 24-26 28-31 42 45-47" + + + + com.amazonaws + DynamoDBLocal + [1.12,2.0) + test + + + + io.github.ganadist.sqlite4java + libsqlite4java-osx-aarch64 + 1.0.392 + test + dylib + + + + + + + dynamodb-local-oregon + DynamoDB Local Release Repository + https://s3-us-west-2.amazonaws.com/dynamodb-local/release + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + + ${project.build.directory}/native-libs + + + + idempotency + eu-central-1 + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy + test-compile + + copy-dependencies + + + test + so,dll,dylib + ${project.build.directory}/native-libs + + + + + + ``` + +=== "AppTest.java" + + ```java hl_lines="13-18 24-30 34" + public class AppTest { + @Mock + private Context context; + private App app; + private static DynamoDbClient client; + + @BeforeAll + public static void setupDynamoLocal() { + int port = getFreePort(); + + // Initialize DynamoDBLocal + try { + DynamoDBProxyServer dynamoProxy = ServerRunner.createServerFromCommandLineArgs(new String[]{ + "-inMemory", + "-port", + Integer.toString(port) + }); + dynamoProxy.start(); + } catch (Exception e) { + throw new RuntimeException(); + } + + // Initialize DynamoDBClient + 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(); + + // create the table (use same table name as in pom.xml) + client.createTable(CreateTableRequest.builder() + .tableName("idempotency") + .keySchema(KeySchemaElement.builder().keyType(KeyType.HASH).attributeName("id").build()) + .attributeDefinitions( + AttributeDefinition.builder().attributeName("id").attributeType(ScalarAttributeType.S).build() + ) + .billingMode(BillingMode.PAY_PER_REQUEST) + .build()); + } + + 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); + } + } + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + app = new App(client); + } + + @Test + public void testApp() { + app.handleRequest(..., context); + // ... assert + } + } + ``` + +=== "App.java" + + ```java + public class App implements RequestHandler { + + public App(DynamoDbClient ddbClient) { + Idempotency.config().withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("IDEMPOTENCY_TABLE_NAME")) + .withDynamoDbClient(ddbClient) + .build() + ).configure(); + } + + public App() { + this(null); + } + + @Idempotent + public SubscriptionResult handleRequest(final Subscription event, final Context context) { + // ... + } + ``` + + +#### SAM Local + +=== "App.java" + + ```java hl_lines="8 9 16" + public class App implements RequestHandler { + + public App() { + DynamoDbClientBuilder ddbBuilder = DynamoDbClient.builder() + .credentialsProvider(EnvironmentVariableCredentialsProvider.create()) + .httpClient(UrlConnectionHttpClient.builder().build()); + + if (System.getenv("AWS_SAM_LOCAL") != null) { + ddbBuilder.endpointOverride(URI.create("http://dynamo:8000")); + } else { + ddbBuilder.region(Region.of(System.getenv("AWS_REGION"))); + } + + Idempotency.config().withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("IDEMPOTENCY_TABLE_NAME")) + .withDynamoDbClient(ddbBuilder.build()) + .build() + ).configure(); + } + + @Idempotent + public SubscriptionResult handleRequest(final Subscription event, final Context context) { + // ... + } + } + ``` + +=== "shell" + + ```shell hl_lines="2 6 7 12 16 21 22" + # use or create a docker network + docker network inspect sam-local || docker network create sam-local + + # start dynamodb-local with docker + docker run -d --rm -p 8000:8000 \ + --network sam-local \ + --name dynamo \ + amazon/dynamodb-local + + # create the idempotency table + aws dynamodb create-table + --table-name idempotency \ + --attribute-definitions AttributeName=id,AttributeType=S \ + --key-schema AttributeName=id,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --endpoint-url http://localhost:8000 + + # invoke the function locally + sam local invoke IdempotentFunction \ + --event event.json \ + --env-vars env.json \ + --docker-network sam-local + ``` + +=== "env.json" + + ```json hl_lines="3" + { + "IdempotentFunction": { + "IDEMPOTENCY_TABLE_NAME": "idempotency" + } + } + ``` + +## Extra resources + +If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out +[this article](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/). diff --git a/docs/utilities/serialization.md b/docs/utilities/serialization.md new file mode 100644 index 000000000..19ff00d37 --- /dev/null +++ b/docs/utilities/serialization.md @@ -0,0 +1,232 @@ +--- +title: Serialization Utilities +description: Utility +--- + +This module contains a set of utilities you may use in your Lambda functions, mainly associated with other modules like [validation](validation.md) and [idempotency](idempotency.md), to manipulate JSON. + +## JMESPath functions + +!!! Tip + [JMESPath](https://jmespath.org/){target="_blank"} is a query language for JSON used by AWS CLI and AWS Lambda Powertools for Java to get a specific part of a json. + +### Key features + +* Deserialize JSON from JSON strings, base64, and compressed data +* Use JMESPath to extract and combine data recursively + +### Getting started + +You might have events that contain encoded JSON payloads as string, base64, or even in compressed format. It is a common use case to decode and extract them partially or fully as part of your Lambda function invocation. + +You will generally use this in combination with other Lambda Powertools modules ([validation](validation.md) and [idempotency](idempotency.md)) where you might need to extract a portion of your data before using them. + +### Built-in functions + +Powertools provides the following JMESPath Functions to easily deserialize common encoded JSON payloads in Lambda functions: + +#### powertools_json function + +Use `powertools_json` function to decode any JSON string anywhere a JMESPath expression is allowed. + +Below example use this function to load the content from the body of an API Gateway request event as a JSON object and retrieve the id field in it: + +=== "MyHandler.java" + + ```java hl_lines="7" + public class MyHandler implements RequestHandler { + + public MyHandler() { + Idempotency.config() + .withConfig( + IdempotencyConfig.builder() + .withEventKeyJMESPath("powertools_json(body).id") + .build()) + .withPersistenceStore( + DynamoDBPersistenceStore.builder() + .withTableName(System.getenv("TABLE_NAME")) + .build()) + .configure(); + } + + @Idempotent + public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent event, final Context context) { + } + ``` + +=== "event" + + ```json hl_lines="2" + { + "body": "{\"message\": \"Lambda rocks\", \"id\": 43876123454654}", + "resource": "/{proxy+}", + "path": "/path/to/resource", + "httpMethod": "POST", + "queryStringParameters": { + "foo": "bar" + }, + "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", + }, + "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" + } + } + ``` + +#### powertools_base64 function + +Use `powertools_base64` function to decode any base64 data. + +Below sample will decode the base64 value within the `data` key, and decode the JSON string into a valid JSON before we can validate it. + +=== "MyEventHandler.java" + + ```java hl_lines="7" + import software.amazon.lambda.powertools.validation.ValidationUtils; + + public class MyEventHandler implements RequestHandler { + + @Override + public String handleRequest(MyEvent myEvent, Context context) { + validate(myEvent, "classpath:/schema.json", "powertools_base64(data)"); + return "OK"; + } + } + ``` +=== "schema.json" +```json +{ +"data" : "ewogICJpZCI6IDQzMjQyLAogICJuYW1lIjogIkZvb0JhciBYWSIsCiAgInByaWNlIjogMjU4Cn0=" +} +``` + +#### powertools_base64_gzip function + +Use `powertools_base64_gzip` function to decompress and decode base64 data. + +Below sample will decompress and decode base64 data. + +=== "MyEventHandler.java" + + ```java hl_lines="7" + import software.amazon.lambda.powertools.validation.ValidationUtils; + + public class MyEventHandler implements RequestHandler { + + @Override + public String handleRequest(MyEvent myEvent, Context context) { + validate(myEvent, "classpath:/schema.json", "powertools_base64_gzip(data)"); + return "OK"; + } + } + ``` + +=== "schema.json" + + ```json + { + "data" : "H4sIAAAAAAAA/6vmUlBQykxRslIwMTYyMdIBcfMSc1OBAkpu+flOiUUKEZFKYOGCosxkkLiRqQVXLQDnWo6bOAAAAA==" + } + ``` + + +### Bring your own JMESPath function + +!!! warning + This should only be used for advanced use cases where you have special formats not covered by the built-in functions. + Please open an issue in Github if you need us to add some common functions. + +Your function must extend `io.burt.jmespath.function.BaseFunction`, take a String as parameter and return a String. +You can read the [doc](https://github.com/burtcorp/jmespath-java#adding-custom-functions) for more information. + +Below is an example that takes some xml and transform it into json. Once your function is created, you need to add it +to powertools.You can then use it to do your validation or in idempotency module. + +=== "XMLFunction.java" + + ```java + public class XMLFunction extends BaseFunction { + public Base64Function() { + super("powertools_xml", ArgumentConstraints.typeOf(JmesPathType.STRING)); + } + + @Override + protected T callFunction(Adapter runtime, List> arguments) { + T value = arguments.get(0).value(); + String xmlString = runtime.toString(value); + + String jsonString = // ... transform xmlString to json + + return runtime.createString(jsonString); + } + } + ``` + +=== "Handler with validation API" + + ```java hl_lines="6 13" + ... + import software.amazon.lambda.powertools.validation.ValidationConfig; + import software.amazon.lambda.powertools.validation.ValidationUtils.validate; + + static { + JsonConfig.get().addFunction(new XMLFunction()); + } + + public class MyXMLEventHandler implements RequestHandler { + + @Override + public String handleRequest(MyEventWithXML myEvent, Context context) { + validate(myEvent, "classpath:/schema.json", "powertools_xml(path.to.xml_data)"); + return "OK"; + } + } + ``` + +=== "Handler with validation annotation" + + ```java hl_lines="6 12" + ... + import software.amazon.lambda.powertools.validation.ValidationConfig; + import software.amazon.lambda.powertools.validation.Validation; + + static { + JsonConfig.get().addFunction(new XMLFunction()); + } + + public class MyXMLEventHandler implements RequestHandler { + + @Override + @Validation(inboundSchema="classpath:/schema.json", envelope="powertools_xml(path.to.xml_data)") + public String handleRequest(MyEventWithXML myEvent, Context context) { + return "OK"; + } + } + ``` diff --git a/docs/utilities/validation.md b/docs/utilities/validation.md index 862e668c0..28d8c7a20 100644 --- a/docs/utilities/validation.md +++ b/docs/utilities/validation.md @@ -221,145 +221,6 @@ This is quite powerful because you can use JMESPath Query language to extract re to [pipe expressions](https://jmespath.org/tutorial.html#pipe-expressions) and [function](https://jmespath.org/tutorial.html#functions) expressions, where you'd extract what you need before validating the actual payload. -## JMESPath functions - -JMESPath functions ensure to make an operation on a specific part of the json.validate - -Powertools provides two built-in functions: - -### powertools_base64 function - -Use `powertools_base64` function to decode any base64 data. - -Below sample will decode the base64 value within the data key, and decode the JSON string into a valid JSON before we can validate it. - -=== "MyEventHandler.java" - - ```java hl_lines="7" - import software.amazon.lambda.powertools.validation.ValidationUtils; - - public class MyEventHandler implements RequestHandler { - - @Override - public String handleRequest(MyEvent myEvent, Context context) { - validate(myEvent, "classpath:/schema.json", "powertools_base64(data)"); - return "OK"; - } - } - ``` -=== "schema.json" - ```json - { - "data" : "ewogICJpZCI6IDQzMjQyLAogICJuYW1lIjogIkZvb0JhciBYWSIsCiAgInByaWNlIjogMjU4Cn0=" - } - ``` - -### powertools_base64_gzip function - -Use `powertools_base64_gzip` function to decompress and decode base64 data. - -Below sample will decompress and decode base64 data. - -=== "MyEventHandler.java" - - ```java hl_lines="7" - import software.amazon.lambda.powertools.validation.ValidationUtils; - - public class MyEventHandler implements RequestHandler { - - @Override - public String handleRequest(MyEvent myEvent, Context context) { - validate(myEvent, "classpath:/schema.json", "powertools_base64_gzip(data)"); - return "OK"; - } - } - ``` - -=== "schema.json" - - ```json - { - "data" : "H4sIAAAAAAAA/6vmUlBQykxRslIwMTYyMdIBcfMSc1OBAkpu+flOiUUKEZFKYOGCosxkkLiRqQVXLQDnWo6bOAAAAA==" - } - ``` - -!!! note - You don't need any function to transform a JSON String into a JSON object, powertools-validation will do it for you. - In the 2 previous example, data contains JSON. Just provide the function to transform the base64 / gzipped / ... string into a clear JSON string. - -### Bring your own JMESPath function - -!!! warning - This should only be used for advanced use cases where you have special formats not covered by the built-in functions. - New functions will be added to the 2 built-in ones. - -Your function must extend `io.burt.jmespath.function.BaseFunction`, take a String as parameter and return a String. -You can read the [doc](https://github.com/burtcorp/jmespath-java#adding-custom-functions) for more information. - -Below is an example that takes some xml and transform it into json. Once your function is created, you need to add it -to powertools.You can then use it to do your validation or using annotation. - -=== "XMLFunction.java" - - ```java - public class XMLFunction extends BaseFunction { - public Base64Function() { - super("powertools_xml", ArgumentConstraints.typeOf(JmesPathType.STRING)); - } - - @Override - protected T callFunction(Adapter runtime, List> arguments) { - T value = arguments.get(0).value(); - String xmlString = runtime.toString(value); - - String jsonString = // ... transform xmlString to json - - return runtime.createString(jsonString); - } - } - ``` - -=== "Handler with validation API" - - ```java hl_lines="6 13" - ... - import software.amazon.lambda.powertools.validation.ValidationConfig; - import software.amazon.lambda.powertools.validation.ValidationUtils.validate; - - static { - ValidationConfig.get().addFunction(new XMLFunction()); - } - - public class MyXMLEventHandler implements RequestHandler { - - @Override - public String handleRequest(MyEventWithXML myEvent, Context context) { - validate(myEvent, "classpath:/schema.json", "powertools_xml(path.to.xml_data)"); - return "OK"; - } - } - ``` - -=== "Handler with validation annotation" - - ```java hl_lines="6 12" - ... - import software.amazon.lambda.powertools.validation.ValidationConfig; - import software.amazon.lambda.powertools.validation.Validation; - - static { - ValidationConfig.get().addFunction(new XMLFunction()); - } - - public class MyXMLEventHandler implements RequestHandler { - - @Override - @Validation(inboundSchema="classpath:/schema.json", envelope="powertools_xml(path.to.xml_data)") - public String handleRequest(MyEventWithXML myEvent, Context context) { - return "OK"; - } - } - ``` ## Change the schema version By default, powertools-validation is configured with [V7](https://json-schema.org/draft-07/json-schema-release-notes.html). diff --git a/example/HelloWorldFunction/build.gradle b/example/HelloWorldFunction/build.gradle index d7b0221d6..b342a4931 100644 --- a/example/HelloWorldFunction/build.gradle +++ b/example/HelloWorldFunction/build.gradle @@ -4,6 +4,7 @@ plugins{ } repositories { + mavenLocal() mavenCentral() } @@ -15,8 +16,11 @@ dependencies { aspect 'software.amazon.lambda:powertools-parameters:1.10.3' aspect 'software.amazon.lambda:powertools-validation:1.10.3' + implementation 'software.amazon.lambda:powertools-idempotency:1.10.3' + aspectpath 'software.amazon.lambda:powertools-idempotency:1.10.3' + implementation 'com.amazonaws:aws-lambda-java-core:1.2.1' - implementation 'com.amazonaws:aws-lambda-java-events:3.1.0' + implementation 'com.amazonaws:aws-lambda-java-events:3.11.0' implementation 'org.apache.logging.log4j:log4j-api:2.16.0' implementation 'org.apache.logging.log4j:log4j-core:2.16.0' diff --git a/example/HelloWorldFunction/pom.xml b/example/HelloWorldFunction/pom.xml index e742c66cf..96786e152 100644 --- a/example/HelloWorldFunction/pom.xml +++ b/example/HelloWorldFunction/pom.xml @@ -43,6 +43,11 @@ powertools-sqs 1.10.3 + + software.amazon.lambda + powertools-idempotency + 1.10.3 + com.amazonaws aws-lambda-java-core @@ -51,7 +56,7 @@ com.amazonaws aws-lambda-java-events - 3.1.0 + 3.11.0 org.apache.logging.log4j diff --git a/example/HelloWorldFunction/src/main/java/helloworld/AppIdempotency.java b/example/HelloWorldFunction/src/main/java/helloworld/AppIdempotency.java new file mode 100644 index 000000000..64b5d79a5 --- /dev/null +++ b/example/HelloWorldFunction/src/main/java/helloworld/AppIdempotency.java @@ -0,0 +1,84 @@ +package helloworld; + +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.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 AppIdempotency implements RequestHandler { + private final static Logger LOG = LogManager.getLogger(); + + public AppIdempotency() { + // 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") + .build() + ).configure(); + } + + + /** + * Try with: + *
+     *     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"}'
+     * 
+ * @param input + * @param context + * @return + */ + @Idempotent + public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) { + 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/example/template.yaml b/example/template.yaml index e3dddad4a..901d8306d 100644 --- a/example/template.yaml +++ b/example/template.yaml @@ -53,6 +53,35 @@ Resources: Path: /helloparams Method: get + IdempotencyTable: + Type: AWS::Serverless::SimpleTable + Properties: + TableName: idempotency_table + PrimaryKey: + Name: id + Type: String + + HelloWorldIdempotentFunction: + Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction + Properties: + CodeUri: HelloWorldFunction + Handler: helloworld.AppIdempotency::handleRequest + MemorySize: 512 + Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object + Variables: + POWERTOOLS_LOG_LEVEL: INFO + AWS_ENDPOINT_DISCOVERY_ENABLED: false + Tracing: Active + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref IdempotencyTable + Events: + HelloWorld: + Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api + Properties: + Path: /helloidem + Method: post + UserPwd: Type: AWS::SecretsManager::Secret Properties: @@ -171,3 +200,9 @@ Outputs: Description: "Hello World Params Lambda Function ARN" Value: !GetAtt HelloWorldParamsFunction.Arn + HelloWorldIdempotencyApi: + Description: "API Gateway endpoint URL for Prod stage for Hello World idempotency function" + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/helloidem/" + HelloWorldIdempotencyFunction: + Description: "Hello World Idempotency Lambda Function ARN" + Value: !GetAtt HelloWorldIdempotentFunction.Arn diff --git a/mkdocs.yml b/mkdocs.yml index 6ed6ec179..0881cdc5c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,11 +10,13 @@ nav: - core/tracing.md - core/metrics.md - Utilities: + - utilities/idempotency.md - utilities/parameters.md - utilities/sqs_large_message_handling.md - utilities/batch.md - utilities/validation.md - utilities/custom_resources.md + - utilities/serialization.md theme: name: material diff --git a/pom.xml b/pom.xml index 1cecee72e..e5e30cdaf 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,7 @@ powertools-core + powertools-serialization powertools-logging powertools-tracing powertools-sqs @@ -36,6 +37,7 @@ powertools-validation powertools-test-suite powertools-cloudformation + powertools-idempotency @@ -90,6 +92,11 @@ powertools-core ${project.version}
+ + software.amazon.lambda + powertools-serialization + ${project.version} + 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
+ + software.amazon.lambda + powertools-serialization + com.amazonaws aws-lambda-java-events diff --git a/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/ValidationConfig.java b/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/ValidationConfig.java index 191c50107..3fd964226 100644 --- a/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/ValidationConfig.java +++ b/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/ValidationConfig.java @@ -18,18 +18,17 @@ import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SpecVersion; 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.validation.jmespath.Base64Function; -import software.amazon.lambda.powertools.validation.jmespath.Base64GZipFunction; - -import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; +import software.amazon.lambda.powertools.utilities.JsonConfig; +import software.amazon.lambda.powertools.utilities.jmespath.Base64Function; +import software.amazon.lambda.powertools.utilities.jmespath.Base64GZipFunction; /** * Use this if you need to customize some part of the JSON Schema validation - * (eg. specification version, Jackson ObjectMapper, or adding functions to JMESPath) + * (eg. specification version, Jackson ObjectMapper, or adding functions to JMESPath). + * + * For everything but the validation features (factory, schemaVersion), {@link ValidationConfig} + * is just a wrapper of {@link JsonConfig}. */ public class ValidationConfig { private ValidationConfig() { @@ -43,24 +42,9 @@ public static ValidationConfig 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 SpecVersion.VersionFlag jsonSchemaVersion = SpecVersion.VersionFlag.V7; private JsonSchemaFactory factory = JsonSchemaFactory.getInstance(jsonSchemaVersion); - private final FunctionRegistry defaultFunctions = FunctionRegistry.defaultRegistry(); - private final FunctionRegistry customFunctions = defaultFunctions.extend( - new Base64Function(), - new Base64GZipFunction()); - private final RuntimeConfiguration configuration = new RuntimeConfiguration.Builder() - .withFunctionRegistry(customFunctions) - .build(); - private JmesPath jmesPath = new JacksonRuntime(configuration, getObjectMapper()); - /** * Set the version of the json schema specifications (default is V7) * @@ -85,13 +69,7 @@ public SpecVersion.VersionFlag getSchemaVersion() { * @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()); + JsonConfig.get().addFunction(function); } /** @@ -109,7 +87,7 @@ public JsonSchemaFactory getFactory() { * @return the {@link JmesPath} */ public JmesPath getJmesPath() { - return jmesPath; + return JsonConfig.get().getJmesPath(); } /** @@ -118,6 +96,6 @@ public JmesPath getJmesPath() { * @return the {@link ObjectMapper} to serialize / deserialize JSON */ public ObjectMapper getObjectMapper() { - return om.get(); + return JsonConfig.get().getObjectMapper(); } } diff --git a/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/internal/ValidationAspect.java b/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/internal/ValidationAspect.java index b665ca2e0..b42ce71ab 100644 --- a/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/internal/ValidationAspect.java +++ b/powertools-validation/src/main/java/software/amazon/lambda/powertools/validation/internal/ValidationAspect.java @@ -26,10 +26,10 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static software.amazon.lambda.powertools.core.internal.LambdaHandlerProcessor.isHandlerMethod; import static software.amazon.lambda.powertools.core.internal.LambdaHandlerProcessor.placedOnRequestHandler; +import static software.amazon.lambda.powertools.utilities.jmespath.Base64Function.decode; +import static software.amazon.lambda.powertools.utilities.jmespath.Base64GZipFunction.decompress; import static software.amazon.lambda.powertools.validation.ValidationUtils.getJsonSchema; import static software.amazon.lambda.powertools.validation.ValidationUtils.validate; -import static software.amazon.lambda.powertools.validation.jmespath.Base64Function.decode; -import static software.amazon.lambda.powertools.validation.jmespath.Base64GZipFunction.decompress; /** * Aspect for {@link Validation} annotation diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index b36b180cb..30e627a56 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -28,6 +28,10 @@ + + + + @@ -61,6 +65,18 @@ + + + + + + + + + + + +