diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotentAttribute.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotentAttribute.cs index 0204ee1af..fcd19f989 100644 --- a/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotentAttribute.cs +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotentAttribute.cs @@ -63,6 +63,11 @@ namespace AWS.Lambda.Powertools.Idempotency; [Injection(typeof(UniversalWrapperAspect), Inherited = true)] public class IdempotentAttribute : UniversalWrapperAttribute { + /// + /// Custom prefix for idempotency key: key_prefix#hash + /// + public string KeyPrefix { get; set; } + /// /// Wraps as a synchronous operation, simply throws IdempotencyConfigurationException /// @@ -90,7 +95,7 @@ protected internal sealed override T WrapSync(Func target, objec Task ResultDelegate() => Task.FromResult(target(args)); - var idempotencyHandler = new IdempotencyAspectHandler(ResultDelegate, eventArgs.Method.Name, payload,GetContext(eventArgs)); + var idempotencyHandler = new IdempotencyAspectHandler(ResultDelegate, eventArgs.Method.Name, KeyPrefix, payload,GetContext(eventArgs)); if (idempotencyHandler == null) { throw new Exception("Failed to create an instance of IdempotencyAspectHandler"); @@ -128,7 +133,7 @@ protected internal sealed override async Task WrapAsync( Task ResultDelegate() => target(args); - var idempotencyHandler = new IdempotencyAspectHandler(ResultDelegate, eventArgs.Method.Name, payload, GetContext(eventArgs)); + var idempotencyHandler = new IdempotencyAspectHandler(ResultDelegate, eventArgs.Method.Name, KeyPrefix, payload, GetContext(eventArgs)); if (idempotencyHandler == null) { throw new Exception("Failed to create an instance of IdempotencyAspectHandler"); diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/IdempotencyAspectHandler.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/IdempotencyAspectHandler.cs index ba1bf80a0..a8d7da731 100644 --- a/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/IdempotencyAspectHandler.cs +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/IdempotencyAspectHandler.cs @@ -50,11 +50,13 @@ internal class IdempotencyAspectHandler /// /// /// + /// /// /// public IdempotencyAspectHandler( Func> target, string functionName, + string keyPrefix, JsonDocument payload, ILambdaContext lambdaContext) { @@ -62,7 +64,7 @@ public IdempotencyAspectHandler( _data = payload; _lambdaContext = lambdaContext; _persistenceStore = Idempotency.Instance.PersistenceStore; - _persistenceStore.Configure(Idempotency.Instance.IdempotencyOptions, functionName); + _persistenceStore.Configure(Idempotency.Instance.IdempotencyOptions, functionName, keyPrefix); } /// diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs index fc8604a2d..3cf9b1f62 100644 --- a/libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs @@ -1,12 +1,12 @@ /* * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * + * * Licensed under the Apache License, Version 2.0 (the "License"). * You may not use this file except in compliance with the License. * A copy of the License is located at - * + * * http://aws.amazon.com/apache2.0 - * + * * or in the "license" file accompanying this file. This file 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 @@ -37,16 +37,17 @@ public abstract class BasePersistenceStore : IPersistenceStore /// Idempotency Options /// private IdempotencyOptions _idempotencyOptions = null!; - + /// /// Function name /// private string _functionName; + /// /// Boolean to indicate whether or not payload validation is enabled /// protected bool PayloadValidationEnabled; - + /// /// LRUCache /// @@ -57,34 +58,45 @@ public abstract class BasePersistenceStore : IPersistenceStore /// /// Idempotency configuration settings /// The name of the function being decorated - public void Configure(IdempotencyOptions idempotencyOptions, string functionName) + /// + public void Configure(IdempotencyOptions idempotencyOptions, string functionName, string keyPrefix) { - var funcEnv = Environment.GetEnvironmentVariable(Constants.LambdaFunctionNameEnv); - _functionName = funcEnv ?? "testFunction"; - if (!string.IsNullOrWhiteSpace(functionName)) + if (!string.IsNullOrEmpty(keyPrefix)) { - _functionName += "." + functionName; + _functionName = keyPrefix; } + else + { + var funcEnv = Environment.GetEnvironmentVariable(Constants.LambdaFunctionNameEnv); + + _functionName = funcEnv ?? "testFunction"; + if (!string.IsNullOrWhiteSpace(functionName)) + { + _functionName += "." + functionName; + } + } + _idempotencyOptions = idempotencyOptions; - + if (!string.IsNullOrWhiteSpace(_idempotencyOptions.PayloadValidationJmesPath)) { PayloadValidationEnabled = true; } - + var useLocalCache = _idempotencyOptions.UseLocalCache; if (useLocalCache) { _cache = new LRUCache(_idempotencyOptions.LocalCacheMaxItems); } } - + /// /// For test purpose only (adding a cache to mock) /// - internal void Configure(IdempotencyOptions options, string functionName, LRUCache cache) + internal void Configure(IdempotencyOptions options, string functionName, string keyPrefix, + LRUCache cache) { - Configure(options, functionName); + Configure(options, functionName, keyPrefix); _cache = cache; } @@ -118,12 +130,12 @@ public virtual async Task SaveSuccess(JsonDocument data, object result, DateTime public virtual async Task SaveInProgress(JsonDocument data, DateTimeOffset now, double? remainingTimeInMs) { var idempotencyKey = GetHashedIdempotencyKey(data); - + if (RetrieveFromCache(idempotencyKey, now) != null) { throw new IdempotencyItemAlreadyExistsException(); } - + long? inProgressExpirationMsTimestamp = null; if (remainingTimeInMs.HasValue) { @@ -137,11 +149,10 @@ public virtual async Task SaveInProgress(JsonDocument data, DateTimeOffset now, null, GetHashedPayload(data), inProgressExpirationMsTimestamp - ); await PutRecord(record, now); } - + /// /// Delete record from the persistence store /// @@ -152,14 +163,14 @@ public virtual async Task DeleteRecord(JsonDocument data, Exception throwable) var idemPotencyKey = GetHashedIdempotencyKey(data); Console.WriteLine("Function raised an exception {0}. " + - "Clearing in progress record in persistence store for idempotency key: {1}", + "Clearing in progress record in persistence store for idempotency key: {1}", throwable.GetType().Name, idemPotencyKey); await DeleteRecord(idemPotencyKey); DeleteFromCache(idemPotencyKey); } - + /// /// Retrieve idempotency key for data provided, fetch from persistence store, and convert to DataRecord. /// @@ -182,7 +193,7 @@ public virtual async Task GetRecord(JsonDocument data, DateTimeOffse ValidatePayload(data, record); return record; } - + /// /// 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 @@ -198,7 +209,7 @@ private void SaveToCache(DataRecord dataRecord) _cache.Set(dataRecord.IdempotencyKey, dataRecord); } - + /// /// Validate that the hashed payload matches data provided and stored data record /// @@ -215,7 +226,7 @@ private void ValidatePayload(JsonDocument data, DataRecord dataRecord) throw new IdempotencyValidationException("Payload does not match stored record for this event key"); } } - + /// /// Retrieve data record from cache /// @@ -228,14 +239,15 @@ private DataRecord RetrieveFromCache(string idempotencyKey, DateTimeOffset now) return null; if (!_cache.TryGet(idempotencyKey, out var record) || record == null) return null; - if (!record.IsExpired(now)) + if (!record.IsExpired(now)) { return record; } + DeleteFromCache(idempotencyKey); return null; } - + /// /// Deletes item from cache /// @@ -244,10 +256,10 @@ private void DeleteFromCache(string idempotencyKey) { if (!_idempotencyOptions.UseLocalCache) return; - + _cache.Delete(idempotencyKey); } - + /// /// Extract payload using validation key jmespath and return a hashed representation /// @@ -259,12 +271,12 @@ private string GetHashedPayload(JsonDocument data) { return ""; } - + var transformer = JsonTransformer.Parse(_idempotencyOptions.PayloadValidationJmesPath); var result = transformer.Transform(data.RootElement); return GenerateHash(result.RootElement); } - + /// /// Calculate unix timestamp of expiry date for idempotency record /// @@ -285,7 +297,7 @@ private string GetHashedIdempotencyKey(JsonDocument data) { var node = data.RootElement; var eventKeyJmesPath = _idempotencyOptions.EventKeyJmesPath; - if (eventKeyJmesPath != null) + if (eventKeyJmesPath != null) { var transformer = JsonTransformer.Parse(eventKeyJmesPath); var result = transformer.Transform(node); @@ -298,7 +310,9 @@ private string GetHashedIdempotencyKey(JsonDocument data) { throw new IdempotencyKeyException("No data found to create a hashed idempotency key"); } - Console.WriteLine("No data found to create a hashed idempotency key. JMESPath: {0}", _idempotencyOptions.EventKeyJmesPath ?? string.Empty); + + Console.WriteLine("No data found to create a hashed idempotency key. JMESPath: {0}", + _idempotencyOptions.EventKeyJmesPath ?? string.Empty); } var hash = GenerateHash(node); @@ -313,9 +327,10 @@ private string GetHashedIdempotencyKey(JsonDocument data) private static bool IsMissingIdempotencyKey(JsonElement data) { return data.ValueKind == JsonValueKind.Null || data.ValueKind == JsonValueKind.Undefined - || (data.ValueKind == JsonValueKind.String && data.ToString() == string.Empty); + || (data.ValueKind == JsonValueKind.String && + data.ToString() == string.Empty); } - + /// /// Generate a hash value from the provided data /// @@ -328,16 +343,16 @@ internal string GenerateHash(JsonElement data) // starting .NET 8 no option to change hash algorithm using var hashAlgorithm = MD5.Create(); #else - using var hashAlgorithm = HashAlgorithm.Create(_idempotencyOptions.HashFunction); #endif if (hashAlgorithm == null) { throw new ArgumentException("Invalid HashAlgorithm"); } + var stringToHash = data.ToString(); var hash = GetHash(hashAlgorithm, stringToHash); - + return hash; } @@ -351,18 +366,18 @@ private static string GetHash(HashAlgorithm hashAlgorithm, string input) { // Convert the input string to a byte array and compute the hash. var data = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(input)); - + // Create a new Stringbuilder to collect the bytes // and create a string. var sBuilder = new StringBuilder(); - + // Loop through each byte of the hashed data // and format each one as a hexadecimal string. for (var i = 0; i < data.Length; i++) { sBuilder.Append(data[i].ToString("x2")); } - + // Return the hexadecimal string. return sBuilder.ToString(); } diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyAttributeWithCustomKeyPrefix.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyAttributeWithCustomKeyPrefix.cs new file mode 100644 index 000000000..f773411ec --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyAttributeWithCustomKeyPrefix.cs @@ -0,0 +1,21 @@ +using System; +using Amazon.Lambda.Core; + +namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers; + +/// +/// Simple Lambda function with Idempotent attribute on a sub method with a custom prefix key +/// +public class IdempotencyAttributeWithCustomKeyPrefix +{ + public string HandleRequest(string input, ILambdaContext context) + { + return ReturnGuid(input); + } + + [Idempotent(KeyPrefix = "MyMethod")] + private string ReturnGuid(string p) + { + return Guid.NewGuid().ToString(); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyFunctionMethodDecorated.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyFunctionMethodDecorated.cs index ae4f0d0d5..ed7520606 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyFunctionMethodDecorated.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyFunctionMethodDecorated.cs @@ -13,7 +13,6 @@ * permissions and limitations under the License. */ -using System; using System.Collections.Generic; using System.IO; using System.Net.Http; @@ -22,7 +21,6 @@ using Amazon.DynamoDBv2; using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.Core; -using AWS.Lambda.Powertools.Idempotency.Tests.Model; namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers; @@ -34,9 +32,6 @@ public IdempotencyFunctionMethodDecorated(AmazonDynamoDBClient client) { Idempotency.Configure(builder => builder -#if NET8_0_OR_GREATER - .WithJsonSerializationContext(TestJsonSerializerContext.Default) -#endif .UseDynamoDb(storeBuilder => storeBuilder .WithTableName("idempotency_table") diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyHandlerWithCustomKeyPrefix.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyHandlerWithCustomKeyPrefix.cs new file mode 100644 index 000000000..8c384e654 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyHandlerWithCustomKeyPrefix.cs @@ -0,0 +1,16 @@ +using System; +using Amazon.Lambda.Core; + +namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers; + +/// +/// Simple Lambda function with Idempotent on handler with a custom prefix key +/// +public class IdempotencyHandlerWithCustomKeyPrefix +{ + [Idempotent(KeyPrefix = "MyHandler")] + public string HandleRequest(string input, ILambdaContext context) + { + return Guid.NewGuid().ToString(); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotentAspectTests.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotentAspectTests.cs index 9a0842717..f83cfe343 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotentAspectTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotentAspectTests.cs @@ -353,7 +353,7 @@ public void Handle_WhenIdempotencyOnSubMethodAnnotated_AndSecondCall_AndNotExpir } [Fact] - public void Handle_WhenIdempotencyOnSubMethodAnnotated_AndKeyJMESPath_ShouldPutInStoreWithKey() + public async Task Handle_WhenIdempotencyOnSubMethodAnnotated_AndKeyJMESPath_ShouldPutInStoreWithKey() { // Arrange var store = new InMemoryPersistenceStore(); @@ -373,7 +373,46 @@ public void Handle_WhenIdempotencyOnSubMethodAnnotated_AndKeyJMESPath_ShouldPutI // Assert // a1d0c6e83f027327d8461063f4ac58a6 = MD5(42) - store.GetRecord("testFunction.createBasket#a1d0c6e83f027327d8461063f4ac58a6").Should().NotBeNull(); + var record = await store.GetRecord("testFunction.CreateBasket#a1d0c6e83f027327d8461063f4ac58a6"); + Assert.NotNull(record); + } + + [Fact] + public async Task WhenIdempotency_Custom_Prefix_Key_Handler() + { + // Arrange + var store = new InMemoryPersistenceStore(); + Idempotency.Configure(builder => + builder + .WithPersistenceStore(store)); + + // Act + var function = new IdempotencyHandlerWithCustomKeyPrefix(); + function.HandleRequest("42", new TestLambdaContext()); + + // Assert + // a1d0c6e83f027327d8461063f4ac58a6 = MD5(42) + var record = await store.GetRecord("MyHandler#a1d0c6e83f027327d8461063f4ac58a6"); + Assert.NotNull(record); + } + + [Fact] + public async Task WhenIdempotency_Custom_Prefix_Key_Method() + { + // Arrange + var store = new InMemoryPersistenceStore(); + Idempotency.Configure(builder => + builder + .WithPersistenceStore(store)); + + // Act + var function = new IdempotencyAttributeWithCustomKeyPrefix(); + function.HandleRequest("42", new TestLambdaContext()); + + // Assert + // a1d0c6e83f027327d8461063f4ac58a6 = MD5(42) + var record = await store.GetRecord("MyMethod#a1d0c6e83f027327d8461063f4ac58a6"); + Assert.NotNull(record); } [Fact] diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Persistence/BasePersistenceStoreTests.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Persistence/BasePersistenceStoreTests.cs index 0b71f51b5..0aed14405 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Persistence/BasePersistenceStoreTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Persistence/BasePersistenceStoreTests.cs @@ -78,7 +78,7 @@ public async Task SaveInProgress_WhenDefaultConfig_ShouldSaveRecordInStore() var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null); + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, null); var now = DateTimeOffset.UtcNow; @@ -102,7 +102,7 @@ public async Task SaveInProgress_WhenRemainingTime_ShouldSaveRecordInStore() var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null); + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, null); var now = DateTimeOffset.UtcNow; var lambdaTimeoutMs = 30000; @@ -130,7 +130,7 @@ public async Task SaveInProgress_WhenKeyJmesPathIsSet_ShouldSaveRecordInStore_Wi persistenceStore.Configure(new IdempotencyOptionsBuilder() .WithEventKeyJmesPath("powertools_json(Body).id") - .Build(), "myfunc"); + .Build(), "myfunc", null); var now = DateTimeOffset.UtcNow; @@ -157,7 +157,7 @@ public async Task persistenceStore.Configure(new IdempotencyOptionsBuilder() .WithEventKeyJmesPath("powertools_json(Body).[id, message]") //[43876123454654,"Lambda rocks"] - .Build(), "myfunc"); + .Build(), "myfunc", null); var now = DateTimeOffset.UtcNow; @@ -185,7 +185,7 @@ public async Task SaveInProgress_WhenJMESPath_NotFound_ShouldThrowException() persistenceStore.Configure(new IdempotencyOptionsBuilder() .WithEventKeyJmesPath("unavailable") .WithThrowOnNoIdempotencyKey(true) // should throw - .Build(), ""); + .Build(), "", null); var now = DateTimeOffset.UtcNow; // Act @@ -209,7 +209,7 @@ public async Task SaveInProgress_WhenJMESpath_NotFound_ShouldNotThrowException() persistenceStore.Configure(new IdempotencyOptionsBuilder() .WithEventKeyJmesPath("unavailable") - .Build(), ""); + .Build(), "", null); var now = DateTimeOffset.UtcNow; @@ -233,7 +233,7 @@ public async Task SaveInProgress_WhenLocalCacheIsSet_AndNotExpired_ShouldThrowEx persistenceStore.Configure(new IdempotencyOptionsBuilder() .WithUseLocalCache(true) .WithEventKeyJmesPath("powertools_json(Body).id") - .Build(), null, cache); + .Build(), null, null, cache); var now = DateTimeOffset.UtcNow; cache.Set("testFunction#2fef178cc82be5ce3da6c5e0466a6182", @@ -266,7 +266,7 @@ public async Task SaveInProgress_WhenLocalCacheIsSetButExpired_ShouldRemoveFromC .WithEventKeyJmesPath("powertools_json(Body).id") .WithUseLocalCache(true) .WithExpiration(TimeSpan.FromSeconds(2)) - .Build(), null, cache); + .Build(), null, null, cache); var now = DateTimeOffset.UtcNow; cache.Set("testFunction#2fef178cc82be5ce3da6c5e0466a6182", @@ -296,7 +296,7 @@ public async Task SaveSuccess_WhenDefaultConfig_ShouldUpdateRecord() var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); LRUCache cache = new(2); - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, cache); + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, null, cache); var product = new Product(34543, "product", 42); @@ -325,7 +325,7 @@ public async Task SaveSuccess_WhenCacheEnabled_ShouldSaveInCache() LRUCache cache = new(2); persistenceStore.Configure(new IdempotencyOptionsBuilder() - .WithUseLocalCache(true).Build(), null, cache); + .WithUseLocalCache(true).Build(), null, null, cache); var product = new Product(34543, "product", 42); var now = DateTimeOffset.UtcNow; @@ -355,7 +355,7 @@ public async Task GetRecord_WhenRecordIsInStore_ShouldReturnRecordFromPersistenc var request = LoadApiGatewayProxyRequest(); LRUCache cache = new(2); - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), "myfunc", cache); + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), "myfunc", null, cache); var now = DateTimeOffset.UtcNow; @@ -378,7 +378,7 @@ public async Task GetRecord_WhenCacheEnabledNotExpired_ShouldReturnRecordFromCac LRUCache cache = new(2); persistenceStore.Configure(new IdempotencyOptionsBuilder() - .WithUseLocalCache(true).Build(), "myfunc", cache); + .WithUseLocalCache(true).Build(), "myfunc", null, cache); var now = DateTimeOffset.UtcNow; var dr = new DataRecord( @@ -407,7 +407,7 @@ public async Task GetRecord_WhenLocalCacheEnabledButRecordExpired_ShouldReturnRe var request = LoadApiGatewayProxyRequest(); LRUCache cache = new(2); persistenceStore.Configure(new IdempotencyOptionsBuilder() - .WithUseLocalCache(true).Build(), "myfunc", cache); + .WithUseLocalCache(true).Build(), "myfunc", null, cache); var now = DateTimeOffset.UtcNow; var dr = new DataRecord( @@ -440,7 +440,7 @@ public async Task GetRecord_WhenInvalidPayload_ShouldThrowValidationException() .WithEventKeyJmesPath("powertools_json(Body).id") .WithPayloadValidationJmesPath("powertools_json(Body).message") .Build(), - "myfunc"); + "myfunc", null); var now = DateTimeOffset.UtcNow; @@ -459,7 +459,7 @@ public async Task DeleteRecord_WhenRecordExist_ShouldDeleteRecordFromPersistence var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null); + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, null); // Act await persistenceStore.DeleteRecord(JsonSerializer.SerializeToDocument(request)!, new ArithmeticException()); @@ -476,7 +476,7 @@ public async Task DeleteRecord_WhenLocalCacheEnabled_ShouldDeleteRecordFromCache var request = LoadApiGatewayProxyRequest(); LRUCache cache = new(2); persistenceStore.Configure(new IdempotencyOptionsBuilder() - .WithUseLocalCache(true).Build(), null, cache); + .WithUseLocalCache(true).Build(), null, null, cache); cache.Set("testFunction#5eff007a9ed2789a9f9f6bc182fc6ae6", new DataRecord("testFunction#5eff007a9ed2789a9f9f6bc182fc6ae6", @@ -497,7 +497,7 @@ public void GenerateHash_WhenInputIsString_ShouldGenerateMd5ofString() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null); + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, null); var expectedHash = "70c24d88041893f7fbab4105b76fd9e1"; // MD5(Lambda rocks) // Act @@ -513,7 +513,7 @@ public void GenerateHash_WhenInputIsObject_ShouldGenerateMd5ofJsonObject() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null); + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, null); var product = new Product(42, "Product", 12); var expectedHash = "c83e720b399b3b4898c8734af177c53a"; // MD5({"Id":42,"Name":"Product","Price":12}) @@ -530,7 +530,7 @@ public void GenerateHash_WhenInputIsDouble_ShouldGenerateMd5ofDouble() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null); + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, null); var expectedHash = "bb84c94278119c8838649706df4db42b"; // MD5(256.42) // Act @@ -539,6 +539,27 @@ public void GenerateHash_WhenInputIsDouble_ShouldGenerateMd5ofDouble() // Assert generatedHash.Should().Be(expectedHash); } + + [Fact] + public async Task When_Key_Prefix_Set_Should_Create_With_Prefix() + { + // Arrange + var persistenceStore = new InMemoryPersistenceStore(); + var request = LoadApiGatewayProxyRequest(); + + persistenceStore.Configure(new IdempotencyOptionsBuilder() + .WithEventKeyJmesPath("powertools_json(Body).id") + .Build(), "myfunc", "MyCustomPrefixKey"); + + var now = DateTimeOffset.UtcNow; + + // Act + await persistenceStore.SaveInProgress(JsonSerializer.SerializeToDocument(request)!, now, null); + + // Assert + var dr = persistenceStore.DataRecord; + dr.IdempotencyKey.Should().Be("MyCustomPrefixKey#2fef178cc82be5ce3da6c5e0466a6182"); + } private static APIGatewayProxyRequest LoadApiGatewayProxyRequest() { diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Persistence/DynamoDBPersistenceStoreTests.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Persistence/DynamoDBPersistenceStoreTests.cs index 957adc3f3..6dc2fb844 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Persistence/DynamoDBPersistenceStoreTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Persistence/DynamoDBPersistenceStoreTests.cs @@ -42,7 +42,7 @@ public DynamoDbPersistenceStoreTests(DynamoDbFixture fixture) .WithTableName(_tableName) .WithDynamoDBClient(_client) .Build(); - _dynamoDbPersistenceStore.Configure(new IdempotencyOptionsBuilder().Build(),functionName: null); + _dynamoDbPersistenceStore.Configure(new IdempotencyOptionsBuilder().Build(),functionName: null, keyPrefix: null); } //putRecord @@ -281,7 +281,7 @@ await _client.PutItemAsync(new PutItemRequest }); // enable payload validation _dynamoDbPersistenceStore.Configure(new IdempotencyOptionsBuilder().WithPayloadValidationJmesPath("path").Build(), - null); + null, null); // Act expiry = now.AddSeconds(3600).ToUnixTimeSeconds(); @@ -367,7 +367,7 @@ public async Task EndToEndWithCustomAttrNamesAndSortKey() .WithStatusAttr("state") .WithValidationAttr("valid") .Build(); - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(),functionName: null); + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(),functionName: null, keyPrefix: null); var now = DateTimeOffset.UtcNow; var record = new DataRecord( diff --git a/libraries/tests/e2e/functions/idempotency/Function/src/Function/Function.cs b/libraries/tests/e2e/functions/idempotency/Function/src/Function/Function.cs index 672ffdfb5..389e414cf 100644 --- a/libraries/tests/e2e/functions/idempotency/Function/src/Function/Function.cs +++ b/libraries/tests/e2e/functions/idempotency/Function/src/Function/Function.cs @@ -71,3 +71,21 @@ public APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest apigwProxy } } } + +namespace CustomKeyPrefixTest +{ + public class Function + { + public Function() + { + var tableName = Environment.GetEnvironmentVariable("IDEMPOTENCY_TABLE_NAME"); + Idempotency.Configure(builder => builder.UseDynamoDb(tableName)); + } + + [Idempotent(KeyPrefix = "MyCustomKeyPrefix")] + public APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest apigwProxyEvent, ILambdaContext context) + { + return TestHelper.TestMethod(apigwProxyEvent); + } + } +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/FunctionTests.cs b/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/FunctionTests.cs index f7eba28f7..3f5c7cc7a 100644 --- a/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/FunctionTests.cs +++ b/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/FunctionTests.cs @@ -89,6 +89,18 @@ public async Task IdempotencyHandlerTest(string functionName) await UpdateFunctionHandler(functionName, "Function::Function.Function::FunctionHandler"); await IdempotencyHandler(functionName); } + + [Theory] + [InlineData("E2ETestLambda_X64_NET8_idempotency")] + [InlineData("E2ETestLambda_ARM_NET8_idempotency")] + [InlineData("E2ETestLambda_X64_NET6_idempotency")] + [InlineData("E2ETestLambda_ARM_NET6_idempotency")] + public async Task IdempotencyHandlerCustomKey(string functionName) + { + _tableName = "IdempotencyTable"; + await UpdateFunctionHandler(functionName, "Function::CustomKeyPrefixTest.Function::FunctionHandler"); + await IdempotencyHandler(functionName, "MyCustomKeyPrefix"); + } private async Task IdempotencyPayloadSubset(string functionName) { @@ -152,7 +164,7 @@ await AssertDynamoDbData( true); } - private async Task IdempotencyHandler(string functionName) + private async Task IdempotencyHandler(string functionName, string? keyPrefix = null) { var payload = await File.ReadAllTextAsync("../../../../../../../payload.json"); @@ -170,9 +182,11 @@ private async Task IdempotencyHandler(string functionName) Assert.Equal(guid1, guid2); Assert.Equal(guid2, guid3); + var key = keyPrefix ?? $"{functionName}.FunctionHandler"; + // Assert DynamoDB await AssertDynamoDbData( - $"{functionName}.FunctionHandler#35973cf447e6cc11008d603c791a232f", + $"{key}#35973cf447e6cc11008d603c791a232f", guid1); }