diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1be4609..cfc6045b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup .NET 6.0 & 8.0 - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # 4.2.0 + uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # 4.3.0 with: dotnet-version: | 6.0.405 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4d9519b0..448c592d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,14 +32,14 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@f6091c0113d1dcf9b98e269ee48e8a7e51b7bdd4 #v2 + uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 #v2 with: languages: ${{ matrix.language }} # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@f6091c0113d1dcf9b98e269ee48e8a7e51b7bdd4 #v2 + uses: github/codeql-action/autobuild@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 #v2 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -52,4 +52,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f6091c0113d1dcf9b98e269ee48e8a7e51b7bdd4 #v2 + uses: github/codeql-action/analyze@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 #v2 diff --git a/.github/workflows/dispatch_analytics.yml b/.github/workflows/dispatch_analytics.yml index a1b0f3bb..2b240cdd 100644 --- a/.github/workflows/dispatch_analytics.yml +++ b/.github/workflows/dispatch_analytics.yml @@ -30,7 +30,7 @@ jobs: environment: analytics steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 + uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 with: aws-region: eu-central-1 role-to-assume: ${{ secrets.AWS_ANALYTICS_ROLE_ARN }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 870b10d1..a0e6f418 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -24,7 +24,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: "3.12" - name: Capture branch and tag @@ -35,7 +35,7 @@ jobs: - name: Build docs website run: make build-docs-website - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 # v4.0.3 with: aws-region: us-east-1 role-to-assume: ${{ secrets.AWS_DOCS_ROLE_ARN }} @@ -54,10 +54,10 @@ jobs: environment: Docs steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Setup .NET 6.0 - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # 4.2.0 + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # 4.3.0 with: - dotnet-version: 6.0.405 + dotnet-version: '8.x' - name: Build Api Docs run: | @@ -65,7 +65,7 @@ jobs: docfx apidocs/docfx.json - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 # v4.0.3 with: aws-region: us-east-1 role-to-assume: ${{ secrets.AWS_DOCS_ROLE_ARN }} diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 98ead3e5..b537fad0 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,6 +1,6 @@ # PROCESS # -# 1. Deploy the core and AOT stacks using the infra deployment workflow. +# 1. Deploy the E2E stacks using the infra deployment workflow for non-aot and aot. # 2. Run the E2E tests after the infrastructure is deployed. # 3. Destroy the CDK stacks after the tests are completed. @@ -33,14 +33,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 # v4.0.3 with: role-to-assume: ${{ secrets.E2E_DEPLOY_ROLE }} aws-region: us-east-1 mask-aws-account-id: true - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # 4.2.0 + uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # 4.3.0 with: dotnet-version: '8.x' @@ -50,10 +50,10 @@ jobs: - name: Install AWS Lambda .NET CLI Tools run: dotnet tool install -g Amazon.Lambda.Tools - - name: Deploy Core Stack + - name: Deploy Stack run: | cd libraries/tests/e2e/infra - cdk deploy --require-approval never + cdk deploy --all --require-approval never deploy-aot-stack: strategy: @@ -70,14 +70,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 # v4.0.3 with: role-to-assume: ${{ secrets.E2E_DEPLOY_ROLE }} aws-region: us-east-1 mask-aws-account-id: true - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # 4.2.0 + uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # 4.3.0 with: dotnet-version: '8.x' @@ -90,35 +90,39 @@ jobs: - name: Deploy AOT Stack run: | cd libraries/tests/e2e/infra-aot - cdk deploy -c architecture=${{ matrix.arch }} --require-approval never + cdk deploy --all -c architecture=${{ matrix.arch }} --require-approval never run-tests: + strategy: + matrix: + utility: [core, idempotency] runs-on: ubuntu-latest needs: [deploy-stack,deploy-aot-stack] + steps: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 # v4.0.3 with: role-to-assume: ${{ secrets.E2E_DEPLOY_ROLE }} aws-region: us-east-1 mask-aws-account-id: true - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # 4.2.0 + uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # 4.3.0 with: dotnet-version: '8.x' - - name: Run Core Tests + - name: Run Tests run: | - cd libraries/tests/e2e/functions/core + cd libraries/tests/e2e/functions/${{ matrix.utility }} dotnet test --filter Category!=AOT - - name: Run Core AOT Tests + - name: Run AOT Tests run: | - cd libraries/tests/e2e/functions/core + cd libraries/tests/e2e/functions/${{ matrix.utility }} dotnet test --filter Category=AOT destroy-stack: @@ -130,7 +134,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 # v4.0.3 with: role-to-assume: ${{ secrets.E2E_DEPLOY_ROLE }} aws-region: us-east-1 @@ -142,10 +146,10 @@ jobs: - name: Install AWS Lambda .NET CLI Tools run: dotnet tool install -g Amazon.Lambda.Tools - - name: Destroy Core Stack + - name: Destroy Stack run: | cd libraries/tests/e2e/infra - cdk destroy --force + cdk destroy --all --force destroy-aot-stack: strategy: @@ -164,7 +168,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 # v4.0.3 with: role-to-assume: ${{ secrets.E2E_DEPLOY_ROLE }} aws-region: us-east-1 @@ -176,8 +180,8 @@ jobs: - name: Install AWS Lambda .NET CLI Tools run: dotnet tool install -g Amazon.Lambda.Tools - - name: Destroy arm64 AOT Core Stack + - name: Destroy arm64 AOT Stack run: | cd libraries/tests/e2e/infra-aot - cdk destroy -c architecture=${{ matrix.arch }} --force + cdk destroy --all -c architecture=${{ matrix.arch }} --force diff --git a/.github/workflows/ossf_scorecard.yml b/.github/workflows/ossf_scorecard.yml new file mode 100644 index 00000000..f5667321 --- /dev/null +++ b/.github/workflows/ossf_scorecard.yml @@ -0,0 +1,48 @@ +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + schedule: + - cron: "0 9 * * *" + push: + branches: [main] + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + # environment: scorecard + permissions: + security-events: write # update code-scanning dashboard + id-token: write # confirm org+repo identity before publish results + + steps: + - name: "Checkout code" + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true # publish to OSSF Scorecard REST API + # repo_token: ${{ secrets.SCORECARD_TOKEN }} # read-only fine-grained token to read branch protection settings + + - name: "Upload results" + uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3.27.9 + with: + sarif_file: results.sarif diff --git a/.github/workflows/reusable_publish_docs.yml b/.github/workflows/reusable_publish_docs.yml index 0cfe197a..c068a035 100644 --- a/.github/workflows/reusable_publish_docs.yml +++ b/.github/workflows/reusable_publish_docs.yml @@ -41,7 +41,7 @@ jobs: - name: Install poetry run: pipx install poetry - name: Set up Python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: "3.12" cache: "poetry" @@ -67,7 +67,7 @@ jobs: poetry run mike set-default --push latest - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 # v4.0.3 with: aws-region: us-east-1 role-to-assume: ${{ secrets.AWS_DOCS_ROLE_ARN }} @@ -95,7 +95,7 @@ jobs: brew install -f docfx --skip-cask-deps --ignore-dependencies docfx apidocs/docfx.json - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 # v4.0.3 with: aws-region: us-east-1 role-to-assume: ${{ secrets.AWS_DOCS_ROLE_ARN }} diff --git a/.github/workflows/secure_workflows.yml b/.github/workflows/secure_workflows.yml index 0b4941da..fff04f91 100644 --- a/.github/workflows/secure_workflows.yml +++ b/.github/workflows/secure_workflows.yml @@ -16,7 +16,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Ensure 3rd party workflows have SHA pinned - uses: zgosalvez/github-actions-ensure-sha-pinned-actions@c3a2b64f69b7a1542a68f44d9edbd9ec3fc1455e # v3.0.20 + uses: zgosalvez/github-actions-ensure-sha-pinned-actions@6eb1abde32fed00453b0d03497f4ba4fecba146d # v3.0.21 with: # Trusted GitHub Actions and/or organizations allowlist: | diff --git a/docs/Dockerfile b/docs/Dockerfile index 5f90260a..4f576cc6 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,3 +1,3 @@ # v9.1.18 -FROM squidfunk/mkdocs-material@sha256:41942f7a2f5163aacd0e866e076d95db4f26550b97d76c1594c04250cbb580e9 +FROM squidfunk/mkdocs-material@sha256:c62453b1ba229982c6325a71165c1a3007c11bd3dd470e7a1446c5783bd145b4 RUN pip install mkdocs-git-revision-date-plugin diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 897702c4..e6cefa3d 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -14,6 +14,7 @@ The idempotency utility provides a simple solution to convert your Lambda functi * Select a subset of the event as the idempotency key using [JMESPath](https://jmespath.org/) expressions * Set a time window in which records with the same payload should be considered duplicates * Expires in-progress executions if the Lambda function times out halfway through +* Ahead-of-Time compilation to native code support [AOT](https://docs.aws.amazon.com/lambda/latest/dg/dotnet-native-aot.html) from version 1.3.0 ## Terminology @@ -821,10 +822,76 @@ Data would then be stored in DynamoDB like this: | idempotency#MyLambdaFunction | 2b2cdb5f86361e97b4383087c1ffdf27 | 1636549571 | COMPLETED | {"id": 527212, "message": "success"} | | idempotency#MyLambdaFunction | f091d2527ad1c78f05d54cc3f363be80 | 1636549585 | IN_PROGRESS | | + +## AOT Support + +Native AOT trims your application code as part of the compilation to ensure that the binary is as small as possible. .NET 8 for Lambda provides improved trimming support compared to previous versions of .NET. + +### WithJsonSerializationContext() + +To use Idempotency utility with AOT support you first need to add `WithJsonSerializationContext()` to your `Idempotency` configuration. + +This ensures that when serializing your payload, the utility uses the correct serialization context. + +In the example below, we use the default `LambdaFunctionJsonSerializerContext`: + +```csharp +Idempotency.Configure(builder => +builder.WithJsonSerializationContext(LambdaFunctionJsonSerializerContext.Default))); + +``` + +Full example: + +```csharp hl_lines="8" +public static class Function +{ + private static async Task Main() + { + var tableName = Environment.GetEnvironmentVariable("IDEMPOTENCY_TABLE_NAME"); + Idempotency.Configure(builder => + builder + .WithJsonSerializationContext(LambdaFunctionJsonSerializerContext.Default) + .WithOptions(optionsBuilder => optionsBuilder + .WithExpiration(TimeSpan.FromHours(1))) + .UseDynamoDb(storeBuilder => storeBuilder + .WithTableName(tableName) + )); + + Func handler = FunctionHandler; + await LambdaBootstrapBuilder.Create(handler, + new SourceGeneratorLambdaJsonSerializer()) + .Build() + .RunAsync(); + } + + [Idempotent] + public static APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest apigwProxyEvent, + ILambdaContext context) + { + return new APIGatewayProxyResponse + { + Body = JsonSerializer.Serialize(response, typeof(Response), LambdaFunctionJsonSerializerContext.Default), + StatusCode = 200, + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } +} + +[JsonSerializable(typeof(APIGatewayProxyRequest))] +[JsonSerializable(typeof(APIGatewayProxyResponse))] +[JsonSerializable(typeof(Response))] +public partial class LambdaFunctionJsonSerializerContext : JsonSerializerContext +{ +} +``` + ## Testing your code The idempotency utility provides several routes to test your code. +You can check our Integration tests which use [TestContainers](https://testcontainers.com/modules/dynamodb/){:target="_blank"} with a local DynamoDB instance to test the idempotency utility. Or our end-to-end tests which use the AWS SDK to interact with a real DynamoDB table. + ### 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. diff --git a/libraries/AWS.Lambda.Powertools.sln b/libraries/AWS.Lambda.Powertools.sln index 05638eb5..72aea967 100644 --- a/libraries/AWS.Lambda.Powertools.sln +++ b/libraries/AWS.Lambda.Powertools.sln @@ -47,13 +47,13 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infra", "Infra", "{93DEAC72-245F-4FC9-A7B5-DAE7EF7E1AB7}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Functions", "Functions", "{CDAE55EB-9438-4F54-B7ED-931D64324D5F}" + ProjectSection(SolutionItems) = preProject + tests\e2e\functions\payload.json = tests\e2e\functions\payload.json + EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infra", "tests\e2e\infra\Infra.csproj", "{AA532674-A61C-41E6-8F9A-ED53D79AF1EC}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{AAFA39E9-66A3-4B9A-AFE9-EAF74A85A7F0}" - ProjectSection(SolutionItems) = preProject - tests\e2e\functions\core\payload.json = tests\e2e\functions\core\payload.json - EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestUtils", "tests\e2e\functions\TestUtils\TestUtils.csproj", "{3C6162D7-0162-4BC2-BBF5-0554539A81CD}" EndProject @@ -83,6 +83,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AOT-Function", "tests\e2e\f EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InfraAot", "tests\e2e\infra-aot\InfraAot.csproj", "{24AC34AD-AEC9-4CFB-BB01-C3C81938AB95}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InfraShared", "tests\e2e\InfraShared\InfraShared.csproj", "{D303B458-9D84-4DDF-8781-2C0211672329}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Idempotency", "Idempotency", "{FB2C7DA3-6FCE-429D-86F9-5775D0231EC6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Function", "tests\e2e\functions\idempotency\Function\src\Function\Function.csproj", "{9AF99F6D-E8E7-443F-A965-D55B8E388836}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Function.Tests", "tests\e2e\functions\idempotency\Function\test\Function.Tests\Function.Tests.csproj", "{FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AOT-FunctionPayloadSubsetTest", "tests\e2e\functions\idempotency\AOT-Function\src\AOT-FunctionPayloadSubsetTest\AOT-FunctionPayloadSubsetTest.csproj", "{ACA789EA-BD38-490B-A7F8-6A3A86985025}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AOT-FunctionHandlerTest", "tests\e2e\functions\idempotency\AOT-Function\src\AOT-FunctionHandlerTest\AOT-FunctionHandlerTest.csproj", "{E71C48D2-AD56-4177-BBD7-6BB859A40C92}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AOT-FunctionMethodAttributeTest", "tests\e2e\functions\idempotency\AOT-Function\src\AOT-FunctionMethodAttributeTest\AOT-FunctionMethodAttributeTest.csproj", "{CC8CFF43-DC72-464C-A42D-55E023DE8500}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -432,6 +446,78 @@ Global {24AC34AD-AEC9-4CFB-BB01-C3C81938AB95}.Release|x64.Build.0 = Release|Any CPU {24AC34AD-AEC9-4CFB-BB01-C3C81938AB95}.Release|x86.ActiveCfg = Release|Any CPU {24AC34AD-AEC9-4CFB-BB01-C3C81938AB95}.Release|x86.Build.0 = Release|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Debug|x64.ActiveCfg = Debug|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Debug|x64.Build.0 = Debug|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Debug|x86.ActiveCfg = Debug|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Debug|x86.Build.0 = Debug|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Release|Any CPU.Build.0 = Release|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Release|x64.ActiveCfg = Release|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Release|x64.Build.0 = Release|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Release|x86.ActiveCfg = Release|Any CPU + {D303B458-9D84-4DDF-8781-2C0211672329}.Release|x86.Build.0 = Release|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Debug|x64.ActiveCfg = Debug|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Debug|x64.Build.0 = Debug|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Debug|x86.ActiveCfg = Debug|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Debug|x86.Build.0 = Debug|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Release|Any CPU.Build.0 = Release|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Release|x64.ActiveCfg = Release|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Release|x64.Build.0 = Release|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Release|x86.ActiveCfg = Release|Any CPU + {9AF99F6D-E8E7-443F-A965-D55B8E388836}.Release|x86.Build.0 = Release|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Debug|x64.ActiveCfg = Debug|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Debug|x64.Build.0 = Debug|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Debug|x86.ActiveCfg = Debug|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Debug|x86.Build.0 = Debug|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Release|Any CPU.Build.0 = Release|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Release|x64.ActiveCfg = Release|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Release|x64.Build.0 = Release|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Release|x86.ActiveCfg = Release|Any CPU + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC}.Release|x86.Build.0 = Release|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Debug|x64.ActiveCfg = Debug|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Debug|x64.Build.0 = Debug|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Debug|x86.ActiveCfg = Debug|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Debug|x86.Build.0 = Debug|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Release|Any CPU.Build.0 = Release|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Release|x64.ActiveCfg = Release|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Release|x64.Build.0 = Release|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Release|x86.ActiveCfg = Release|Any CPU + {ACA789EA-BD38-490B-A7F8-6A3A86985025}.Release|x86.Build.0 = Release|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Debug|x64.ActiveCfg = Debug|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Debug|x64.Build.0 = Debug|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Debug|x86.ActiveCfg = Debug|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Debug|x86.Build.0 = Debug|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Release|Any CPU.Build.0 = Release|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Release|x64.ActiveCfg = Release|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Release|x64.Build.0 = Release|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Release|x86.ActiveCfg = Release|Any CPU + {E71C48D2-AD56-4177-BBD7-6BB859A40C92}.Release|x86.Build.0 = Release|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Debug|x64.ActiveCfg = Debug|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Debug|x64.Build.0 = Debug|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Debug|x86.ActiveCfg = Debug|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Debug|x86.Build.0 = Debug|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Release|Any CPU.Build.0 = Release|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Release|x64.ActiveCfg = Release|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Release|x64.Build.0 = Release|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Release|x86.ActiveCfg = Release|Any CPU + {CC8CFF43-DC72-464C-A42D-55E023DE8500}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution @@ -470,5 +556,12 @@ Global {8DDAFE37-ED59-4710-9415-8EBA44CC6437} = {3C9FA701-31FF-4747-B324-E0D252EAFD63} {8DDED681-AE8D-45EB-A22E-2FFB88620F9B} = {3C9FA701-31FF-4747-B324-E0D252EAFD63} {24AC34AD-AEC9-4CFB-BB01-C3C81938AB95} = {93DEAC72-245F-4FC9-A7B5-DAE7EF7E1AB7} + {D303B458-9D84-4DDF-8781-2C0211672329} = {93DEAC72-245F-4FC9-A7B5-DAE7EF7E1AB7} + {FB2C7DA3-6FCE-429D-86F9-5775D0231EC6} = {CDAE55EB-9438-4F54-B7ED-931D64324D5F} + {9AF99F6D-E8E7-443F-A965-D55B8E388836} = {FB2C7DA3-6FCE-429D-86F9-5775D0231EC6} + {FBCE2C8A-2F64-4B62-8CF1-D4A14C19A5CC} = {FB2C7DA3-6FCE-429D-86F9-5775D0231EC6} + {ACA789EA-BD38-490B-A7F8-6A3A86985025} = {FB2C7DA3-6FCE-429D-86F9-5775D0231EC6} + {E71C48D2-AD56-4177-BBD7-6BB859A40C92} = {FB2C7DA3-6FCE-429D-86F9-5775D0231EC6} + {CC8CFF43-DC72-464C-A42D-55E023DE8500} = {FB2C7DA3-6FCE-429D-86F9-5775D0231EC6} EndGlobalSection EndGlobal diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/Idempotency.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/Idempotency.cs index e8de9f52..0685cb16 100644 --- a/libraries/src/AWS.Lambda.Powertools.Idempotency/Idempotency.cs +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/Idempotency.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 @@ -14,8 +14,10 @@ */ using System; +using System.Text.Json.Serialization; using Amazon.Lambda.Core; using AWS.Lambda.Powertools.Common; +using AWS.Lambda.Powertools.Idempotency.Internal.Serializers; using AWS.Lambda.Powertools.Idempotency.Persistence; namespace AWS.Lambda.Powertools.Idempotency; @@ -27,7 +29,7 @@ namespace AWS.Lambda.Powertools.Idempotency; /// Use it before the function handler get called. /// Example: Idempotency.Configure(builder => builder.WithPersistenceStore(...)); /// -public sealed class Idempotency +public sealed class Idempotency { /// /// The general configurations for the idempotency @@ -47,6 +49,7 @@ internal Idempotency(IPowertoolsConfigurations powertoolsConfigurations) { powertoolsConfigurations.SetExecutionEnvironment(this); } + /// /// Set Idempotency options /// @@ -68,7 +71,7 @@ private void SetPersistenceStore(BasePersistenceStore persistenceStore) /// /// Holds the idempotency Instance: /// - public static Idempotency Instance { get; } = new(PowertoolsConfigurations.Instance); + internal static Idempotency Instance { get; } = new(PowertoolsConfigurations.Instance); /// /// Use this method to configure persistence layer (mandatory) and idempotency options (optional) @@ -90,7 +93,7 @@ public static void Configure(Action configurationAction) /// Holds ILambdaContext /// public ILambdaContext LambdaContext { get; private set; } - + /// /// Can be used in a method which is not the handler to capture the Lambda context, /// to calculate the remaining time before the invocation times out. @@ -177,5 +180,18 @@ public IdempotencyBuilder WithOptions(IdempotencyOptions options) Options = options; return this; } + +#if NET8_0_OR_GREATER + /// + /// Set Customer JsonSerializerContext to append to IdempotencySerializationContext + /// + /// + /// IdempotencyBuilder + public IdempotencyBuilder WithJsonSerializationContext(JsonSerializerContext context) + { + IdempotencySerializer.AddTypeInfoResolver(context); + return this; + } +#endif } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotencyOptionsBuilder.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotencyOptionsBuilder.cs index 721ebf29..1371f8e7 100644 --- a/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotencyOptionsBuilder.cs +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotencyOptionsBuilder.cs @@ -11,26 +11,32 @@ public class IdempotencyOptionsBuilder /// Default maximum number of items in the local cache. /// private readonly int _localCacheMaxItems = 256; + /// /// Local cache enabled /// private bool _useLocalCache; + /// /// Default expiration in seconds. /// private long _expirationInSeconds = 60 * 60; // 1 hour + /// /// Event key JMESPath expression. /// private string _eventKeyJmesPath; + /// /// Payload validation JMESPath expression. /// private string _payloadValidationJmesPath; + /// /// Throw exception if no idempotency key is found. /// private bool _throwOnNoIdempotencyKey; + /// /// Default Hash function /// @@ -107,7 +113,7 @@ public IdempotencyOptionsBuilder WithThrowOnNoIdempotencyKey(bool throwOnNoIdemp /// the instance of the builder (to chain operations) public IdempotencyOptionsBuilder WithExpiration(TimeSpan duration) { - _expirationInSeconds = (long) duration.TotalSeconds; + _expirationInSeconds = (long)duration.TotalSeconds; return this; } @@ -116,9 +122,15 @@ public IdempotencyOptionsBuilder WithExpiration(TimeSpan duration) /// /// Can be any algorithm supported by HashAlgorithm.Create /// the instance of the builder (to chain operations) +#if NET8_0_OR_GREATER + [Obsolete("Idempotency uses MD5 and does not support other hash algorithms.")] +#endif public IdempotencyOptionsBuilder WithHashFunction(string hashFunction) { +#if NET6_0 + // for backward compability keep this code in .net 6 _hashFunction = hashFunction; +#endif return this; } } \ 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 9f1683fa..fcd19f98 100644 --- a/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotentAttribute.cs +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/IdempotentAttribute.cs @@ -23,6 +23,7 @@ using AWS.Lambda.Powertools.Common; using AWS.Lambda.Powertools.Idempotency.Exceptions; using AWS.Lambda.Powertools.Idempotency.Internal; +using AWS.Lambda.Powertools.Idempotency.Internal.Serializers; namespace AWS.Lambda.Powertools.Idempotency; @@ -62,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 /// @@ -89,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"); @@ -127,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"); @@ -151,7 +157,7 @@ private static JsonDocument GetPayload(AspectEventArgs eventArgs) // 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; + payload = args is not null && args.Any() ? JsonDocument.Parse(IdempotencySerializer.Serialize(args[0], typeof(object))) : null; } else { @@ -160,7 +166,7 @@ private static JsonDocument GetPayload(AspectEventArgs eventArgs) if (parameter != null) { // set payload to the value of the parameter - payload = JsonDocument.Parse(JsonSerializer.Serialize(args[Array.IndexOf(eventArgsMethod.GetParameters(), parameter)])); + payload = JsonDocument.Parse(IdempotencySerializer.Serialize(args[Array.IndexOf(eventArgsMethod.GetParameters(), parameter)], typeof(object))); } } diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/IdempotencyAspectHandler.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/IdempotencyAspectHandler.cs index a78ce309..a8d7da73 100644 --- a/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/IdempotencyAspectHandler.cs +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/IdempotencyAspectHandler.cs @@ -18,6 +18,7 @@ using System.Threading.Tasks; using Amazon.Lambda.Core; using AWS.Lambda.Powertools.Idempotency.Exceptions; +using AWS.Lambda.Powertools.Idempotency.Internal.Serializers; using AWS.Lambda.Powertools.Idempotency.Persistence; namespace AWS.Lambda.Powertools.Idempotency.Internal; @@ -49,11 +50,13 @@ internal class IdempotencyAspectHandler /// /// /// + /// /// /// public IdempotencyAspectHandler( Func> target, string functionName, + string keyPrefix, JsonDocument payload, ILambdaContext lambdaContext) { @@ -61,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); } /// @@ -184,7 +187,7 @@ private Task HandleForStatus(DataRecord record) default: try { - var result = JsonSerializer.Deserialize(record.ResponseData!); + var result = IdempotencySerializer.Deserialize(record.ResponseData!); if (result is null) { throw new IdempotencyPersistenceLayerException("Unable to cast function response as " + typeof(T).Name); diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/Serializers/IdempotencySerializationContext.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/Serializers/IdempotencySerializationContext.cs new file mode 100644 index 00000000..6f77b9cf --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/Serializers/IdempotencySerializationContext.cs @@ -0,0 +1,32 @@ +/* + * 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.Text.Json.Serialization; + +namespace AWS.Lambda.Powertools.Idempotency.Internal.Serializers; + +#if NET8_0_OR_GREATER + + +/// +/// The source generated JsonSerializerContext to be used to Serialize Idempotency types +/// +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(object))] +public partial class IdempotencySerializationContext : JsonSerializerContext +{ + +} +#endif \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/Serializers/IdempotencySerializer.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/Serializers/IdempotencySerializer.cs new file mode 100644 index 00000000..823603ee --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Idempotency/Internal/Serializers/IdempotencySerializer.cs @@ -0,0 +1,142 @@ +/* + * 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.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using AWS.Lambda.Powertools.Common.Utils; + +namespace AWS.Lambda.Powertools.Idempotency.Internal.Serializers; + +/// +/// Serializer for Idempotency. +/// +internal static class IdempotencySerializer +{ + private static JsonSerializerOptions _jsonOptions; + + static IdempotencySerializer() + { + BuildDefaultOptions(); + } + + /// + /// Builds the default JsonSerializerOptions. + /// + private static void BuildDefaultOptions() + { + _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; +#if NET8_0_OR_GREATER + if (!RuntimeFeatureWrapper.IsDynamicCodeSupported) + { + _jsonOptions.TypeInfoResolverChain.Add(IdempotencySerializationContext.Default); + } +#endif + } + +#if NET8_0_OR_GREATER + + /// + /// Adds a JsonTypeInfoResolver to the JsonSerializerOptions. + /// + /// The JsonTypeInfoResolver to add. + /// + /// This method is only available in .NET 8.0 and later versions. + /// + internal static void AddTypeInfoResolver(JsonSerializerContext context) + { + BuildDefaultOptions(); + _jsonOptions.TypeInfoResolverChain.Add(context); + } + + /// + /// Gets the JsonTypeInfo for a given type. + /// + /// The type to get information for. + /// The JsonTypeInfo for the specified type, or null if not found. + internal static JsonTypeInfo GetTypeInfo(Type type) + { + var typeInfo = _jsonOptions.TypeInfoResolver?.GetTypeInfo(type, _jsonOptions); + if (typeInfo == null) + { + throw new SerializationException( + $"Type {type} is not known to the serializer. Ensure it's included in the JsonSerializerContext."); + } + + return typeInfo; + } + + internal static void SetJsonOptions(JsonSerializerOptions options) + { + _jsonOptions = options; + } +#endif + + /// + /// Serializes the specified object to a JSON string. + /// + /// The object to serialize. + /// The type of the object to serialize. + /// A JSON string representation of the object. + internal static string Serialize(object value, Type inputType) + { +#if NET6_0 + return JsonSerializer.Serialize(value, _jsonOptions); +#else + if (RuntimeFeatureWrapper.IsDynamicCodeSupported) + { +#pragma warning disable + return JsonSerializer.Serialize(value, _jsonOptions); + } + + var typeInfo = GetTypeInfo(inputType); + if (typeInfo == null) + { + throw new SerializationException( + $"Type {inputType} is not known to the serializer. Ensure it's included in the JsonSerializerContext."); + } + + return JsonSerializer.Serialize(value, typeInfo); +#endif + } + + /// + /// Deserializes the specified JSON string to an object of type T. + /// + /// The type of the object to deserialize to. + /// The JSON string to deserialize. + /// An object of type T represented by the JSON string. + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "False positive")] + [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "False positive")] + internal static T Deserialize(string value) + { +#if NET6_0 + return JsonSerializer.Deserialize(value,_jsonOptions); +#else + if (RuntimeFeatureWrapper.IsDynamicCodeSupported) + { + return JsonSerializer.Deserialize(value, _jsonOptions); + } + + return (T)JsonSerializer.Deserialize(value, GetTypeInfo(typeof(T))); +#endif + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs b/libraries/src/AWS.Lambda.Powertools.Idempotency/Persistence/BasePersistenceStore.cs index c71dc9d9..3cf9b1f6 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 @@ -21,6 +21,7 @@ using AWS.Lambda.Powertools.Common; using AWS.Lambda.Powertools.Idempotency.Exceptions; using AWS.Lambda.Powertools.Idempotency.Internal; +using AWS.Lambda.Powertools.Idempotency.Internal.Serializers; using AWS.Lambda.Powertools.JMESPath; namespace AWS.Lambda.Powertools.Idempotency.Persistence; @@ -36,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 /// @@ -56,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 = keyPrefix; + } + else { - _functionName += "." + functionName; + 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; } @@ -95,7 +108,7 @@ internal void Configure(IdempotencyOptions options, string functionName, LRUCach /// The current date time public virtual async Task SaveSuccess(JsonDocument data, object result, DateTimeOffset now) { - var responseJson = JsonSerializer.Serialize(result); + var responseJson = IdempotencySerializer.Serialize(result, typeof(object)); var record = new DataRecord( GetHashedIdempotencyKey(data), DataRecord.DataRecordStatus.COMPLETED, @@ -117,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) { @@ -136,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 /// @@ -151,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. /// @@ -181,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 @@ -197,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 /// @@ -214,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 /// @@ -227,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 /// @@ -243,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 /// @@ -258,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 /// @@ -284,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); @@ -297,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); @@ -312,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 /// @@ -323,14 +339,20 @@ private static bool IsMissingIdempotencyKey(JsonElement data) /// internal string GenerateHash(JsonElement data) { +#if NET8_0_OR_GREATER + // 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; } @@ -344,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/src/AWS.Lambda.Powertools.JMESPath/Serializers/JMESPathSerializationContext.cs b/libraries/src/AWS.Lambda.Powertools.JMESPath/Serializers/JMESPathSerializationContext.cs new file mode 100644 index 00000000..96611cde --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.JMESPath/Serializers/JMESPathSerializationContext.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 System.Text.Json; +using System.Text.Json.Serialization; + +namespace AWS.Lambda.Powertools.JMESPath.Serializers; + +#if NET8_0_OR_GREATER + +/// +/// The source generated JsonSerializerContext to be used to Serialize JMESPath types +/// +[JsonSourceGenerationOptions(WriteIndented = false)] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(object))] +[JsonSerializable(typeof(decimal))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(JsonElement))] +public partial class JmesPathSerializationContext : JsonSerializerContext +{ +} + +#endif \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.JMESPath/Serializers/JMESPathSerializer.cs b/libraries/src/AWS.Lambda.Powertools.JMESPath/Serializers/JMESPathSerializer.cs new file mode 100644 index 00000000..b599145f --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.JMESPath/Serializers/JMESPathSerializer.cs @@ -0,0 +1,57 @@ +/* + * 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.Text.Json; + +namespace AWS.Lambda.Powertools.JMESPath.Serializers; + +/// +/// Class used to serialize JMESPath types +/// +internal static class JmesPathSerializer +{ + /// + /// Serializes the specified value. + /// + /// The value. + /// Type of the input. + /// System.String. + internal static string Serialize(object value, Type inputType) + { +#if NET6_0 + return JsonSerializer.Serialize(value); +#else + + return JsonSerializer.Serialize(value, inputType, JmesPathSerializationContext.Default); +#endif + } + + /// + /// Deserializes the specified value. + /// + /// + /// The value. + /// T. + internal static T Deserialize(string value) + { +#if NET6_0 + return JsonSerializer.Deserialize(value); +#else + + return (T)JsonSerializer.Deserialize(value, typeof(T), JmesPathSerializationContext.Default); +#endif + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/DecimalValue.cs b/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/DecimalValue.cs index b03ff5db..563fe015 100644 --- a/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/DecimalValue.cs +++ b/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/DecimalValue.cs @@ -14,8 +14,8 @@ */ using System; -using System.Text.Json; using AWS.Lambda.Powertools.JMESPath.Expressions; +using AWS.Lambda.Powertools.JMESPath.Serializers; namespace AWS.Lambda.Powertools.JMESPath.Values; @@ -98,7 +98,6 @@ public IExpression GetExpression() public override string ToString() { - var s = JsonSerializer.Serialize(_value); - return s; + return JmesPathSerializer.Serialize(_value, typeof(decimal)); } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/DoubleValue.cs b/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/DoubleValue.cs index 2962f28d..0d999eb9 100644 --- a/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/DoubleValue.cs +++ b/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/DoubleValue.cs @@ -16,6 +16,7 @@ using System; using System.Text.Json; using AWS.Lambda.Powertools.JMESPath.Expressions; +using AWS.Lambda.Powertools.JMESPath.Serializers; namespace AWS.Lambda.Powertools.JMESPath.Values; @@ -103,7 +104,6 @@ public IExpression GetExpression() public override string ToString() { - var s = JsonSerializer.Serialize(_value); - return s; + return JmesPathSerializer.Serialize(_value, typeof(double)); } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/JsonElementValue.cs b/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/JsonElementValue.cs index b215d339..0137a87f 100644 --- a/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/JsonElementValue.cs +++ b/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/JsonElementValue.cs @@ -18,6 +18,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using AWS.Lambda.Powertools.JMESPath.Expressions; +using AWS.Lambda.Powertools.JMESPath.Serializers; namespace AWS.Lambda.Powertools.JMESPath.Values; @@ -203,13 +204,22 @@ public bool TryGetDouble(out double value) public bool TryGetProperty(string propertyName, out IValue property) { var r = _element.TryGetProperty(propertyName, out var prop); - - property = prop.ValueKind == JsonValueKind.String && IsJsonValid(prop.GetString()) - ? new JsonElementValue(JsonNode.Parse(prop.GetString() ?? string.Empty).Deserialize()) - : new JsonElementValue(prop); - + property = CreateJsonElementValue(prop); return r; } + + JsonElementValue CreateJsonElementValue(JsonElement prop) + { + if (prop.ValueKind == JsonValueKind.String && IsJsonValid(prop.GetString())) + { + var jsonString = prop.GetString() ?? string.Empty; + var jsonElement = JmesPathSerializer.Deserialize(jsonString); + return new JsonElementValue(jsonElement); + } + + return new JsonElementValue(prop); + } + private static bool IsJsonValid(string json) { @@ -244,7 +254,6 @@ public IExpression GetExpression() public override string ToString() { - var s = JsonSerializer.Serialize(_element); - return s; + return JmesPathSerializer.Serialize(_element, typeof(JsonElement)); } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/ObjectValue.cs b/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/ObjectValue.cs index 86153435..cba85f1a 100644 --- a/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/ObjectValue.cs +++ b/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/ObjectValue.cs @@ -16,8 +16,8 @@ using System; using System.Collections.Generic; using System.Text; -using System.Text.Json; using AWS.Lambda.Powertools.JMESPath.Expressions; +using AWS.Lambda.Powertools.JMESPath.Serializers; namespace AWS.Lambda.Powertools.JMESPath.Values; @@ -44,7 +44,8 @@ private sealed class ObjectEnumerator : IObjectValueEnumerator public ObjectEnumerator(IDictionary value) { _value = value; - _enumerator = value.GetEnumerator(); + using var enumerator = value.GetEnumerator(); + _enumerator = enumerator; } public bool MoveNext() @@ -162,7 +163,7 @@ public override string ToString() first = false; } - buffer.Append(JsonSerializer.Serialize(property.Key)); + buffer.Append(JmesPathSerializer.Serialize(property.Key, property.Key.GetType())); buffer.Append(':'); buffer.Append(property.Value); } diff --git a/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/StringValue.cs b/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/StringValue.cs index 02fb2890..d9d1d721 100644 --- a/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/StringValue.cs +++ b/libraries/src/AWS.Lambda.Powertools.JMESPath/Values/StringValue.cs @@ -14,8 +14,8 @@ */ using System; -using System.Text.Json; using AWS.Lambda.Powertools.JMESPath.Expressions; +using AWS.Lambda.Powertools.JMESPath.Serializers; namespace AWS.Lambda.Powertools.JMESPath.Values; @@ -90,7 +90,6 @@ public IExpression GetExpression() public override string ToString() { - var s = JsonSerializer.Serialize(_value); - return s; + return JmesPathSerializer.Serialize(_value, typeof(string)); } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Tracing/Internal/Helpers.cs b/libraries/src/AWS.Lambda.Powertools.Tracing/Internal/Helpers.cs new file mode 100644 index 00000000..f124852d --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Tracing/Internal/Helpers.cs @@ -0,0 +1,24 @@ +using System; +using System.Text.RegularExpressions; + +namespace AWS.Lambda.Powertools.Tracing.Internal; + +/// +/// Helper class +/// +public static class Helpers +{ + /// + /// Sanitize a string by removing any characters that are not alphanumeric, whitespace, or one of the following: _ . : / % & # = + - @ + /// + /// + /// + public static string SanitizeString(string input) + { + // Define a regular expression pattern to match allowed characters + var pattern = @"[^a-zA-Z0-9\s_\.\:/%&#=+\-@]"; + + // Replace any character that does not match the pattern with an empty string, with a timeout + return Regex.Replace(input, pattern, string.Empty, RegexOptions.None, TimeSpan.FromMilliseconds(100)); + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Tracing/Internal/XRayRecorder.cs b/libraries/src/AWS.Lambda.Powertools.Tracing/Internal/XRayRecorder.cs index e192036e..53bface3 100644 --- a/libraries/src/AWS.Lambda.Powertools.Tracing/Internal/XRayRecorder.cs +++ b/libraries/src/AWS.Lambda.Powertools.Tracing/Internal/XRayRecorder.cs @@ -78,7 +78,9 @@ public XRayRecorder(IAWSXRayRecorder awsxRayRecorder, IPowertoolsConfigurations public void BeginSubsegment(string name) { if (_isLambda) - _awsxRayRecorder.BeginSubsegment(name); + { + _awsxRayRecorder.BeginSubsegment(Helpers.SanitizeString(name)); + } } /// diff --git a/libraries/src/AWS.Lambda.Powertools.Tracing/TracingAttribute.cs b/libraries/src/AWS.Lambda.Powertools.Tracing/TracingAttribute.cs index 1946fb9c..c144d038 100644 --- a/libraries/src/AWS.Lambda.Powertools.Tracing/TracingAttribute.cs +++ b/libraries/src/AWS.Lambda.Powertools.Tracing/TracingAttribute.cs @@ -15,7 +15,6 @@ using System; using AspectInjector.Broker; -using AWS.Lambda.Powertools.Common; using AWS.Lambda.Powertools.Tracing.Internal; namespace AWS.Lambda.Powertools.Tracing; @@ -114,8 +113,10 @@ public class TracingAttribute : Attribute /// /// Set custom segment name for the operation. /// The default is '## {MethodName}'. + /// + /// The logical name of the service that handled the request, up to 200 characters. + /// Names can contain Unicode letters, numbers, and whitespace, and the following symbols: \_, ., :, /, %, &, #, =, +, \\, -, @ /// - /// The name of the segment. public string SegmentName { get; set; } = ""; /// 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 00000000..f773411e --- /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/IdempotencyFunction.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyFunction.cs index 5c500f05..32485b09 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyFunction.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Handlers/IdempotencyFunction.cs @@ -21,6 +21,7 @@ using System.Threading.Tasks; using Amazon.DynamoDBv2; using Amazon.Lambda.APIGatewayEvents; +using AWS.Lambda.Powertools.Idempotency.Tests.Model; namespace AWS.Lambda.Powertools.Idempotency.Tests.Handlers; 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 4a404588..ed752060 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; 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 00000000..8c384e65 --- /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/IdempotencyTest.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/IdempotencyTest.cs index 018e672a..8e85d616 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/IdempotencyTest.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/IdempotencyTest.cs @@ -22,6 +22,7 @@ using Amazon.DynamoDBv2.Model; using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.TestUtilities; +using AWS.Lambda.Powertools.Idempotency.Internal.Serializers; using AWS.Lambda.Powertools.Idempotency.Tests.Handlers; using AWS.Lambda.Powertools.Idempotency.Tests.Persistence; using FluentAssertions; @@ -39,20 +40,17 @@ public IdempotencyTest(DynamoDbFixture fixture) _client = fixture.Client; _tableName = fixture.TableName; } - + [Fact] [Trait("Category", "Integration")] - public async Task EndToEndTest() + public async Task EndToEndTest() { var function = new IdempotencyFunction(_client); - - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - var request = JsonSerializer.Deserialize(await File.ReadAllTextAsync("./resources/apigw_event2.json"),options); - + + var request = + IdempotencySerializer.Deserialize( + await File.ReadAllTextAsync("./resources/apigw_event2.json")); + var response = await function.Handle(request); function.HandlerExecuted.Should().BeTrue(); @@ -61,7 +59,10 @@ public async Task EndToEndTest() var response2 = await function.Handle(request); function.HandlerExecuted.Should().BeFalse(); - JsonSerializer.Serialize(response).Should().Be(JsonSerializer.Serialize(response)); + IdempotencySerializer.Serialize(response, typeof(APIGatewayProxyResponse)).Should() + .Be(IdempotencySerializer.Serialize(response2, typeof(APIGatewayProxyResponse))); + + response.Body.Should().Contain("hello world"); response2.Body.Should().Contain("hello world"); var scanResponse = await _client.ScanAsync(new ScanRequest @@ -69,33 +70,30 @@ public async Task EndToEndTest() TableName = _tableName }); scanResponse.Count.Should().Be(1); - + // delete row dynamo var key = new Dictionary { ["id"] = new AttributeValue { S = "testFunction.GetPageContents#ff323c6f0c5ceb97eed49121babcec0f" } }; - await _client.DeleteItemAsync(new DeleteItemRequest{TableName = _tableName, Key = key}); + await _client.DeleteItemAsync(new DeleteItemRequest { TableName = _tableName, Key = key }); } - + [Fact] [Trait("Category", "Integration")] - public async Task EndToEndTestMethod() + public async Task EndToEndTestMethod() { var function = new IdempotencyFunctionMethodDecorated(_client); - - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - + var context = new TestLambdaContext { RemainingTime = TimeSpan.FromSeconds(30) }; - - var request = JsonSerializer.Deserialize(await File.ReadAllTextAsync("./resources/apigw_event2.json"),options); - + + var request = + IdempotencySerializer.Deserialize( + await File.ReadAllTextAsync("./resources/apigw_event2.json")); + var response = await function.Handle(request, context); function.MethodCalled.Should().BeTrue(); @@ -105,7 +103,8 @@ public async Task EndToEndTestMethod() function.MethodCalled.Should().BeFalse(); // Assert - JsonSerializer.Serialize(response).Should().Be(JsonSerializer.Serialize(response2)); + IdempotencySerializer.Serialize(response, typeof(APIGatewayProxyResponse)).Should() + .Be(IdempotencySerializer.Serialize(response2, typeof(APIGatewayProxyResponse))); response.Body.Should().Contain("hello world"); response2.Body.Should().Contain("hello world"); @@ -114,12 +113,12 @@ public async Task EndToEndTestMethod() TableName = _tableName }); scanResponse.Count.Should().Be(1); - + // delete row dynamo var key = new Dictionary { ["id"] = new AttributeValue { S = "testFunction.GetPageContents#ff323c6f0c5ceb97eed49121babcec0f" } }; - await _client.DeleteItemAsync(new DeleteItemRequest{TableName = _tableName, Key = key}); + await _client.DeleteItemAsync(new DeleteItemRequest { TableName = _tableName, Key = key }); } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotencySerializerTests.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotencySerializerTests.cs new file mode 100644 index 00000000..065c985c --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotencySerializerTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using AWS.Lambda.Powertools.Common.Utils; +using AWS.Lambda.Powertools.Idempotency.Internal.Serializers; +using AWS.Lambda.Powertools.Idempotency.Tests.Model; +using NSubstitute; +using Xunit; + +namespace AWS.Lambda.Powertools.Idempotency.Tests.Internal; + +public class IdempotencySerializerTests +{ + public IdempotencySerializerTests() + { +#if NET8_0_OR_GREATER + IdempotencySerializer.AddTypeInfoResolver(TestJsonSerializerContext.Default); +#endif + } + + [Fact] + public void Serialize_ValidObject_ReturnsJsonString() + { + // Arrange + var testObject = new TestClass { Id = 1, Name = "Test" }; + + // Act + var result = IdempotencySerializer.Serialize(testObject, typeof(TestClass)); + + // Assert + Assert.Contains("\"Id\":1", result); + Assert.Contains("\"Name\":\"Test\"", result); + } + + [Fact] + public void Deserialize_ValidJsonString_ReturnsObject() + { + // Arrange + var json = "{\"Id\":1,\"Name\":\"Test\"}"; + + // Act + var result = IdempotencySerializer.Deserialize(json); + + // Assert + Assert.Equal(1, result.Id); + Assert.Equal("Test", result.Name); + } + + [Fact] + public void BuildDefaultOptions_SetsCorrectProperties() + { + // Arrange & Act + var field = typeof(IdempotencySerializer).GetField("_jsonOptions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + var options = (JsonSerializerOptions)field!.GetValue(null); + + // Assert + Assert.True(options.PropertyNameCaseInsensitive); + } + +#if NET8_0_OR_GREATER + + [Fact] + public void GetTypeInfo_UnknownType_ThrowsException() + { + // Arrange + var mockResolver = Substitute.For(); + mockResolver.GetTypeInfo(typeof(TestClass), Arg.Any()) + .Returns((JsonTypeInfo)null); + + var options = new JsonSerializerOptions(); + options.TypeInfoResolver = mockResolver; + + // Use reflection to set the private _jsonOptions field + var field = typeof(IdempotencySerializer).GetField("_jsonOptions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + field!.SetValue(null, options); + + // Act & Assert + var exception = Assert.Throws(() => IdempotencySerializer.GetTypeInfo(typeof(TestClass))); + Assert.Equal("Type AWS.Lambda.Powertools.Idempotency.Tests.Model.TestClass is not known to the serializer. Ensure it's included in the JsonSerializerContext.", exception.Message); + } + + [Fact] + public void AddTypeInfoResolver_AddsResolverToChain() + { + // Arrange + var mockContext = new TestJsonSerializerContext(); + + // Act + IdempotencySerializer.AddTypeInfoResolver(mockContext); + + // Assert + // Use reflection to get the private _jsonOptions field + var field = typeof(IdempotencySerializer).GetField("_jsonOptions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + var options = (JsonSerializerOptions)field!.GetValue(null); + + Assert.Contains(mockContext, options!.TypeInfoResolverChain); + } + + [Fact] + public void Serialize_WhenDynamicCodeNotSupported_UsesDefaultTypeInfoResolver() + { + // Arrange + var testObject = new TestClass { Id = 1, Name = "Test" }; + RuntimeFeatureWrapper.SetIsDynamicCodeSupported(false); + + // Act + var result = IdempotencySerializer.Serialize(testObject, typeof(TestClass)); + + // Assert + Assert.Contains("\"Id\":1", result); + Assert.Contains("\"Name\":\"Test\"", result); + + // Reset + RuntimeFeatureWrapper.Reset(); + } + + [Fact] + public void Serialize_WhenDynamicCodeSupported_UsesTypeInfo() + { + // Arrange + var testObject = new TestClass { Id = 1, Name = "Test" }; + RuntimeFeatureWrapper.SetIsDynamicCodeSupported(true); + + // Act + var result = IdempotencySerializer.Serialize(testObject, typeof(TestClass)); + + // Assert + Assert.Contains("\"Id\":1", result); + Assert.Contains("\"Name\":\"Test\"", result); + + // Reset + RuntimeFeatureWrapper.Reset(); + } + + [Fact] + public void SetJsonOptions_UpdatesOptionsCorrectly() + { + // Arrange + var newOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = false + }; + + // Act + IdempotencySerializer.SetJsonOptions(newOptions); + + // Assert + var field = typeof(IdempotencySerializer).GetField("_jsonOptions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + var options = (JsonSerializerOptions)field!.GetValue(null); + Assert.Same(newOptions, options); + } + +#endif +} \ 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 d9836854..f83cfe34 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotentAspectTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/IdempotentAspectTests.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 @@ -25,11 +25,7 @@ using AWS.Lambda.Powertools.Idempotency.Tests.Model; using FluentAssertions; using NSubstitute; -using NSubstitute.ExceptionExtensions; using Xunit; -// ReSharper disable CompareOfFloatsByEqualityOperator - -[assembly: CollectionBehavior(DisableTestParallelization = true)] namespace AWS.Lambda.Powertools.Idempotency.Tests.Internal; @@ -45,25 +41,28 @@ public async Task Handle_WhenFirstCall_ShouldPutInStore(Type type) var store = Substitute.For(); Idempotency.Configure(builder => builder +#if NET8_0_OR_GREATER + .WithJsonSerializationContext(TestJsonSerializerContext.Default) +#endif .WithPersistenceStore(store) .WithOptions(optionsBuilder => optionsBuilder.WithEventKeyJmesPath("Id")) - ); - + ); + var context = new TestLambdaContext { RemainingTime = TimeSpan.FromSeconds(30) }; - + var function = Activator.CreateInstance(type) as IIdempotencyEnabledFunction; var product = new Product(42, "fake product", 12); - + //Act var basket = await function!.HandleTest(product, context); - + //Assert basket.Products.Count.Should().Be(1); function.HandlerExecuted.Should().BeTrue(); - + await store.Received().SaveInProgress( Arg.Is(t => t.ToString() == JsonSerializer.SerializeToDocument(product, new JsonSerializerOptions()).ToString()), @@ -86,14 +85,17 @@ public async Task Handle_WhenSecondCall_AndNotExpired_ShouldGetFromStore(Type ty var store = Substitute.For(); store.SaveInProgress(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => throw new IdempotencyItemAlreadyExistsException()); - + // GIVEN Idempotency.Configure(builder => builder +#if NET8_0_OR_GREATER + .WithJsonSerializationContext(TestJsonSerializerContext.Default) +#endif .WithPersistenceStore(store) .WithOptions(optionsBuilder => optionsBuilder.WithEventKeyJmesPath("Id")) - ); - + ); + var product = new Product(42, "fake product", 12); var basket = new Basket(product); var record = new DataRecord( @@ -105,19 +107,20 @@ public async Task Handle_WhenSecondCall_AndNotExpired_ShouldGetFromStore(Type ty store.GetRecord(Arg.Any(), Arg.Any()).Returns(record); var function = Activator.CreateInstance(type) as IIdempotencyEnabledFunction; - + // Act var resultBasket = await function!.HandleTest(product, new TestLambdaContext()); - + // Assert resultBasket.Should().Be(basket); function.HandlerExecuted.Should().BeFalse(); } - + [Theory] [InlineData(typeof(IdempotencyEnabledFunction))] [InlineData(typeof(IdempotencyEnabledSyncFunction))] - public async Task Handle_WhenSecondCall_AndStatusInProgress_ShouldThrowIdempotencyAlreadyInProgressException(Type type) + public async Task Handle_WhenSecondCall_AndStatusInProgress_ShouldThrowIdempotencyAlreadyInProgressException( + Type type) { // Arrange var store = Substitute.For(); @@ -125,12 +128,15 @@ public async Task Handle_WhenSecondCall_AndStatusInProgress_ShouldThrowIdempoten Idempotency.Configure(builder => builder .WithPersistenceStore(store) +#if NET8_0_OR_GREATER + .WithJsonSerializationContext(TestJsonSerializerContext.Default) +#endif .WithOptions(optionsBuilder => optionsBuilder.WithEventKeyJmesPath("Id")) - ); - + ); + store.SaveInProgress(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => throw new IdempotencyItemAlreadyExistsException()); - + var product = new Product(42, "fake product", 12); var basket = new Basket(product); var record = new DataRecord( @@ -145,30 +151,35 @@ public async Task Handle_WhenSecondCall_AndStatusInProgress_ShouldThrowIdempoten // Act var function = Activator.CreateInstance(type) as IIdempotencyEnabledFunction; Func act = async () => await function!.HandleTest(product, new TestLambdaContext()); - + // Assert await act.Should().ThrowAsync(); } - + [Theory] [InlineData(typeof(IdempotencyEnabledFunction))] [InlineData(typeof(IdempotencyEnabledSyncFunction))] - public async Task Handle_WhenSecondCall_InProgress_LambdaTimeout_Expired_ShouldThrowIdempotencyInconsistentStateException(Type type) + public async Task + Handle_WhenSecondCall_InProgress_LambdaTimeout_Expired_ShouldThrowIdempotencyInconsistentStateException( + Type type) { // Arrange var store = Substitute.For(); - + Idempotency.Configure(builder => builder .WithPersistenceStore(store) +#if NET8_0_OR_GREATER + .WithJsonSerializationContext(TestJsonSerializerContext.Default) +#endif .WithOptions(optionsBuilder => optionsBuilder.WithEventKeyJmesPath("Id")) ); - + store.SaveInProgress(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => throw new IdempotencyItemAlreadyExistsException()); var timestampInThePast = DateTimeOffset.Now.AddSeconds(-30).ToUnixTimeMilliseconds(); - + var product = new Product(42, "fake product", 12); var basket = new Basket(product); var record = new DataRecord( @@ -178,14 +189,14 @@ public async Task Handle_WhenSecondCall_InProgress_LambdaTimeout_Expired_ShouldT JsonSerializer.SerializeToNode(basket)!.ToString(), null, timestampInThePast); - + store.GetRecord(Arg.Any(), Arg.Any()) .Returns(record); - + // Act var function = Activator.CreateInstance(type) as IIdempotencyEnabledFunction; Func act = async () => await function!.HandleTest(product, new TestLambdaContext()); - + // Assert await act.Should().ThrowAsync(); } @@ -193,28 +204,31 @@ public async Task Handle_WhenSecondCall_InProgress_LambdaTimeout_Expired_ShouldT [Theory] [InlineData(typeof(IdempotencyWithErrorFunction))] [InlineData(typeof(IdempotencyWithErrorSyncFunction))] - public async Task Handle_WhenThrowException_ShouldDeleteRecord_AndThrowFunctionException(Type type) + public async Task Handle_WhenThrowException_ShouldDeleteRecord_AndThrowFunctionException(Type type) { // Arrange var store = Substitute.For(); - + Idempotency.Configure(builder => builder .WithPersistenceStore(store) +#if NET8_0_OR_GREATER + .WithJsonSerializationContext(TestJsonSerializerContext.Default) +#endif .WithOptions(optionsBuilder => optionsBuilder.WithEventKeyJmesPath("Id")) - ); - + ); + var function = Activator.CreateInstance(type) as IIdempotencyWithErrorFunction; var product = new Product(42, "fake product", 12); - + // Act Func act = async () => await function!.HandleTest(product, new TestLambdaContext()); - + // Assert await act.Should().ThrowAsync(); await store.Received().DeleteRecord(Arg.Any(), Arg.Any()); } - + [Theory] [InlineData(typeof(IdempotencyEnabledFunction))] [InlineData(typeof(IdempotencyEnabledSyncFunction))] @@ -222,18 +236,21 @@ public async Task Handle_WhenIdempotencyDisabled_ShouldJustRunTheFunction(Type t { // Arrange var store = Substitute.For(); - + Environment.SetEnvironmentVariable(Constants.IdempotencyDisabledEnv, "true"); - + Idempotency.Configure(builder => builder .WithPersistenceStore(store) +#if NET8_0_OR_GREATER + .WithJsonSerializationContext(TestJsonSerializerContext.Default) +#endif .WithOptions(optionsBuilder => optionsBuilder.WithEventKeyJmesPath("Id")) - ); - + ); + var function = Activator.CreateInstance(type) as IIdempotencyEnabledFunction; var product = new Product(42, "fake product", 12); - + // Act var basket = await function!.HandleTest(product, new TestLambdaContext()); @@ -249,13 +266,13 @@ public void Idempotency_Set_Execution_Environment_Context() // Arrange var assemblyName = "AWS.Lambda.Powertools.Idempotency"; var assemblyVersion = "1.0.0"; - + var env = Substitute.For(); env.GetAssemblyName(Arg.Any()).Returns(assemblyName); env.GetAssemblyVersion(Arg.Any()).Returns(assemblyVersion); var conf = new PowertoolsConfigurations(new SystemWrapper(env)); - + // Act var xRayRecorder = new Idempotency(conf); @@ -266,7 +283,7 @@ public void Idempotency_Set_Execution_Environment_Context() ); env.Received(1).GetEnvironmentVariable("AWS_EXECUTION_ENV"); - + Assert.NotNull(xRayRecorder); } @@ -281,16 +298,16 @@ public async Task Handle_WhenIdempotencyOnSubMethodAnnotated_AndFirstCall_Should { RemainingTime = TimeSpan.FromSeconds(30) }; - + // Act IdempotencyInternalFunction function = new IdempotencyInternalFunction(true); Product product = new Product(42, "fake product", 12); Basket resultBasket = function.HandleRequest(product, context); - + // Assert resultBasket.Products.Count.Should().Be(2); function.IsSubMethodCalled.Should().BeTrue(); - + await store .Received(1) .SaveInProgress( @@ -334,18 +351,21 @@ public void Handle_WhenIdempotencyOnSubMethodAnnotated_AndSecondCall_AndNotExpir resultBasket.Should().Be(basket); function.IsSubMethodCalled.Should().BeFalse(); } - + [Fact] - public void Handle_WhenIdempotencyOnSubMethodAnnotated_AndKeyJMESPath_ShouldPutInStoreWithKey() + public async Task Handle_WhenIdempotencyOnSubMethodAnnotated_AndKeyJMESPath_ShouldPutInStoreWithKey() { // Arrange var store = new InMemoryPersistenceStore(); Idempotency.Configure(builder => builder .WithPersistenceStore(store) +#if NET8_0_OR_GREATER + .WithJsonSerializationContext(TestJsonSerializerContext.Default) +#endif .WithOptions(optionsBuilder => optionsBuilder.WithEventKeyJmesPath("Id")) ); - + // Act IdempotencyInternalFunctionInternalKey function = new IdempotencyInternalFunctionInternalKey(); Product product = new Product(42, "fake product", 12); @@ -353,10 +373,50 @@ 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] - public void Handle_WhenIdempotencyOnSubMethodNotAnnotated_ShouldThrowException() + public void Handle_WhenIdempotencyOnSubMethodNotAnnotated_ShouldThrowException() { // Arrange var store = Substitute.For(); @@ -369,13 +429,13 @@ public void Handle_WhenIdempotencyOnSubMethodNotAnnotated_ShouldThrowException() 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() + public void Handle_WhenIdempotencyOnSubMethodVoid_ShouldThrowException() { // Arrange var store = Substitute.For(); diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/LRUCacheTests.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/LRUCacheTests.cs index 54ec5388..a34f5474 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/LRUCacheTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Internal/LRUCacheTests.cs @@ -127,7 +127,7 @@ public async Task TestMultiThreadingAsync() tasks.Add(Task.Run(() => StoreElement(cache, numOfOps))); } - await Task.WhenAll(tasks).ConfigureAwait(false); + await Task.WhenAll(tasks); for (var i = numOfOps - numOfThreads; i < numOfOps; i++) { diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Model/TestClass.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Model/TestClass.cs new file mode 100644 index 00000000..9f74a2a7 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Model/TestClass.cs @@ -0,0 +1,7 @@ +namespace AWS.Lambda.Powertools.Idempotency.Tests.Model; + +public class TestClass +{ + public int Id { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Model/TestJsonSerializerContext.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Model/TestJsonSerializerContext.cs new file mode 100644 index 00000000..600c5be1 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Model/TestJsonSerializerContext.cs @@ -0,0 +1,29 @@ +/* + * 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.Text.Json.Serialization; +using Amazon.Lambda.APIGatewayEvents; + +namespace AWS.Lambda.Powertools.Idempotency.Tests.Model; + +[JsonSerializable(typeof(Basket))] +[JsonSerializable(typeof(APIGatewayProxyRequest))] +[JsonSerializable(typeof(APIGatewayProxyResponse))] +[JsonSerializable(typeof(Product))] +[JsonSerializable(typeof(TestClass))] +public partial class TestJsonSerializerContext : JsonSerializerContext +{ + +} \ No newline at end of file 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 366aac77..0aed1440 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Persistence/BasePersistenceStoreTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/Persistence/BasePersistenceStoreTests.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 @@ -21,6 +21,7 @@ using Amazon.Lambda.APIGatewayEvents; using AWS.Lambda.Powertools.Idempotency.Exceptions; using AWS.Lambda.Powertools.Idempotency.Internal; +using AWS.Lambda.Powertools.Idempotency.Internal.Serializers; using AWS.Lambda.Powertools.Idempotency.Persistence; using AWS.Lambda.Powertools.Idempotency.Tests.Model; using FluentAssertions; @@ -35,6 +36,7 @@ class InMemoryPersistenceStore : BasePersistenceStore private string _validationHash = null; public DataRecord DataRecord; public int Status = -1; + public override Task GetRecord(string idempotencyKey) { Status = 0; @@ -68,18 +70,18 @@ public override Task DeleteRecord(string idempotencyKey) return Task.CompletedTask; } } - + [Fact] public async Task SaveInProgress_WhenDefaultConfig_ShouldSaveRecordInStore() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null); - + + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, null); + var now = DateTimeOffset.UtcNow; - + // Act await persistenceStore.SaveInProgress(JsonSerializer.SerializeToDocument(request)!, now, null); @@ -92,19 +94,19 @@ public async Task SaveInProgress_WhenDefaultConfig_ShouldSaveRecordInStore() dr.PayloadHash.Should().BeEmpty(); persistenceStore.Status.Should().Be(1); } - + [Fact] public async Task SaveInProgress_WhenRemainingTime_ShouldSaveRecordInStore() { // Arrange 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; - + var lambdaTimeoutMs = 30000; + // Act await persistenceStore.SaveInProgress(JsonSerializer.SerializeToDocument(request)!, now, lambdaTimeoutMs); @@ -125,16 +127,16 @@ public async Task SaveInProgress_WhenKeyJmesPathIsSet_ShouldSaveRecordInStore_Wi // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - + persistenceStore.Configure(new IdempotencyOptionsBuilder() .WithEventKeyJmesPath("powertools_json(Body).id") - .Build(), "myfunc"); + .Build(), "myfunc", null); var now = DateTimeOffset.UtcNow; - + // Act await persistenceStore.SaveInProgress(JsonSerializer.SerializeToDocument(request)!, now, null); - + // Assert var dr = persistenceStore.DataRecord; dr.Status.Should().Be(DataRecord.DataRecordStatus.INPROGRESS); @@ -144,23 +146,24 @@ public async Task SaveInProgress_WhenKeyJmesPathIsSet_ShouldSaveRecordInStore_Wi dr.PayloadHash.Should().BeEmpty(); persistenceStore.Status.Should().Be(1); } - + [Fact] - public async Task SaveInProgress_WhenKeyJmesPathIsSetToMultipleFields_ShouldSaveRecordInStore_WithIdempotencyKeyEqualsKeyJmesPath() + public async Task + SaveInProgress_WhenKeyJmesPathIsSetToMultipleFields_ShouldSaveRecordInStore_WithIdempotencyKeyEqualsKeyJmesPath() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - + persistenceStore.Configure(new IdempotencyOptionsBuilder() .WithEventKeyJmesPath("powertools_json(Body).[id, message]") //[43876123454654,"Lambda rocks"] - .Build(), "myfunc"); + .Build(), "myfunc", null); var now = DateTimeOffset.UtcNow; - + // Act await persistenceStore.SaveInProgress(JsonSerializer.SerializeToDocument(request)!, now, null); - + // Assert var dr = persistenceStore.DataRecord; dr.Status.Should().Be(DataRecord.DataRecordStatus.INPROGRESS); @@ -170,45 +173,46 @@ public async Task SaveInProgress_WhenKeyJmesPathIsSetToMultipleFields_ShouldSave dr.PayloadHash.Should().BeEmpty(); persistenceStore.Status.Should().Be(1); } - - + + [Fact] public async Task SaveInProgress_WhenJMESPath_NotFound_ShouldThrowException() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - + persistenceStore.Configure(new IdempotencyOptionsBuilder() .WithEventKeyJmesPath("unavailable") .WithThrowOnNoIdempotencyKey(true) // should throw - .Build(), ""); + .Build(), "", null); var now = DateTimeOffset.UtcNow; - + // Act - var act = async () => await persistenceStore.SaveInProgress(JsonSerializer.SerializeToDocument(request)!, now, null); - + var act = async () => + await persistenceStore.SaveInProgress(JsonSerializer.SerializeToDocument(request)!, now, null); + // Assert await act.Should() .ThrowAsync() .WithMessage("No data found to create a hashed idempotency key"); - + persistenceStore.Status.Should().Be(-1); } - + [Fact] public async Task SaveInProgress_WhenJMESpath_NotFound_ShouldNotThrowException() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - + persistenceStore.Configure(new IdempotencyOptionsBuilder() .WithEventKeyJmesPath("unavailable") - .Build(), ""); - + .Build(), "", null); + var now = DateTimeOffset.UtcNow; - + // Act await persistenceStore.SaveInProgress(JsonSerializer.SerializeToDocument(request)!, now, null); @@ -217,20 +221,20 @@ public async Task SaveInProgress_WhenJMESpath_NotFound_ShouldNotThrowException() dr.Status.Should().Be(DataRecord.DataRecordStatus.INPROGRESS); persistenceStore.Status.Should().Be(1); } - + [Fact] public async Task SaveInProgress_WhenLocalCacheIsSet_AndNotExpired_ShouldThrowException() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - - LRUCache cache = new (2); + + LRUCache cache = new(2); 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", new DataRecord( @@ -239,7 +243,7 @@ public async Task SaveInProgress_WhenLocalCacheIsSet_AndNotExpired_ShouldThrowEx now.AddSeconds(3600).ToUnixTimeSeconds(), null, null) ); - + // Act var act = () => persistenceStore.SaveInProgress(JsonSerializer.SerializeToDocument(request)!, now, null); @@ -249,21 +253,21 @@ await act.Should() persistenceStore.Status.Should().Be(-1); } - + [Fact] public async Task SaveInProgress_WhenLocalCacheIsSetButExpired_ShouldRemoveFromCache() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - - LRUCache cache = new (2); + + LRUCache cache = new(2); persistenceStore.Configure(new IdempotencyOptionsBuilder() .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", new DataRecord( @@ -272,7 +276,7 @@ public async Task SaveInProgress_WhenLocalCacheIsSetButExpired_ShouldRemoveFromC now.AddSeconds(-3).ToUnixTimeSeconds(), null, null) ); - + // Act await persistenceStore.SaveInProgress(JsonSerializer.SerializeToDocument(request)!, now, null); @@ -282,22 +286,22 @@ public async Task SaveInProgress_WhenLocalCacheIsSetButExpired_ShouldRemoveFromC cache.Count.Should().Be(0); persistenceStore.Status.Should().Be(1); } - + ////// Save Success - + [Fact] - public async Task SaveSuccess_WhenDefaultConfig_ShouldUpdateRecord() + public async Task SaveSuccess_WhenDefaultConfig_ShouldUpdateRecord() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - LRUCache cache = new (2); - persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, cache); + LRUCache cache = new(2); + persistenceStore.Configure(new IdempotencyOptionsBuilder().Build(), null, null, cache); var product = new Product(34543, "product", 42); - + var now = DateTimeOffset.UtcNow; - + // Act await persistenceStore.SaveSuccess(JsonSerializer.SerializeToDocument(request)!, product, now); @@ -305,77 +309,76 @@ public async Task SaveSuccess_WhenDefaultConfig_ShouldUpdateRecord() var dr = persistenceStore.DataRecord; dr.Status.Should().Be(DataRecord.DataRecordStatus.COMPLETED); dr.ExpiryTimestamp.Should().Be(now.AddSeconds(3600).ToUnixTimeSeconds()); - dr.ResponseData.Should().Be(JsonSerializer.Serialize(product)); + dr.ResponseData.Should().Be(IdempotencySerializer.Serialize(product, typeof(Product))); dr.IdempotencyKey.Should().Be("testFunction#5eff007a9ed2789a9f9f6bc182fc6ae6"); dr.PayloadHash.Should().BeEmpty(); persistenceStore.Status.Should().Be(2); cache.Count.Should().Be(0); } - + [Fact] public async Task SaveSuccess_WhenCacheEnabled_ShouldSaveInCache() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - LRUCache cache = new (2); - + 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; - + // Act await persistenceStore.SaveSuccess(JsonSerializer.SerializeToDocument(request)!, product, now); // Assert persistenceStore.Status.Should().Be(2); cache.Count.Should().Be(1); - + var foundDataRecord = cache.TryGet("testFunction#5eff007a9ed2789a9f9f6bc182fc6ae6", out var record); foundDataRecord.Should().BeTrue(); record.Status.Should().Be(DataRecord.DataRecordStatus.COMPLETED); record.ExpiryTimestamp.Should().Be(now.AddSeconds(3600).ToUnixTimeSeconds()); - record.ResponseData.Should().Be(JsonSerializer.Serialize(product)); + record.ResponseData.Should().Be(IdempotencySerializer.Serialize(product, typeof(Product))); record.IdempotencyKey.Should().Be("testFunction#5eff007a9ed2789a9f9f6bc182fc6ae6"); record.PayloadHash.Should().BeEmpty(); } - + /// Get Record - [Fact] - public async Task GetRecord_WhenRecordIsInStore_ShouldReturnRecordFromPersistence() + public async Task GetRecord_WhenRecordIsInStore_ShouldReturnRecordFromPersistence() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); 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; - + // Act var record = await persistenceStore.GetRecord(JsonSerializer.SerializeToDocument(request)!, now); - + // Assert record.IdempotencyKey.Should().Be("testFunction.myfunc#5eff007a9ed2789a9f9f6bc182fc6ae6"); record.Status.Should().Be(DataRecord.DataRecordStatus.INPROGRESS); record.ResponseData.Should().Be("Response"); persistenceStore.Status.Should().Be(0); } - + [Fact] - public async Task GetRecord_WhenCacheEnabledNotExpired_ShouldReturnRecordFromCache() + public async Task GetRecord_WhenCacheEnabledNotExpired_ShouldReturnRecordFromCache() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); 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( @@ -388,23 +391,23 @@ public async Task GetRecord_WhenCacheEnabledNotExpired_ShouldReturnRecordFromCac // Act var record = await persistenceStore.GetRecord(JsonSerializer.SerializeToDocument(request)!, now); - + // Assert record.IdempotencyKey.Should().Be("testFunction.myfunc#5eff007a9ed2789a9f9f6bc182fc6ae6"); record.Status.Should().Be(DataRecord.DataRecordStatus.COMPLETED); record.ResponseData.Should().Be("result of the function"); persistenceStore.Status.Should().Be(-1); } - + [Fact] - public async Task GetRecord_WhenLocalCacheEnabledButRecordExpired_ShouldReturnRecordFromPersistence() + public async Task GetRecord_WhenLocalCacheEnabledButRecordExpired_ShouldReturnRecordFromPersistence() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); 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( @@ -417,7 +420,7 @@ public async Task GetRecord_WhenLocalCacheEnabledButRecordExpired_ShouldReturnRe // Act var record = await persistenceStore.GetRecord(JsonSerializer.SerializeToDocument(request)!, now); - + // Assert record.IdempotencyKey.Should().Be("testFunction.myfunc#5eff007a9ed2789a9f9f6bc182fc6ae6"); record.Status.Should().Be(DataRecord.DataRecordStatus.INPROGRESS); @@ -425,129 +428,148 @@ public async Task GetRecord_WhenLocalCacheEnabledButRecordExpired_ShouldReturnRe persistenceStore.Status.Should().Be(0); cache.Count.Should().Be(0); } - + [Fact] public async Task GetRecord_WhenInvalidPayload_ShouldThrowValidationException() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - + persistenceStore.Configure(new IdempotencyOptionsBuilder() .WithEventKeyJmesPath("powertools_json(Body).id") .WithPayloadValidationJmesPath("powertools_json(Body).message") .Build(), - "myfunc"); - + "myfunc", null); + var now = DateTimeOffset.UtcNow; - + // Act Func act = () => persistenceStore.GetRecord(JsonSerializer.SerializeToDocument(request)!, now); - + // Assert await act.Should().ThrowAsync(); } - + // Delete Record [Fact] - public async Task DeleteRecord_WhenRecordExist_ShouldDeleteRecordFromPersistence() + public async Task DeleteRecord_WhenRecordExist_ShouldDeleteRecordFromPersistence() { // Arrange 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()); - + // Assert persistenceStore.Status.Should().Be(3); } - + [Fact] - public async Task DeleteRecord_WhenLocalCacheEnabled_ShouldDeleteRecordFromCache() + public async Task DeleteRecord_WhenLocalCacheEnabled_ShouldDeleteRecordFromCache() { // Arrange var persistenceStore = new InMemoryPersistenceStore(); var request = LoadApiGatewayProxyRequest(); - LRUCache cache = new (2); + 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", + new DataRecord("testFunction#5eff007a9ed2789a9f9f6bc182fc6ae6", DataRecord.DataRecordStatus.COMPLETED, 123, null, null)); - + // Act await persistenceStore.DeleteRecord(JsonSerializer.SerializeToDocument(request)!, new ArithmeticException()); - + // Assert persistenceStore.Status.Should().Be(3); - cache.Count.Should().Be(0); + cache.Count.Should().Be(0); } - + [Fact] - public void GenerateHash_WhenInputIsString_ShouldGenerateMd5ofString() + 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 var jsonValue = JsonValue.Create("Lambda rocks"); var generatedHash = persistenceStore.GenerateHash(JsonDocument.Parse(jsonValue!.ToJsonString()).RootElement); - + // Assert generatedHash.Should().Be(expectedHash); } - + [Fact] 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}) - + // Act var jsonValue = JsonValue.Create(product); var generatedHash = persistenceStore.GenerateHash(JsonDocument.Parse(jsonValue!.ToJsonString()).RootElement); - + // Assert generatedHash.Should().Be(expectedHash); } [Fact] - public void GenerateHash_WhenInputIsDouble_ShouldGenerateMd5ofDouble() + 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 var generatedHash = persistenceStore.GenerateHash(JsonDocument.Parse("256.42").RootElement); - + // 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() { - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - var eventJson = File.ReadAllText("./resources/apigw_event.json"); try { - var request = JsonSerializer.Deserialize(eventJson, options); +#if NET8_0_OR_GREATER + IdempotencySerializer.AddTypeInfoResolver(TestJsonSerializerContext.Default); +#endif + var request = IdempotencySerializer.Deserialize(eventJson); return request!; } catch (Exception e) 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 957adc3f..6dc2fb84 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/AWS.Lambda.Powertools.Idempotency.Tests/TestSetup.cs b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/TestSetup.cs new file mode 100644 index 00000000..26f0e213 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Idempotency.Tests/TestSetup.cs @@ -0,0 +1,18 @@ +/* + * 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 Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Tracing.Tests/Handlers/Handlers.cs b/libraries/tests/AWS.Lambda.Powertools.Tracing.Tests/Handlers/Handlers.cs index 385ce96c..38210ac9 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Tracing.Tests/Handlers/Handlers.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Tracing.Tests/Handlers/Handlers.cs @@ -31,6 +31,18 @@ public void HandleWithSegmentName() } + [Tracing(SegmentName = "## <
$>g__Handler|0_0")] + public void HandleWithInvalidSegmentName() + { + MethodWithInvalidSegmentName(); + } + + [Tracing(SegmentName = "Inval$#id | ")] + private void MethodWithInvalidSegmentName() + { + + } + [Tracing(Namespace = "Namespace Defined")] public void HandleWithNamespace() { diff --git a/libraries/tests/AWS.Lambda.Powertools.Tracing.Tests/TracingAttributeTest.cs b/libraries/tests/AWS.Lambda.Powertools.Tracing.Tests/TracingAttributeTest.cs index 58252d67..f02619c1 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Tracing.Tests/TracingAttributeTest.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Tracing.Tests/TracingAttributeTest.cs @@ -256,6 +256,28 @@ public void OnEntry_WhenSegmentNameHasValue_BeginSubsegmentWithValue() Assert.Single(segment.Subsegments); Assert.Equal("SegmentName", subSegment.Name); } + + [Fact] + public void OnEntry_WhenSegmentName_Is_Unsupported() + { + // Arrange + Environment.SetEnvironmentVariable("LAMBDA_TASK_ROOT", "AWS"); + Environment.SetEnvironmentVariable("POWERTOOLS_SERVICE_NAME", "POWERTOOLS"); + + // Act + var segment = AWSXRayRecorder.Instance.TraceContext.GetEntity(); + _handler.HandleWithInvalidSegmentName(); + var subSegment = segment.Subsegments[0]; + var childSegment = subSegment.Subsegments[0]; + + // Assert + Assert.True(segment.IsSubsegmentsAdded); + Assert.True(subSegment.IsSubsegmentsAdded); + Assert.Single(segment.Subsegments); + Assert.Single(subSegment.Subsegments); + Assert.Equal("## Maing__Handler0_0", subSegment.Name); + Assert.Equal("Inval#id Segment", childSegment.Name); + } [Fact] public void OnEntry_WhenNamespaceIsNull_SetNamespaceWithService() diff --git a/libraries/tests/e2e/infra/FunctionConstruct.cs b/libraries/tests/e2e/InfraShared/FunctionConstruct.cs similarity index 57% rename from libraries/tests/e2e/infra/FunctionConstruct.cs rename to libraries/tests/e2e/InfraShared/FunctionConstruct.cs index 127d1875..6dfeb84b 100644 --- a/libraries/tests/e2e/infra/FunctionConstruct.cs +++ b/libraries/tests/e2e/InfraShared/FunctionConstruct.cs @@ -1,32 +1,39 @@ -using System.Collections.Generic; using Amazon.CDK; using Amazon.CDK.AWS.Lambda; using Constructs; -using TestUtils; -namespace Infra; +namespace InfraShared; public class FunctionConstruct : Construct { + public Function Function { get; set; } + public FunctionConstruct(Construct scope, string id, FunctionConstructProps props) : base(scope, id) { var framework = props.Runtime == Runtime.DOTNET_6 ? "net6.0" : "net8.0"; var distPath = $"{props.DistPath}/deploy_{props.Architecture.Name}_{props.Runtime.Name}.zip"; - _ = new Function(this, id, new FunctionProps + var command = props.IsAot + ? $"dotnet-lambda package -pl {props.SourcePath} -cmd ../../../ -o {distPath} -f net8.0 -farch {props.Architecture.Name} -cifb public.ecr.aws/sam/build-dotnet8" + : $"dotnet-lambda package -pl {props.SourcePath} -o {distPath} -f {framework} -farch {props.Architecture.Name}"; + + Console.WriteLine(command); + + Function = new Function(this, id, new FunctionProps { Runtime = props.Runtime, Architecture = props.Architecture, FunctionName = props.Name, - Handler = props.Handler, + Handler = props.Handler!, Tracing = Tracing.ACTIVE, Timeout = Duration.Seconds(10), + Environment = props.Environment, Code = Code.FromCustomCommand(distPath, [ - $"dotnet-lambda package -pl {props.SourcePath} -o {distPath} -f {framework} -farch {props.Architecture.Name}" + command ], new CustomCommandOptions { - CommandOptions = new Dictionary {{"shell", true }} + CommandOptions = new Dictionary { { "shell", true } } }) }); } diff --git a/libraries/tests/e2e/InfraShared/FunctionConstructProps.cs b/libraries/tests/e2e/InfraShared/FunctionConstructProps.cs new file mode 100644 index 00000000..a5d316b8 --- /dev/null +++ b/libraries/tests/e2e/InfraShared/FunctionConstructProps.cs @@ -0,0 +1,26 @@ +using Amazon.CDK; +using Amazon.CDK.AWS.Lambda; + +namespace InfraShared; + +public class FunctionConstructProps : PowertoolsDefaultStackProps +{ + public required Architecture Architecture { get; set; } + public required Runtime Runtime { get; set; } + public required string Name { get; set; } + public required string SourcePath { get; set; } + public required string DistPath { get; set; } +} + +public class PowertoolsDefaultStackProps : StackProps +{ + public bool IsAot { get; set; } = false; + public string? ArchitectureString { get; set; } + public Dictionary? Environment { get; set; } + public string? Handler { get; set; } +} + +public class IdempotencyStackProps : PowertoolsDefaultStackProps +{ + public required string TableName { get; set; } +} \ No newline at end of file diff --git a/libraries/tests/e2e/InfraShared/IdempotencyStack.cs b/libraries/tests/e2e/InfraShared/IdempotencyStack.cs new file mode 100644 index 00000000..1718e823 --- /dev/null +++ b/libraries/tests/e2e/InfraShared/IdempotencyStack.cs @@ -0,0 +1,88 @@ +using Amazon.CDK; +using Amazon.CDK.AWS.DynamoDB; +using Amazon.CDK.AWS.Lambda; +using Constructs; +using Attribute = Amazon.CDK.AWS.DynamoDB.Attribute; + +namespace InfraShared; + +public class IdempotencyStack : Stack +{ + public Table Table { get; set; } + + public IdempotencyStack(Construct scope, string id, IdempotencyStackProps props) : base(scope, id, props) + { + Table = new Table(this, "Idempotency", new TableProps + { + PartitionKey = new Attribute + { + Name = "id", + Type = AttributeType.STRING + }, + TableName = props.TableName, + BillingMode = BillingMode.PAY_PER_REQUEST, + TimeToLiveAttribute = "expiration", + RemovalPolicy = RemovalPolicy.DESTROY + }); + + var utility = "idempotency"; + + if (props.IsAot) + { + var tests = new[] { "HandlerTest", "PayloadSubsetTest", "MethodAttributeTest" }; + + foreach (var test in tests) + { + var baseAotPath = $"../functions/{utility}/AOT-Function/src/AOT-Function{test}"; + var distAotPath = $"../functions/{utility}/AOT-Function/dist/AOT-Function{test}"; + var path = new Path(baseAotPath, distAotPath); + props.Handler = $"AOT-Function{test}"; + + var architecture = props.ArchitectureString == "arm64" ? Architecture.ARM_64 : Architecture.X86_64; + var arch = architecture == Architecture.X86_64 ? "X64" : "ARM"; + CreateFunctionConstruct(this, $"{utility}_{arch}_aot_net8__{test}", Runtime.DOTNET_8, architecture, + $"E2ETestLambda_{arch}_AOT_NET8_{utility}_{test}", path, props); + } + } + else + { + var basePath = $"../functions/{utility}/Function/src/Function"; + var distPath = $"../functions/{utility}/Function/dist"; + var path = new Path(basePath, distPath); + props.Handler = "Function::Function.Function::FunctionHandler"; + + CreateFunctionConstruct(this, $"{utility}_X64_net8", Runtime.DOTNET_8, Architecture.X86_64, + $"E2ETestLambda_X64_NET8_{utility}", path, props); + CreateFunctionConstruct(this, $"{utility}_arm_net8", Runtime.DOTNET_8, Architecture.ARM_64, + $"E2ETestLambda_ARM_NET8_{utility}", path, props); + CreateFunctionConstruct(this, $"{utility}_X64_net6", Runtime.DOTNET_6, Architecture.X86_64, + $"E2ETestLambda_X64_NET6_{utility}", path, props); + CreateFunctionConstruct(this, $"{utility}_arm_net6", Runtime.DOTNET_6, Architecture.ARM_64, + $"E2ETestLambda_ARM_NET6_{utility}", path, props); + } + } + + private void CreateFunctionConstruct(Construct scope, string id, Runtime runtime, Architecture architecture, + string name, Path path, PowertoolsDefaultStackProps props) + { + var lambdaFunction = new FunctionConstruct(scope, id, new FunctionConstructProps + { + Runtime = runtime, + Architecture = architecture, + Name = name, + Handler = props.Handler!, + SourcePath = path.SourcePath, + DistPath = path.DistPath, + Environment = new Dictionary + { + { "IDEMPOTENCY_TABLE_NAME", Table.TableName } + }, + IsAot = props.IsAot + }); + + // Grant the Lambda function permissions to perform all actions on the DynamoDB table + Table.GrantReadWriteData(lambdaFunction.Function); + } +} + +public record Path(string SourcePath, string DistPath); \ No newline at end of file diff --git a/libraries/tests/e2e/InfraShared/InfraShared.csproj b/libraries/tests/e2e/InfraShared/InfraShared.csproj new file mode 100644 index 00000000..dc08a5e1 --- /dev/null +++ b/libraries/tests/e2e/InfraShared/InfraShared.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + InfraShared + enable + enable + + + + + + + + + + diff --git a/libraries/tests/e2e/functions/TestUtils/FunctionConstructProps.cs b/libraries/tests/e2e/functions/TestUtils/FunctionConstructProps.cs deleted file mode 100644 index 85aa0666..00000000 --- a/libraries/tests/e2e/functions/TestUtils/FunctionConstructProps.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Amazon.CDK.AWS.Lambda; - -namespace TestUtils; - -public class FunctionConstructProps -{ - public required Architecture Architecture { get; set; } - public required Runtime Runtime { get; set; } - public required string Name { get; set; } - public required string Handler { get; set; } - public required string SourcePath { get; set; } - public required string DistPath { get; set; } -} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/core/logging/Function/test/Function.Tests/FunctionTests.cs b/libraries/tests/e2e/functions/core/logging/Function/test/Function.Tests/FunctionTests.cs index 587cc27f..ca3a857a 100644 --- a/libraries/tests/e2e/functions/core/logging/Function/test/Function.Tests/FunctionTests.cs +++ b/libraries/tests/e2e/functions/core/logging/Function/test/Function.Tests/FunctionTests.cs @@ -45,7 +45,7 @@ internal async Task TestFunction(string functionName) { FunctionName = functionName, InvocationType = InvocationType.RequestResponse, - Payload = await File.ReadAllTextAsync("../../../../../../../payload.json"), + Payload = await File.ReadAllTextAsync("../../../../../../../../payload.json"), LogType = LogType.Tail }; diff --git a/libraries/tests/e2e/functions/core/metrics/Function/test/Function.Tests/FunctionTests.cs b/libraries/tests/e2e/functions/core/metrics/Function/test/Function.Tests/FunctionTests.cs index 647d3669..1670dceb 100644 --- a/libraries/tests/e2e/functions/core/metrics/Function/test/Function.Tests/FunctionTests.cs +++ b/libraries/tests/e2e/functions/core/metrics/Function/test/Function.Tests/FunctionTests.cs @@ -23,7 +23,7 @@ public FunctionTests(ITestOutputHelper testOutputHelper) [Trait("Category", "AOT")] [Theory] [InlineData("E2ETestLambda_X64_AOT_NET8_metrics")] - // [InlineData("E2ETestLambda_ARM_AOT_NET8_metrics")] + [InlineData("E2ETestLambda_ARM_AOT_NET8_metrics")] public async Task AotFunctionTest(string functionName) { await TestFunction(functionName); @@ -45,7 +45,7 @@ internal async Task TestFunction(string functionName) { FunctionName = functionName, InvocationType = InvocationType.RequestResponse, - Payload = await File.ReadAllTextAsync("../../../../../../../payload.json"), + Payload = await File.ReadAllTextAsync("../../../../../../../../payload.json"), LogType = LogType.Tail }; diff --git a/libraries/tests/e2e/functions/core/tracing/Function/test/Function.Tests/FunctionTests.cs b/libraries/tests/e2e/functions/core/tracing/Function/test/Function.Tests/FunctionTests.cs index 9203a1d5..aa1c0b39 100644 --- a/libraries/tests/e2e/functions/core/tracing/Function/test/Function.Tests/FunctionTests.cs +++ b/libraries/tests/e2e/functions/core/tracing/Function/test/Function.Tests/FunctionTests.cs @@ -26,7 +26,7 @@ public FunctionTests(ITestOutputHelper testOutputHelper) [Trait("Category", "AOT")] [Theory] [InlineData("E2ETestLambda_X64_AOT_NET8_tracing")] - // [InlineData("E2ETestLambda_ARM_AOT_NET8_tracing")] + [InlineData("E2ETestLambda_ARM_AOT_NET8_tracing")] public async Task AotFunctionTest(string functionName) { await TestFunction(functionName); @@ -48,7 +48,7 @@ internal async Task TestFunction(string functionName) { FunctionName = functionName, InvocationType = InvocationType.RequestResponse, - Payload = await File.ReadAllTextAsync("../../../../../../../payload.json"), + Payload = await File.ReadAllTextAsync("../../../../../../../../payload.json"), LogType = LogType.Tail }; diff --git a/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionHandlerTest/AOT-FunctionHandlerTest.csproj b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionHandlerTest/AOT-FunctionHandlerTest.csproj new file mode 100644 index 00000000..2d080cbc --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionHandlerTest/AOT-FunctionHandlerTest.csproj @@ -0,0 +1,34 @@ + + + Exe + net8.0 + enable + enable + Lambda + + true + + true + + true + + partial + AOT-Function + + + + + + + + + + + + + TestHelperAOT.cs + + + \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionHandlerTest/Function.cs b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionHandlerTest/Function.cs new file mode 100644 index 00000000..3aea7eba --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionHandlerTest/Function.cs @@ -0,0 +1,44 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport; +using System.Text.Json.Serialization; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Serialization.SystemTextJson; +using AWS.Lambda.Powertools.Idempotency; + +namespace AOT_Function; + +public static class Function +{ + private static async Task Main() + { + var tableName = Environment.GetEnvironmentVariable("IDEMPOTENCY_TABLE_NAME"); + Idempotency.Configure(builder => + builder + .WithJsonSerializationContext(LambdaFunctionJsonSerializerContext.Default) + .WithOptions(optionsBuilder => optionsBuilder + .WithExpiration(TimeSpan.FromHours(1))) + .UseDynamoDb(storeBuilder => storeBuilder + .WithTableName(tableName) + )); + + Func handler = FunctionHandler; + await LambdaBootstrapBuilder.Create(handler, + new SourceGeneratorLambdaJsonSerializer()) + .Build() + .RunAsync(); + } + + [Idempotent] + public static APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest apigwProxyEvent, + ILambdaContext context) + { + return TestHelperAot.TestMethod(apigwProxyEvent); + } +} + +[JsonSerializable(typeof(APIGatewayProxyRequest))] +[JsonSerializable(typeof(APIGatewayProxyResponse))] +[JsonSerializable(typeof(Response))] +public partial class LambdaFunctionJsonSerializerContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionHandlerTest/aws-lambda-tools-defaults.json b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionHandlerTest/aws-lambda-tools-defaults.json new file mode 100644 index 00000000..be3c7ec1 --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionHandlerTest/aws-lambda-tools-defaults.json @@ -0,0 +1,16 @@ +{ + "Information": [ + "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.", + "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.", + "dotnet lambda help", + "All the command line options for the Lambda command can be specified in this file." + ], + "profile": "", + "region": "", + "configuration": "Release", + "function-runtime": "dotnet8", + "function-memory-size": 512, + "function-timeout": 30, + "function-handler": "AOT-Function", + "msbuild-parameters": "--self-contained true" +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionMethodAttributeTest/AOT-FunctionMethodAttributeTest.csproj b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionMethodAttributeTest/AOT-FunctionMethodAttributeTest.csproj new file mode 100644 index 00000000..2d080cbc --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionMethodAttributeTest/AOT-FunctionMethodAttributeTest.csproj @@ -0,0 +1,34 @@ + + + Exe + net8.0 + enable + enable + Lambda + + true + + true + + true + + partial + AOT-Function + + + + + + + + + + + + + TestHelperAOT.cs + + + \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionMethodAttributeTest/Function.cs b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionMethodAttributeTest/Function.cs new file mode 100644 index 00000000..5233c1f3 --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionMethodAttributeTest/Function.cs @@ -0,0 +1,51 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport; +using System.Text.Json.Serialization; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Serialization.SystemTextJson; +using AWS.Lambda.Powertools.Idempotency; + +namespace AOT_Function; + +public static class Function +{ + private static async Task Main() + { + var tableName = Environment.GetEnvironmentVariable("IDEMPOTENCY_TABLE_NAME"); + Idempotency.Configure(builder => + builder + .WithJsonSerializationContext(LambdaFunctionJsonSerializerContext.Default) + .WithOptions(optionsBuilder => optionsBuilder + .WithExpiration(TimeSpan.FromHours(1))) + .UseDynamoDb(storeBuilder => storeBuilder + .WithTableName(tableName) + )); + + Func handler = FunctionHandler; + await LambdaBootstrapBuilder.Create(handler, + new SourceGeneratorLambdaJsonSerializer()) + .Build() + .RunAsync(); + } + + public static APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest apigwProxyEvent, + ILambdaContext context) + { + return new APIGatewayProxyResponse + { + Body = MyInternalMethod("dummy", apigwProxyEvent.RequestContext.RequestId), + StatusCode = 200 + }; + } + + [Idempotent] + private static string MyInternalMethod(string argOne, [IdempotencyKey] string argTwo) { + return Guid.NewGuid().ToString(); + } +} + +[JsonSerializable(typeof(APIGatewayProxyRequest))] +[JsonSerializable(typeof(APIGatewayProxyResponse))] +public partial class LambdaFunctionJsonSerializerContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionMethodAttributeTest/aws-lambda-tools-defaults.json b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionMethodAttributeTest/aws-lambda-tools-defaults.json new file mode 100644 index 00000000..be3c7ec1 --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionMethodAttributeTest/aws-lambda-tools-defaults.json @@ -0,0 +1,16 @@ +{ + "Information": [ + "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.", + "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.", + "dotnet lambda help", + "All the command line options for the Lambda command can be specified in this file." + ], + "profile": "", + "region": "", + "configuration": "Release", + "function-runtime": "dotnet8", + "function-memory-size": 512, + "function-timeout": 30, + "function-handler": "AOT-Function", + "msbuild-parameters": "--self-contained true" +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/AOT-FunctionPayloadSubsetTest.csproj b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/AOT-FunctionPayloadSubsetTest.csproj new file mode 100644 index 00000000..7896b34c --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/AOT-FunctionPayloadSubsetTest.csproj @@ -0,0 +1,29 @@ + + + Exe + net8.0 + enable + enable + Lambda + + true + + true + + true + + partial + AOT-Function + + + + + + + + + + + \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/Function.cs b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/Function.cs new file mode 100644 index 00000000..189250f1 --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/Function.cs @@ -0,0 +1,45 @@ +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport; +using System.Text.Json.Serialization; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Serialization.SystemTextJson; +using AWS.Lambda.Powertools.Idempotency; + +namespace AOT_Function; + +public static class Function +{ + private static async Task Main() + { + var tableName = Environment.GetEnvironmentVariable("IDEMPOTENCY_TABLE_NAME"); + Idempotency.Configure(builder => + builder + .WithJsonSerializationContext(LambdaFunctionJsonSerializerContext.Default) + .WithOptions(optionsBuilder => optionsBuilder + .WithEventKeyJmesPath("powertools_json(Body).[\"user_id\", \"product_id\"]") + .WithExpiration(TimeSpan.FromHours(1))) + .UseDynamoDb(storeBuilder => storeBuilder + .WithTableName(tableName) + )); + + Func handler = FunctionHandler; + await LambdaBootstrapBuilder.Create(handler, + new SourceGeneratorLambdaJsonSerializer()) + .Build() + .RunAsync(); + } + + [Idempotent] + public static APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest apigwProxyEvent, + ILambdaContext context) + { + return TestHelperAot.TestMethod(apigwProxyEvent); + } +} + +[JsonSerializable(typeof(APIGatewayProxyRequest))] +[JsonSerializable(typeof(APIGatewayProxyResponse))] +[JsonSerializable(typeof(Response))] +public partial class LambdaFunctionJsonSerializerContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/TestHelperAOT.cs b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/TestHelperAOT.cs new file mode 100644 index 00000000..144c7158 --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/TestHelperAOT.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using Amazon.Lambda.APIGatewayEvents; + +namespace AOT_Function; + +public static class TestHelperAot +{ + public static APIGatewayProxyResponse TestMethod(APIGatewayProxyRequest apigwProxyEvent) + { + var response = new Response + { + Greeting = "Hello Powertools for AWS Lambda (.NET)", + Guid = Guid.NewGuid().ToString() // Guid generated in the Handler. used to compare Handler output + }; + + try + { + return new APIGatewayProxyResponse + { + Body = JsonSerializer.Serialize(response, typeof(Response), LambdaFunctionJsonSerializerContext.Default), + StatusCode = 200, + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } + catch (Exception e) + { + return new APIGatewayProxyResponse + { + Body = e.Message, + StatusCode = 500, + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } + } +} + +public class Response +{ + public string? Greeting { get; set; } + public string? Guid { get; set; } +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/aws-lambda-tools-defaults.json b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/aws-lambda-tools-defaults.json new file mode 100644 index 00000000..be3c7ec1 --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/AOT-Function/src/AOT-FunctionPayloadSubsetTest/aws-lambda-tools-defaults.json @@ -0,0 +1,16 @@ +{ + "Information": [ + "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.", + "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.", + "dotnet lambda help", + "All the command line options for the Lambda command can be specified in this file." + ], + "profile": "", + "region": "", + "configuration": "Release", + "function-runtime": "dotnet8", + "function-memory-size": 512, + "function-timeout": 30, + "function-handler": "AOT-Function", + "msbuild-parameters": "--self-contained true" +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/Function/src/Function/Function.cs b/libraries/tests/e2e/functions/idempotency/Function/src/Function/Function.cs new file mode 100644 index 00000000..389e414c --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/Function/src/Function/Function.cs @@ -0,0 +1,91 @@ +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Core; +using AWS.Lambda.Powertools.Idempotency; +using Helpers; + +// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class. +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] + +namespace Function +{ + public class Function + { + public Function() + { + var tableName = Environment.GetEnvironmentVariable("IDEMPOTENCY_TABLE_NAME"); + Idempotency.Configure(builder => builder.UseDynamoDb(tableName)); + } + + [Idempotent] + public APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest apigwProxyEvent, ILambdaContext context) + { + return TestHelper.TestMethod(apigwProxyEvent); + } + } +} + +namespace IdempotencyAttributeTest +{ + public class Function + { + public Function() + { + var tableName = Environment.GetEnvironmentVariable("IDEMPOTENCY_TABLE_NAME"); + Idempotency.Configure(builder => builder.UseDynamoDb(tableName)); + } + + public APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest apigwProxyEvent, ILambdaContext context) + { + return new APIGatewayProxyResponse + { + Body = MyInternalMethod("dummy", apigwProxyEvent.RequestContext.RequestId), + StatusCode = 200 + }; + } + + [Idempotent] + private string MyInternalMethod(string argOne, [IdempotencyKey] string argTwo) { + return Guid.NewGuid().ToString(); + } + } +} + +namespace IdempotencyPayloadSubsetTest +{ + public class Function + { + public Function() + { + var tableName = Environment.GetEnvironmentVariable("IDEMPOTENCY_TABLE_NAME"); + Idempotency.Configure(builder => + builder + .WithOptions(optionsBuilder => + optionsBuilder.WithEventKeyJmesPath("powertools_json(Body).[\"user_id\", \"product_id\"]")) + .UseDynamoDb(tableName)); + } + + [Idempotent] + public APIGatewayProxyResponse FunctionHandler(APIGatewayProxyRequest apigwProxyEvent, ILambdaContext context) + { + return TestHelper.TestMethod(apigwProxyEvent); + } + } +} + +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/src/Function/Function.csproj b/libraries/tests/e2e/functions/idempotency/Function/src/Function/Function.csproj new file mode 100644 index 00000000..0dedeaea --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/Function/src/Function/Function.csproj @@ -0,0 +1,21 @@ + + + net6.0;net8.0 + enable + enable + true + Lambda + + true + + true + + + + + + + + + + \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/Function/src/Function/TestHelper.cs b/libraries/tests/e2e/functions/idempotency/Function/src/Function/TestHelper.cs new file mode 100644 index 00000000..4708d225 --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/Function/src/Function/TestHelper.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using Amazon.Lambda.APIGatewayEvents; + +namespace Helpers; + +public static class TestHelper +{ + public static APIGatewayProxyResponse TestMethod(APIGatewayProxyRequest apigwProxyEvent) + { + var response = new + { + Greeting = "Hello Powertools for AWS Lambda (.NET)", + Guid = Guid.NewGuid().ToString() // Guid generated in the Handler. used to compare Handler output + }; + + try + { + return new APIGatewayProxyResponse + { + Body = JsonSerializer.Serialize(response), + StatusCode = 200, + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } + catch (Exception e) + { + return new APIGatewayProxyResponse + { + Body = e.Message, + StatusCode = 500, + Headers = new Dictionary { { "Content-Type", "application/json" } } + }; + } + } +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/Function/src/Function/aws-lambda-tools-defaults.json b/libraries/tests/e2e/functions/idempotency/Function/src/Function/aws-lambda-tools-defaults.json new file mode 100644 index 00000000..307a7dca --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/Function/src/Function/aws-lambda-tools-defaults.json @@ -0,0 +1,13 @@ +{ + "Information": [ + "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.", + "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.", + "dotnet lambda help", + "All the command line options for the Lambda command can be specified in this file." + ], + "profile": "", + "region": "", + "configuration": "Release", + "function-memory-size": 512, + "function-timeout": 30 +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/Function.Tests.csproj b/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/Function.Tests.csproj new file mode 100644 index 00000000..b4c5f8d3 --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/Function.Tests.csproj @@ -0,0 +1,23 @@ + + + net8.0 + enable + enable + true + Logging.E2E.Tests + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 00000000..3f5c7cc7 --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/FunctionTests.cs @@ -0,0 +1,355 @@ +using System.Text.Json; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using Amazon.Lambda; +using Amazon.Lambda.APIGatewayEvents; +using Xunit; +using Amazon.Lambda.Model; +using Xunit.Abstractions; + +namespace Function.Tests; + +[Trait("Category", "E2E")] +public class FunctionTests +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly AmazonLambdaClient _lambdaClient; + private readonly AmazonDynamoDBClient _dynamoDbClient; + private string _tableName = null!; + + public FunctionTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + _lambdaClient = new AmazonLambdaClient(); + _dynamoDbClient = new AmazonDynamoDBClient(); + } + + [Trait("Category", "AOT")] + [Theory] + [InlineData("E2ETestLambda_ARM_AOT_NET8_idempotency_HandlerTest", "IdempotencyTable-AOT-arm64")] + [InlineData("E2ETestLambda_X64_AOT_NET8_idempotency_HandlerTest", "IdempotencyTable-AOT-x86_64")] + public async Task IdempotencyHandlerAotTest(string functionName, string tableName) + { + _tableName = tableName; + await IdempotencyHandler(functionName); + } + + [Trait("Category", "AOT")] + [Theory] + [InlineData("E2ETestLambda_ARM_AOT_NET8_idempotency_MethodAttributeTest", "IdempotencyTable-AOT-arm64")] + [InlineData("E2ETestLambda_X64_AOT_NET8_idempotency_MethodAttributeTest", "IdempotencyTable-AOT-x86_64")] + public async Task IdempotencyAttributeAotTest(string functionName, string tableName) + { + _tableName = tableName; + await IdempotencyAttribute(functionName); + } + + [Trait("Category", "AOT")] + [Theory] + [InlineData("E2ETestLambda_ARM_AOT_NET8_idempotency_PayloadSubsetTest", "IdempotencyTable-AOT-arm64")] + [InlineData("E2ETestLambda_X64_AOT_NET8_idempotency_PayloadSubsetTest", "IdempotencyTable-AOT-x86_64")] + public async Task IdempotencyPayloadSubsetAotTest(string functionName, string tableName) + { + _tableName = tableName; + await IdempotencyPayloadSubset(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 IdempotencyPayloadSubsetTest(string functionName) + { + _tableName = "IdempotencyTable"; + await UpdateFunctionHandler(functionName, "Function::IdempotencyPayloadSubsetTest.Function::FunctionHandler"); + await IdempotencyPayloadSubset(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 IdempotencyAttributeTest(string functionName) + { + _tableName = "IdempotencyTable"; + await UpdateFunctionHandler(functionName, "Function::IdempotencyAttributeTest.Function::FunctionHandler"); + await IdempotencyAttribute(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 IdempotencyHandlerTest(string functionName) + { + _tableName = "IdempotencyTable"; + 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) + { + // First unique request + var firstProductId = Guid.NewGuid().ToString(); + var (firstResponse1, firstGuid1) = await ExecutePayloadSubsetRequest(functionName, "xyz", firstProductId); + var (firstResponse2, firstGuid2) = await ExecutePayloadSubsetRequest(functionName, "xyz", firstProductId); + + // Assert first request pair + Assert.Equal(200, firstResponse1.StatusCode); + Assert.Equal(200, firstResponse2.StatusCode); + Assert.Equal(firstGuid1, firstGuid2); // Idempotency check + await AssertDynamoDbData( + $"{functionName}.FunctionHandler#{Helpers.HashRequest($"[\"xyz\",\"{firstProductId}\"]")}", + firstGuid1); + + // Second unique request + var secondProductId = Guid.NewGuid().ToString(); + var (secondResponse1, secondGuid1) = await ExecutePayloadSubsetRequest(functionName, "xyz", secondProductId); + var (secondResponse2, secondGuid2) = await ExecutePayloadSubsetRequest(functionName, "xyz", secondProductId); + + // Assert second request pair + Assert.Equal(200, secondResponse1.StatusCode); + Assert.Equal(200, secondResponse2.StatusCode); + Assert.Equal(secondGuid1, secondGuid2); // Idempotency check + Assert.NotEqual(firstGuid1, secondGuid1); // Different requests should have different GUIDs + await AssertDynamoDbData( + $"{functionName}.FunctionHandler#{Helpers.HashRequest($"[\"xyz\",\"{secondProductId}\"]")}", + secondGuid1); + } + + private async Task IdempotencyAttribute(string functionName) + { + // First unique request + var requestId1 = Guid.NewGuid().ToString(); + var (firstResponse1, firstGuid1) = await ExecuteAttributeRequest(functionName, requestId1); + var (firstResponse2, firstGuid2) = await ExecuteAttributeRequest(functionName, requestId1); + + // Assert first request pair + Assert.Equal(200, firstResponse1.StatusCode); + Assert.Equal(200, firstResponse2.StatusCode); + Assert.Equal(firstGuid1, firstGuid2); // Idempotency check + await AssertDynamoDbData( + $"{functionName}.MyInternalMethod#{Helpers.HashRequest(requestId1)}", + firstGuid1, + true); + + // Second unique request + var requestId2 = Guid.NewGuid().ToString(); + var (secondResponse1, secondGuid1) = await ExecuteAttributeRequest(functionName, requestId2); + var (secondResponse2, secondGuid2) = await ExecuteAttributeRequest(functionName, requestId2); + + // Assert second request pair + Assert.Equal(200, secondResponse1.StatusCode); + Assert.Equal(200, secondResponse2.StatusCode); + Assert.Equal(secondGuid1, secondGuid2); // Idempotency check + Assert.NotEqual(firstGuid1, secondGuid1); // Different requests should have different GUIDs + await AssertDynamoDbData( + $"{functionName}.MyInternalMethod#{Helpers.HashRequest(requestId2)}", + secondGuid1, + true); + } + + private async Task IdempotencyHandler(string functionName, string? keyPrefix = null) + { + var payload = await File.ReadAllTextAsync("../../../../../../../payload.json"); + + // Execute three identical requests + var (response1, guid1) = await ExecuteHandlerRequest(functionName, payload); + var (response2, guid2) = await ExecuteHandlerRequest(functionName, payload); + var (response3, guid3) = await ExecuteHandlerRequest(functionName, payload); + + // Assert all responses + Assert.Equal(200, response1.StatusCode); + Assert.Equal(200, response2.StatusCode); + Assert.Equal(200, response3.StatusCode); + + // Assert idempotency + Assert.Equal(guid1, guid2); + Assert.Equal(guid2, guid3); + + var key = keyPrefix ?? $"{functionName}.FunctionHandler"; + + // Assert DynamoDB + await AssertDynamoDbData( + $"{key}#35973cf447e6cc11008d603c791a232f", + guid1); + } + + private async Task UpdateFunctionHandler(string functionName, string handler) + { + var updateRequest = new UpdateFunctionConfigurationRequest + { + FunctionName = functionName, + Handler = handler + }; + + var updateResponse = await _lambdaClient.UpdateFunctionConfigurationAsync(updateRequest); + + if (updateResponse.HttpStatusCode == System.Net.HttpStatusCode.OK) + { + Console.WriteLine($"Successfully updated the handler for function {functionName} to {handler}"); + } + else + { + Assert.Fail( + $"Failed to update the handler for function {functionName}. Status code: {updateResponse.HttpStatusCode}"); + } + + //wait a few seconds for the changes to take effect + await Task.Delay(5000); + } + + private async Task AssertDynamoDbData(string id, string requestId, bool isSavedDataString = false) + { + _testOutputHelper.WriteLine($"Querying DynamoDB with id: {id}"); + + var queryRequest = new QueryRequest + { + TableName = _tableName, + KeyConditionExpression = "id = :v_id", + ExpressionAttributeValues = new Dictionary + { + { ":v_id", new AttributeValue { S = id } } + } + }; + + _testOutputHelper.WriteLine($"QueryRequest: {JsonSerializer.Serialize(queryRequest)}"); + + var queryResponse = await _dynamoDbClient.QueryAsync(queryRequest); + + _testOutputHelper.WriteLine($"QueryResponse: {JsonSerializer.Serialize(queryResponse)}"); + + if (queryResponse.Items.Count == 0) + { + Assert.Fail("No items found in DynamoDB for the given id."); + } + + foreach (var item in queryResponse.Items) + { + var data = item["data"].S; + var status = item["status"].S; + + Assert.Equal("COMPLETED", status); + + if (!isSavedDataString) + { + var parsedData = JsonSerializer.Deserialize(data); + + if (parsedData == null) + { + Assert.Fail("Failed to parse data field."); + } + + var parsedResponse = JsonSerializer.Deserialize(parsedData.Body); + if (parsedResponse == null) + { + Assert.Fail("Failed to parse response."); + } + + Assert.Equal(requestId, parsedResponse.Guid); + } + else + { + Assert.Equal(requestId, data.Trim('"')); + } + } + } + + // Helper methods for executing requests + private async Task<(APIGatewayProxyResponse Response, string Guid)> ExecutePayloadSubsetRequest( + string functionName, string userId, string productId) + { + var request = new InvokeRequest + { + FunctionName = functionName, + InvocationType = InvocationType.RequestResponse, + Payload = JsonSerializer.Serialize(new APIGatewayProxyRequest + { + Body = $"{{\"user_id\":\"{userId}\",\"product_id\":\"{productId}\"}}" + }), + LogType = LogType.Tail + }; + + return await ExecuteRequest(request); + } + + private async Task<(APIGatewayProxyResponse Response, string Guid)> ExecuteAttributeRequest( + string functionName, string requestId) + { + var request = new InvokeRequest + { + FunctionName = functionName, + InvocationType = InvocationType.RequestResponse, + Payload = JsonSerializer.Serialize(new APIGatewayProxyRequest + { + Body = "{\"user_id\":\"***\",\"product_id\":\"123456789\"}", + RequestContext = new APIGatewayProxyRequest.ProxyRequestContext + { + AccountId = "123456789012", + RequestId = requestId + } + }), + LogType = LogType.Tail + }; + + return await ExecuteRequest(request); + } + + private async Task<(APIGatewayProxyResponse Response, string Guid)> ExecuteHandlerRequest( + string functionName, string payload) + { + var request = new InvokeRequest + { + FunctionName = functionName, + InvocationType = InvocationType.RequestResponse, + Payload = payload, + LogType = LogType.Tail + }; + + return await ExecuteRequest(request); + } + + private async Task<(APIGatewayProxyResponse Response, string Guid)> ExecuteRequest(InvokeRequest request) + { + var response = await _lambdaClient.InvokeAsync(request); + + if (string.IsNullOrEmpty(response.LogResult)) + Assert.Fail("No LogResult field returned in the response of Lambda invocation."); + + var responsePayload = System.Text.Encoding.UTF8.GetString(response.Payload.ToArray()); + var parsedResponse = JsonSerializer.Deserialize(responsePayload) + ?? throw new Exception("Failed to parse payload."); + + string guid; + try + { + // The GUID is inside the Response object + var parsedBody = JsonSerializer.Deserialize(parsedResponse.Body); + guid = parsedBody?.Guid ?? parsedResponse.Body; + } + catch (JsonException) + { + // For scenarios where the Body is already the GUID + guid = parsedResponse.Body; + } + + return (parsedResponse, guid); + } +} + +public record Response(string Greeting, string Guid); \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/Helpers.cs b/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/Helpers.cs new file mode 100644 index 00000000..ca270bbf --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/Function/test/Function.Tests/Helpers.cs @@ -0,0 +1,25 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Function.Tests; + +public static class Helpers +{ + public static string HashRequest(string input) + { + using var hashAlgorithm = MD5.Create(); + if (hashAlgorithm == null) + { + throw new ArgumentException("Invalid HashAlgorithm"); + } + + var data = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(input)); + var sBuilder = new StringBuilder(); + for (var i = 0; i < data.Length; i++) + { + sBuilder.Append(data[i].ToString("x2")); + } + + return sBuilder.ToString(); + } +} \ No newline at end of file diff --git a/libraries/tests/e2e/functions/idempotency/IdempotencyTests.sln b/libraries/tests/e2e/functions/idempotency/IdempotencyTests.sln new file mode 100644 index 00000000..add4ad93 --- /dev/null +++ b/libraries/tests/e2e/functions/idempotency/IdempotencyTests.sln @@ -0,0 +1,30 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Function", "Function", "{FE3A26C9-5A8D-4DD3-A87B-2D7FC5BC15A8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{28C61FF3-B4F5-44AC-9375-A4C6FC8579C8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Function.Tests", "Function\test\Function.Tests\Function.Tests.csproj", "{8959B0AC-3B85-4E30-9C48-CAD5F72AD5BB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8959B0AC-3B85-4E30-9C48-CAD5F72AD5BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8959B0AC-3B85-4E30-9C48-CAD5F72AD5BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8959B0AC-3B85-4E30-9C48-CAD5F72AD5BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8959B0AC-3B85-4E30-9C48-CAD5F72AD5BB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {28C61FF3-B4F5-44AC-9375-A4C6FC8579C8} = {FE3A26C9-5A8D-4DD3-A87B-2D7FC5BC15A8} + {8959B0AC-3B85-4E30-9C48-CAD5F72AD5BB} = {28C61FF3-B4F5-44AC-9375-A4C6FC8579C8} + EndGlobalSection +EndGlobal diff --git a/libraries/tests/e2e/functions/core/payload.json b/libraries/tests/e2e/functions/payload.json similarity index 100% rename from libraries/tests/e2e/functions/core/payload.json rename to libraries/tests/e2e/functions/payload.json diff --git a/libraries/tests/e2e/infra-aot/AOTStackProps.cs b/libraries/tests/e2e/infra-aot/AOTStackProps.cs deleted file mode 100644 index 4e735a8f..00000000 --- a/libraries/tests/e2e/infra-aot/AOTStackProps.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Amazon.CDK; - -namespace InfraAot; - -public class AotStackProps : StackProps -{ - public string Architecture { get; set; } -} \ No newline at end of file diff --git a/libraries/tests/e2e/infra-aot/CoreAotStack.cs b/libraries/tests/e2e/infra-aot/CoreAotStack.cs index 40197ab5..4387892c 100644 --- a/libraries/tests/e2e/infra-aot/CoreAotStack.cs +++ b/libraries/tests/e2e/infra-aot/CoreAotStack.cs @@ -1,7 +1,7 @@ using Amazon.CDK; using Amazon.CDK.AWS.Lambda; using Constructs; -using TestUtils; +using InfraShared; using Architecture = Amazon.CDK.AWS.Lambda.Architecture; namespace InfraAot; @@ -10,9 +10,9 @@ public class CoreAotStack : Stack { private readonly Architecture _architecture; - internal CoreAotStack(Construct scope, string id, AotStackProps props = null) : base(scope, id, props) + internal CoreAotStack(Construct scope, string id, PowertoolsDefaultStackProps props = null) : base(scope, id, props) { - if (props != null) _architecture = props.Architecture == "arm64" ? Architecture.ARM_64 : Architecture.X86_64; + if (props != null) _architecture = props.ArchitectureString == "arm64" ? Architecture.ARM_64 : Architecture.X86_64; CreateFunctionConstructs("logging"); CreateFunctionConstructs("metrics"); @@ -25,7 +25,7 @@ private void CreateFunctionConstructs(string utility) var distAotPath = $"../functions/core/{utility}/AOT-Function/dist"; var arch = _architecture == Architecture.X86_64 ? "X64" : "ARM"; - CreateFunctionConstruct(this, $"{utility}_ARM_aot_net8", Runtime.DOTNET_8, _architecture, + CreateFunctionConstruct(this, $"{utility}_{arch}_aot_net8", Runtime.DOTNET_8, _architecture, $"E2ETestLambda_{arch}_AOT_NET8_{utility}", baseAotPath, distAotPath); } @@ -39,7 +39,8 @@ private void CreateFunctionConstruct(Construct scope, string id, Runtime runtime Name = name, Handler = "AOT-Function", SourcePath = sourcePath, - DistPath = distPath + DistPath = distPath, + IsAot = true }); } } \ No newline at end of file diff --git a/libraries/tests/e2e/infra-aot/FunctionConstruct.cs b/libraries/tests/e2e/infra-aot/FunctionConstruct.cs deleted file mode 100644 index aa4e92cb..00000000 --- a/libraries/tests/e2e/infra-aot/FunctionConstruct.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Generic; -using Amazon.CDK; -using Amazon.CDK.AWS.Lambda; -using Constructs; -using TestUtils; - -namespace InfraAot; - -public class FunctionConstruct : Construct -{ - public FunctionConstruct(Construct scope, string id, FunctionConstructProps props) : base(scope, id) - { - var distPath = $"{props.DistPath}/deploy_{props.Architecture.Name}_{props.Runtime.Name}.zip"; - _ = new Function(this, id, new FunctionProps - { - Runtime = props.Runtime, - Architecture = props.Architecture, - FunctionName = props.Name, - Handler = props.Handler, - Tracing = Tracing.ACTIVE, - Timeout = Duration.Seconds(10), - Code = Code.FromCustomCommand(distPath, - [ - $"dotnet-lambda package -pl {props.SourcePath} -cmd ../../../ -o {distPath} -f net8.0 -farch {props.Architecture.Name} -cifb public.ecr.aws/sam/build-dotnet8" - ], - new CustomCommandOptions - { - CommandOptions = new Dictionary {{"shell", true }} - }) - }); - } -} \ No newline at end of file diff --git a/libraries/tests/e2e/infra-aot/InfraAot.csproj b/libraries/tests/e2e/infra-aot/InfraAot.csproj index e1c7a0f9..1e7b2b1e 100644 --- a/libraries/tests/e2e/infra-aot/InfraAot.csproj +++ b/libraries/tests/e2e/infra-aot/InfraAot.csproj @@ -18,5 +18,6 @@ + diff --git a/libraries/tests/e2e/infra-aot/Program.cs b/libraries/tests/e2e/infra-aot/Program.cs index d50d0f5f..db4d533a 100644 --- a/libraries/tests/e2e/infra-aot/Program.cs +++ b/libraries/tests/e2e/infra-aot/Program.cs @@ -1,4 +1,5 @@ using Amazon.CDK; +using InfraShared; namespace InfraAot { @@ -11,7 +12,8 @@ public static void Main(string[] args) var architecture = app.Node.TryGetContext("architecture")?.ToString(); if (architecture == null) { - throw new System.ArgumentException("architecture context is required. Please provide it with --context architecture=arm64|x86_64"); + throw new System.ArgumentException( + "architecture context is required. Please provide it with --context architecture=arm64|x86_64"); } if (architecture != "arm64" && architecture != "x86_64") @@ -20,16 +22,22 @@ public static void Main(string[] args) } var id = "CoreAotStack"; - if(architecture == "arm64") + var idempotencystackAotId = "IdempotencyStack-AOT"; + if (architecture == "arm64") { id = $"CoreAotStack-{architecture}"; + idempotencystackAotId = $"IdempotencyStack-AOT-{architecture}"; } - _ = new CoreAotStack(app, id, new AotStackProps + _ = new CoreAotStack(app, id, new PowertoolsDefaultStackProps { - Architecture = architecture + ArchitectureString = architecture }); + + _ = new IdempotencyStack(app, idempotencystackAotId, + new IdempotencyStackProps { IsAot = true, ArchitectureString = architecture, TableName = $"IdempotencyTable-AOT-{architecture}" }); + app.Synth(); } } -} +} \ No newline at end of file diff --git a/libraries/tests/e2e/infra/CoreStack.cs b/libraries/tests/e2e/infra/CoreStack.cs index 23455b64..d77c725a 100644 --- a/libraries/tests/e2e/infra/CoreStack.cs +++ b/libraries/tests/e2e/infra/CoreStack.cs @@ -1,7 +1,7 @@ using Amazon.CDK; using Amazon.CDK.AWS.Lambda; using Constructs; -using TestUtils; +using InfraShared; using Architecture = Amazon.CDK.AWS.Lambda.Architecture; namespace Infra @@ -40,7 +40,7 @@ private void CreateFunctionConstruct(Construct scope, string id, Runtime runtime Name = name, Handler = "Function::Function.Function::FunctionHandler", SourcePath = sourcePath, - DistPath = distPath + DistPath = distPath, }); } } diff --git a/libraries/tests/e2e/infra/Infra.csproj b/libraries/tests/e2e/infra/Infra.csproj index e1c7a0f9..9b7b55f6 100644 --- a/libraries/tests/e2e/infra/Infra.csproj +++ b/libraries/tests/e2e/infra/Infra.csproj @@ -17,6 +17,6 @@ - + diff --git a/libraries/tests/e2e/infra/Program.cs b/libraries/tests/e2e/infra/Program.cs index 6d1267c8..d56d83b2 100644 --- a/libraries/tests/e2e/infra/Program.cs +++ b/libraries/tests/e2e/infra/Program.cs @@ -1,4 +1,5 @@ using Amazon.CDK; +using InfraShared; namespace Infra { @@ -10,6 +11,8 @@ public static void Main(string[] args) _ = new CoreStack(app, "CoreStack", new StackProps { }); + _ = new IdempotencyStack(app, "IdempotencyStack", new IdempotencyStackProps { TableName = "IdempotencyTable" }); + app.Synth(); } } diff --git a/mkdocs.yml b/mkdocs.yml index d62a9ace..9dbf670d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -85,6 +85,7 @@ markdown_extensions: copyright: Copyright © 2024 Amazon Web Services plugins: + - privacy - git-revision-date - search diff --git a/poetry.lock b/poetry.lock index 7bc050f7..b54242f5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -293,13 +293,13 @@ files = [ [[package]] name = "pygments" -version = "2.13.0" +version = "2.15.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, - {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, + {file = "Pygments-2.15.0-py3-none-any.whl", hash = "sha256:77a3299119af881904cd5ecd1ac6a66214b6e9bed1f2db16993b54adede64094"}, + {file = "Pygments-2.15.0.tar.gz", hash = "sha256:f7e36cffc4c517fbc252861b9a6e4644ca0e5abadf9a113c72d1358ad09b9500"}, ] [package.extras] diff --git a/version.json b/version.json index f94048ab..d52ea67c 100644 --- a/version.json +++ b/version.json @@ -2,11 +2,11 @@ "Core": { "Logging": "1.6.4", "Metrics": "1.8.0", - "Tracing": "1.6.0" + "Tracing": "1.6.1" }, "Utilities": { "Parameters": "1.3.0", - "Idempotency": "1.2.2", + "Idempotency": "1.3.0", "BatchProcessing": "1.2.0" } }