From 10505e4cc92f64a13ce5f3e4ecd8ecf18a326a6c Mon Sep 17 00:00:00 2001 From: Hossam Barakat Date: Sun, 9 Jul 2023 11:18:28 +0400 Subject: [PATCH 1/2] Fix typo in IsMissingIdemPotencyKey --- .../Persistence/BasePersistenceStore.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs index 8e10e632..a20acf07 100644 --- a/libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs @@ -290,7 +290,7 @@ private string GetHashedIdempotencyKey(JsonDocument data) node = JsonDocument.Parse(result).RootElement; } - if (IsMissingIdemPotencyKey(node)) + if (IsMissingIdempotencyKey(node)) { if (_idempotencyOptions.ThrowOnNoIdempotencyKey) { @@ -308,7 +308,7 @@ private string GetHashedIdempotencyKey(JsonDocument data) /// /// /// True if the Idempotency key is missing - private static bool IsMissingIdemPotencyKey(JsonElement data) + private static bool IsMissingIdempotencyKey(JsonElement data) { return data.ValueKind == JsonValueKind.Null || data.ValueKind == JsonValueKind.Undefined || (data.ValueKind == JsonValueKind.String && data.ToString() == string.Empty); From 55f84fe5754898b90a8806f632eded159d63d0e1 Mon Sep 17 00:00:00 2001 From: Hossam Barakat Date: Sun, 9 Jul 2023 11:44:59 +0400 Subject: [PATCH 2/2] Support Idempotent attribute on any method --- docs/utilities/idempotency.md | 32 +++++ .../IdempotencyKeyAttribute.cs | 34 ++++++ .../IdempotentAttribute.cs | 54 ++++++++- .../Handlers/IdempotencyInternalFunction.cs | 41 +++++++ .../IdempotencyInternalFunctionInternalKey.cs | 36 ++++++ .../IdempotencyInternalFunctionInvalid.cs | 40 +++++++ .../IdempotencyInternalFunctionVoid.cs | 40 +++++++ .../Internal/IdempotentAspectTests.cs | 111 ++++++++++++++++++ .../Internal/InMemoryPersistenceStore.cs | 48 ++++++++ 9 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotencyKeyAttribute.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunction.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionInternalKey.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionInvalid.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionVoid.cs create mode 100644 libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/InMemoryPersistenceStore.cs diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 9fd11cd5..375dae94 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -122,6 +122,38 @@ You can quickly start by configuring `Idempotency` and using it with the `Idempo } ``` +#### Idempotent attribute on another method + +You can use the `Idempotent` attribute for any .NET function, not only the Lambda handlers. + +When using `Idempotent` attribute 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` attribute on the parameter to use. + +!!! info "The parameter must be serializable in JSON. We use `System.Text.Json` internally to (de)serialize objects" + + ```csharp + public class Function + { + public Function() + { + Idempotency.Configure(builder => builder.UseDynamoDb("idempotency_table")); + } + + public Task FunctionHandler(string input, ILambdaContext context) + { + dummpy("hello", "world") + return Task.FromResult(input.ToUpper()); + } + + [Idempotent] + private string dummy(string argOne, [IdempotencyKey] string argTwo) { + return "something"; + } + } + ``` + ### Choosing a payload subset for idempotency !!! tip "Tip: Dealing with always changing payloads" diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotencyKeyAttribute.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotencyKeyAttribute.cs new file mode 100644 index 00000000..8716a363 --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotencyKeyAttribute.cs @@ -0,0 +1,34 @@ +/* + * 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 + * permissions and limitations under the License. + */ + +using System; + +namespace AWS.Lambda.Powertools.Idempotency; + +/// +/// IdempotencyKey is used to signal that a method parameter is used as a key for idempotency. +/// Must be used in conjunction with the Idempotency attribute. +/// +/// Example: +/// +/// [Idempotent] +/// private Basket SubMethod([IdempotencyKey]string magicProduct, Product p) { ... } +/// Note: This annotation is not needed when the method only has one parameter. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class IdempotencyKeyAttribute: Attribute +{ + +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotentAttribute.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotentAttribute.cs index c14ca1af..d211a7cd 100644 --- a/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotentAttribute.cs +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotentAttribute.cs @@ -15,8 +15,10 @@ using System; using System.Linq; +using System.Reflection; using System.Text.Json; using System.Threading.Tasks; +using Amazon.Lambda.Core; using AspectInjector.Broker; using AWS.Lambda.Powertools.Common; using AWS.Lambda.Powertools.Idempotency.Exceptions; @@ -74,8 +76,12 @@ protected internal sealed override T WrapSync(Func target, objec { return base.WrapSync(target, args, eventArgs); } - - var payload = args is not null && args.Any() ? JsonDocument.Parse(JsonSerializer.Serialize(args[0])) : null; + if (eventArgs.ReturnType == typeof(void)) + { + throw new IdempotencyConfigurationException("The annotated method doesn't return anything. Unable to perform idempotency on void return type"); + } + + var payload = GetPayload(eventArgs); 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"); @@ -107,8 +113,13 @@ protected internal sealed override async Task WrapAsync( { return await base.WrapAsync(target, args, eventArgs); } + + if (eventArgs.ReturnType == typeof(void)) + { + throw new IdempotencyConfigurationException("The annotated method doesn't return anything. Unable to perform idempotency on void return type"); + } - var payload = args is not null && args.Any() ? JsonDocument.Parse(JsonSerializer.Serialize(args[0])) : null; + var payload = GetPayload(eventArgs); 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"); @@ -124,4 +135,41 @@ protected internal sealed override async Task WrapAsync( var result = await idempotencyHandler.Handle(); return result; } + + /// + /// Retrieve the payload from the annotated method parameter + /// + /// The instance containing the event data. + /// + /// The payload + private static JsonDocument GetPayload(AspectEventArgs eventArgs) + { + JsonDocument payload = null; + var eventArgsMethod = eventArgs.Method; + var args = eventArgs.Args; + var isPlacedOnRequestHandler = IsPlacedOnRequestHandler(eventArgsMethod); + // Use the first argument if IdempotentAttribute placed on handler or number of arguments is 1 + if (isPlacedOnRequestHandler || args.Count == 1) + { + payload = args is not null && args.Any() ? JsonDocument.Parse(JsonSerializer.Serialize(args[0])) : null; + } + else + { + //Find the first parameter in eventArgsMethod with attribute IdempotencyKeyAttribute + var parameter = eventArgsMethod.GetParameters().FirstOrDefault(p => p.GetCustomAttribute() != null); + if (parameter != null) + { + // set payload to the value of the parameter + payload = JsonDocument.Parse(JsonSerializer.Serialize(args[Array.IndexOf(eventArgsMethod.GetParameters(), parameter)])); + } + } + + return payload; + } + + private static bool IsPlacedOnRequestHandler(MethodBase method) + { + //Check if method has two arguments and the second one is of type ILambdaContext + return method.GetParameters().Length == 2 && method.GetParameters()[1].ParameterType == typeof(ILambdaContext); + } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunction.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunction.cs new file mode 100644 index 00000000..8ce1c2b3 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunction.cs @@ -0,0 +1,41 @@ +/* + * 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 + * permissions and limitations under the License. + */ + +using Amazon.Lambda.Core; +using AWS.Lambda.Powertools.Idempotency.Tests.Model; + +namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers; + +/// +/// Simple Lambda function with Idempotent attribute on a sub method (not the Lambda handler one) +/// +public class IdempotencyInternalFunction +{ + public Basket HandleRequest(Product input, ILambdaContext context) + { + return CreateBasket("fake", input); + } + + [Idempotent] + private Basket CreateBasket([IdempotencyKey]string magicProduct, Product p) + { + IsSubMethodCalled = true; + Basket b = new Basket(p); + b.Add(new Product(0, magicProduct, 0)); + return b; + } + + public bool IsSubMethodCalled { get; private set; } = false; +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionInternalKey.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionInternalKey.cs new file mode 100644 index 00000000..e4f961ce --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionInternalKey.cs @@ -0,0 +1,36 @@ +/* + * 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 + * permissions and limitations under the License. + */ + +using Amazon.Lambda.Core; +using AWS.Lambda.Powertools.Idempotency.Tests.Model; + +namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers; + +/// +/// Simple Lambda function with Idempotent attribute on a sub method (not the Lambda handler one) +/// +public class IdempotencyInternalFunctionInternalKey +{ + public Basket HandleRequest(Product input, ILambdaContext context) + { + return CreateBasket(input); + } + + [Idempotent] + private Basket CreateBasket(Product p) + { + return new Basket(p); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionInvalid.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionInvalid.cs new file mode 100644 index 00000000..f01f5a65 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionInvalid.cs @@ -0,0 +1,40 @@ +/* + * 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 + * permissions and limitations under the License. + */ + +using Amazon.Lambda.Core; +using AWS.Lambda.Powertools.Idempotency.Tests.Model; + +namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers; + +/// +/// Simple Lambda function with Idempotent attribute on a sub method. +/// This one is invalid as there are two parameters and IdempotencyKey attribute +/// is not used to specify which one will be used as a key for persistence. +/// +public class IdempotencyInternalFunctionInvalid +{ + public Basket HandleRequest(Product input, ILambdaContext 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; + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionVoid.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionVoid.cs new file mode 100644 index 00000000..0612df0f --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyInternalFunctionVoid.cs @@ -0,0 +1,40 @@ +/* + * 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 + * permissions and limitations under the License. + */ + +using Amazon.Lambda.Core; +using AWS.Lambda.Powertools.Idempotency.Tests.Model; + +namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers; + +/// +/// Simple Lambda function with Idempotent attribute a sub method. +/// This one is invalid because the annotated method return type is void, thus we cannot store any response. +/// +public class IdempotencyInternalFunctionVoid +{ + public Basket HandleRequest(Product input, ILambdaContext 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)); + } + +} \ 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 b850d82a..2913b8ef 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotentAspectTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotentAspectTests.cs @@ -220,6 +220,117 @@ public void Idempotency_Set_Execution_Environment_Context() Assert.NotNull(xRayRecorder); } + + [Fact] + public void Handle_WhenIdempotencyOnSubMethodAnnotated_AndFirstCall_ShouldPutInStore() + { + // Arrange + var store = new Mock(); + Idempotency.Configure(builder => builder.WithPersistenceStore(store.Object)); + + // Act + IdempotencyInternalFunction function = new IdempotencyInternalFunction(); + Product product = new Product(42, "fake product", 12); + Basket resultBasket = function.HandleRequest(product, new TestLambdaContext()); + + // Assert + resultBasket.Products.Count.Should().Be(2); + function.IsSubMethodCalled.Should().BeTrue(); + + store + .Verify(x=>x.SaveInProgress(It.Is(t=> t.ToString() == JsonSerializer.SerializeToDocument("fake", It.IsAny()).ToString()), It.IsAny())); + + store + .Verify(x=>x.SaveSuccess(It.IsAny(), It.Is(y => y.Equals(resultBasket)), It.IsAny())); + } + + [Fact] + public void Handle_WhenIdempotencyOnSubMethodAnnotated_AndSecondCall_AndNotExpired_ShouldGetFromStore() + { + // Arrange + var store = new Mock(); + store.Setup(x => x.SaveInProgress(It.IsAny(), It.IsAny())) + .Throws(); + + Idempotency.Configure(builder => builder.WithPersistenceStore(store.Object)); + + Product product = new Product(42, "fake product", 12); + Basket basket = new Basket(product); + DataRecord record = new DataRecord( + "fake", + DataRecord.DataRecordStatus.COMPLETED, + DateTimeOffset.UtcNow.AddSeconds(356).ToUnixTimeSeconds(), + JsonSerializer.SerializeToNode(basket)!.ToString(), + null); + store.Setup(x => x.GetRecord(It.IsAny(), It.IsAny())) + .ReturnsAsync(record); + + // Act + var function = new IdempotencyInternalFunction(); + Basket resultBasket = function.HandleRequest(product, new TestLambdaContext()); + + // assert + resultBasket.Should().Be(basket); + function.IsSubMethodCalled.Should().BeFalse(); + } + + [Fact] + public void Handle_WhenIdempotencyOnSubMethodAnnotated_AndKeyJMESPath_ShouldPutInStoreWithKey() + { + // Arrange + var store = new InMemoryPersistenceStore(); + Idempotency.Configure(builder => + builder + .WithPersistenceStore(store) + .WithOptions(optionsBuilder => optionsBuilder.WithEventKeyJmesPath("Id")) + ); + + // Act + IdempotencyInternalFunctionInternalKey function = new IdempotencyInternalFunctionInternalKey(); + Product product = new Product(42, "fake product", 12); + function.HandleRequest(product, new TestLambdaContext()); + + // Assert + // a1d0c6e83f027327d8461063f4ac58a6 = MD5(42) + store.GetRecord("testFunction.createBasket#a1d0c6e83f027327d8461063f4ac58a6").Should().NotBeNull(); + } + [Fact] + public void Handle_WhenIdempotencyOnSubMethodNotAnnotated_ShouldThrowException() + { + // Arrange + var store = new Mock(); + Idempotency.Configure(builder => + builder + .WithPersistenceStore(store.Object) + ); + + // Act + IdempotencyInternalFunctionInvalid function = new IdempotencyInternalFunctionInvalid(); + Product product = new Product(42, "fake product", 12); + Action act = () => function!.HandleRequest(product, new TestLambdaContext()); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Handle_WhenIdempotencyOnSubMethodVoid_ShouldThrowException() + { + // Arrange + var store = new Mock(); + Idempotency.Configure(builder => + builder + .WithPersistenceStore(store.Object) + ); + + // Act + IdempotencyInternalFunctionVoid function = new IdempotencyInternalFunctionVoid(); + Product product = new Product(42, "fake product", 12); + Action act = () => function.HandleRequest(product, new TestLambdaContext()); + + // Assert + act.Should().Throw(); + } public void Dispose() { diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/InMemoryPersistenceStore.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/InMemoryPersistenceStore.cs new file mode 100644 index 00000000..6a834ead --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/InMemoryPersistenceStore.cs @@ -0,0 +1,48 @@ +/* + * 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 + * permissions and limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AWS.Lambda.Powertools.Idempotency.Persistence; + +namespace AWS.Lambda.Powertools.Idempotency.Tests.Internal; + +public class InMemoryPersistenceStore: BasePersistenceStore +{ + private readonly Dictionary _records = new(); + public override Task GetRecord(string idempotencyKey) + { + return Task.FromResult(_records.ContainsKey(idempotencyKey) ? _records[idempotencyKey] : null); + } + + public override Task PutRecord(DataRecord record, DateTimeOffset now) + { + _records[record.IdempotencyKey] = record; + return Task.CompletedTask; + } + + public override Task UpdateRecord(DataRecord record) + { + _records[record.IdempotencyKey] = record; + return Task.CompletedTask; + } + + public override Task DeleteRecord(string idempotencyKey) + { + _records.Remove(idempotencyKey); + return Task.CompletedTask; + } +} \ No newline at end of file