├── .config └── dotnet-tools.json ├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── 10_bug_report.yml │ ├── 20_feature_request.yml │ ├── 30_question.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── actionlint-matcher.json ├── dependabot.yml ├── update-dotnet-sdk.json └── workflows │ ├── build.yml │ ├── bump-version.yml │ ├── codeql.yml │ ├── dependency-review.yml │ ├── lint.yml │ ├── ossf-scorecard.yml │ └── release.yml ├── .gitignore ├── .markdownlint.json ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── .vsconfig ├── AwsLambdaTestServer.ruleset ├── AwsLambdaTestServer.slnx ├── AwsLambdaTestServer.snk ├── CODE_OF_CONDUCT.md ├── Directory.Build.props ├── Directory.Packages.props ├── LICENSE ├── NuGet.config ├── README.md ├── SECURITY.md ├── build.ps1 ├── global.json ├── package-icon.png ├── package-readme.md ├── samples ├── MathsFunctions.Tests │ ├── MathsFunctionTests.cs │ └── MathsFunctions.Tests.csproj ├── MathsFunctions │ ├── MathsFunction.cs │ ├── MathsFunctions.csproj │ ├── MathsRequest.cs │ └── MathsResponse.cs ├── MinimalApi.Tests │ ├── ApiTests.cs │ ├── HttpLambdaTestServer.cs │ └── MinimalApi.Tests.csproj └── MinimalApi │ ├── HashRequest.cs │ ├── HashResponse.cs │ ├── MinimalApi.csproj │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── appsettings.Development.json │ └── appsettings.json ├── src └── AwsLambdaTestServer │ ├── LambdaTestContext.cs │ ├── LambdaTestRequest.cs │ ├── LambdaTestResponse.cs │ ├── LambdaTestResponseExtensions.cs │ ├── LambdaTestServer.cs │ ├── LambdaTestServerExtensions.cs │ ├── LambdaTestServerOptions.cs │ ├── MartinCostello.Testing.AwsLambdaTestServer.csproj │ ├── PublicAPI │ ├── PublicAPI.Shipped.txt │ └── PublicAPI.Unshipped.txt │ └── RuntimeHandler.cs ├── stylecop.json └── tests └── AwsLambdaTestServer.Tests ├── AssemblyFixture.cs ├── AwsIntegrationTests.cs ├── Examples.cs ├── FunctionRunner.cs ├── HttpLambdaTestServer.cs ├── HttpLambdaTestServerTests.cs ├── LambdaTestRequestTests.cs ├── LambdaTestResponseExtensionsTests.cs ├── LambdaTestServerCollection.cs ├── LambdaTestServerExtensionsTests.cs ├── LambdaTestServerOptionsTests.cs ├── LambdaTestServerTests.cs ├── MartinCostello.Testing.AwsLambdaTestServer.Tests.csproj ├── MyFunctionEntrypoint.cs ├── MyHandler.cs ├── MyRequest.cs ├── MyResponse.cs ├── ParallelismTests.cs ├── ReverseFunction.cs ├── ReverseFunctionTests.cs ├── ReverseFunctionWithCustomOptionsTests.cs ├── ReverseFunctionWithLoggingTests.cs ├── ReverseFunctionWithMobileSdkTests.cs └── TestExtensions.cs /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-validate": { 6 | "version": "0.0.1-preview.537", 7 | "commands": [ 8 | "dotnet-validate" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "C# (.NET)", 3 | "customizations": { 4 | "vscode": { 5 | "extensions": [ 6 | "editorconfig.editorconfig", 7 | "ms-dotnettools.csharp", 8 | "ms-vscode.PowerShell" 9 | ] 10 | } 11 | }, 12 | "postCreateCommand": "./build.ps1 -SkipTests", 13 | "remoteEnv": { 14 | "PATH": "/root/.dotnet/tools:${containerWorkspaceFolder}/.dotnet:${containerEnv:PATH}" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = crlf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{config,csproj,json,props,ruleset,targets,vsconfig,yml}] 12 | indent_size = 2 13 | 14 | [*.{sh}] 15 | end_of_line = lf 16 | 17 | # Code files 18 | [*.{cs,csx,vb,vbx}] 19 | file_header_template = Copyright (c) Martin Costello, 2019. All rights reserved.\nLicensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 20 | indent_size = 4 21 | insert_final_newline = true 22 | charset = utf-8-bom 23 | 24 | ############################### 25 | # .NET Coding Conventions # 26 | ############################### 27 | [*.{cs,vb}] 28 | 29 | # Enable style analyzers 30 | dotnet_analyzer_diagnostic.category-Style.severity = warning 31 | 32 | dotnet_diagnostic.IDE0005.severity = silent 33 | dotnet_diagnostic.IDE0045.severity = silent 34 | dotnet_diagnostic.IDE0046.severity = silent 35 | dotnet_diagnostic.IDE0058.severity = silent 36 | dotnet_diagnostic.IDE0072.severity = silent 37 | dotnet_diagnostic.IDE0079.severity = silent 38 | 39 | # Organize usings 40 | dotnet_sort_system_directives_first = true 41 | 42 | # this. preferences 43 | dotnet_style_qualification_for_field = false:none 44 | dotnet_style_qualification_for_property = false:none 45 | dotnet_style_qualification_for_method = false:none 46 | dotnet_style_qualification_for_event = false:none 47 | 48 | # Language keywords vs BCL types preferences 49 | dotnet_style_predefined_type_for_locals_parameters_members = true:none 50 | dotnet_style_predefined_type_for_member_access = true:none 51 | 52 | # Modifier preferences 53 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:none 54 | dotnet_style_readonly_field = true:suggestion 55 | 56 | # Expression-level preferences 57 | dotnet_style_object_initializer = true:suggestion 58 | dotnet_style_collection_initializer = true:suggestion 59 | dotnet_style_explicit_tuple_names = true:suggestion 60 | dotnet_style_null_propagation = true:suggestion 61 | dotnet_style_coalesce_expression = true:suggestion 62 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:none 63 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 64 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 65 | dotnet_style_prefer_auto_properties = true:none 66 | 67 | # Public API analyzer preferences 68 | dotnet_public_api_analyzer.require_api_files = true 69 | 70 | ############################### 71 | # Naming Conventions # 72 | ############################### 73 | 74 | # Style Definitions 75 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case 76 | 77 | # Use PascalCase for constant fields 78 | dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion 79 | dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields 80 | dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style 81 | dotnet_naming_symbols.constant_fields.applicable_kinds = field 82 | dotnet_naming_symbols.constant_fields.applicable_accessibilities = * 83 | dotnet_naming_symbols.constant_fields.required_modifiers = const 84 | 85 | ############################### 86 | # C# Coding Conventions # 87 | ############################### 88 | [*.cs] 89 | # var preferences 90 | csharp_style_var_for_built_in_types = true:none 91 | csharp_style_var_when_type_is_apparent = true:none 92 | csharp_style_var_elsewhere = true:none 93 | 94 | # Expression-bodied members 95 | csharp_style_expression_bodied_methods = false:none 96 | csharp_style_expression_bodied_constructors = false:none 97 | csharp_style_expression_bodied_operators = false:none 98 | csharp_style_expression_bodied_properties = true:none 99 | csharp_style_expression_bodied_indexers = true:none 100 | csharp_style_expression_bodied_accessors = true:none 101 | csharp_style_expression_bodied_local_functions = when_on_single_line 102 | 103 | # Pattern matching preferences 104 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 105 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 106 | 107 | # Null-checking preferences 108 | csharp_style_throw_expression = true:suggestion 109 | csharp_style_conditional_delegate_call = true:suggestion 110 | 111 | # Modifier preferences 112 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion 113 | 114 | # Expression-level preferences 115 | csharp_prefer_braces = true:none 116 | csharp_style_deconstructed_variable_declaration = true:suggestion 117 | csharp_prefer_simple_default_expression = true:suggestion 118 | csharp_style_pattern_local_over_anonymous_function = true:suggestion 119 | csharp_style_inlined_variable_declaration = true:suggestion 120 | 121 | # Namespace preferences 122 | csharp_style_namespace_declarations = file_scoped 123 | 124 | ############################### 125 | # C# Formatting Rules # 126 | ############################### 127 | # New line preferences 128 | csharp_new_line_before_open_brace = all 129 | csharp_new_line_before_else = true 130 | csharp_new_line_before_catch = true 131 | csharp_new_line_before_finally = true 132 | csharp_new_line_before_members_in_object_initializers = true 133 | csharp_new_line_before_members_in_anonymous_types = true 134 | csharp_new_line_between_query_expression_clauses = true 135 | 136 | # Indentation preferences 137 | csharp_indent_case_contents = true 138 | csharp_indent_switch_labels = true 139 | csharp_indent_labels = flush_left 140 | 141 | # Space preferences 142 | csharp_space_after_cast = false 143 | csharp_space_after_keywords_in_control_flow_statements = true 144 | csharp_space_between_method_call_parameter_list_parentheses = false 145 | csharp_space_between_method_declaration_parameter_list_parentheses = false 146 | csharp_space_between_parentheses = false 147 | csharp_space_before_colon_in_inheritance_clause = true 148 | csharp_space_after_colon_in_inheritance_clause = true 149 | csharp_space_around_binary_operators = before_and_after 150 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 151 | csharp_space_between_method_call_name_and_opening_parenthesis = false 152 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 153 | 154 | # Wrapping preferences 155 | csharp_preserve_single_line_statements = true 156 | csharp_preserve_single_line_blocks = true 157 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh eol=lf 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @martincostello 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | To contribute changes (source code, scripts, configuration) to this repository please follow the steps below. These steps are a guideline for contributing and do not necessarily need to be followed for all changes. 4 | 5 | 1. If you intend to fix a bug please create an issue before forking the repository. 6 | 1. Fork the `main` branch of this repository from the latest commit. 7 | 1. Create a branch from your fork's `main` branch to help isolate your changes from any further work on `main`. If fixing an issue try to reference its name in your branch name (e.g. `issue-42`) to make changes easier to track the changes. 8 | 1. Work on your proposed changes on your fork. If you are fixing an issue include at least one unit test that reproduces it if the code changes to fix it have not been applied; if you are adding new functionality please include unit tests appropriate to the changes you are making. The [code coverage figure](https://codecov.io/gh/martincostello/lambda-test-server) should be maintained where possible. 9 | 1. When you think your changes are complete, test that the code builds cleanly using `build.ps1`. There should be no compiler warnings and all tests should pass. 10 | 1. Once your changes build cleanly locally submit a Pull Request back to the `main` branch from your fork's branch. Ideally commits to your branch should be squashed before creating the Pull Request. If the Pull Request fixes an issue please reference it in the title and/or description. Please keep changes focused around a specific topic rather than include multiple types of changes in a single Pull Request. 11 | 1. After your Pull Request is created it will build against the repository's continuous integrations. 12 | 1. Once the Pull Request has been reviewed by the project's [contributors](https://github.com/martincostello/lambda-test-server/graphs/contributors) and the status checks pass your Pull Request will be merged back to the `main` branch, assuming that the changes are deemed appropriate. 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [martincostello] 2 | buy_me_a_coffee: martincostello 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: '' 3 | --- 4 | 5 | ### Expected behaviour 6 | 7 | 8 | 9 | ### Actual behaviour 10 | 11 | 12 | 13 | ### Steps to reproduce 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/10_bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Something not behaving as expected? 3 | title: '[Bug]: ' 4 | labels: ['bug'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/lambda-test-server/blob/main/README.md) before submitting a bug report. 10 | 11 | If you're not using the latest release, please try upgrading to the latest version first to see if the issue resolves itself. 12 | - type: input 13 | attributes: 14 | label: Version 15 | description: Which version of the library are you experiencing the issue with? 16 | placeholder: 0.9.0 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Describe the bug 22 | description: A clear and concise description of what the bug is. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Expected behaviour 28 | description: A clear and concise description of what you expected to happen. 29 | validations: 30 | required: false 31 | - type: textarea 32 | attributes: 33 | label: Actual behaviour 34 | description: What actually happens. 35 | validations: 36 | required: false 37 | - type: textarea 38 | attributes: 39 | label: Steps to reproduce 40 | description: | 41 | Provide a link to a [minimalistic project which reproduces this issue (repro)](https://stackoverflow.com/help/mcve) hosted in a **public** GitHub repository. 42 | Code snippets, such as a failing unit test or small console app, which demonstrate the issue wrapped in a [fenced code block](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks) are also acceptable. 43 | 44 | This issue will be closed if: 45 | - The behaviour you're reporting cannot be easily reproduced. 46 | - The issue is a duplicate of an existing issue. 47 | - The behaviour you're reporting is by design. 48 | validations: 49 | required: false 50 | - type: textarea 51 | attributes: 52 | label: Exception(s) (if any) 53 | description: Include any exception(s) and/or stack trace(s) you get when facing this issue. 54 | render: text 55 | validations: 56 | required: false 57 | - type: input 58 | attributes: 59 | label: .NET Version 60 | description: | 61 | Run `dotnet --version` to get the .NET SDK version you're using. 62 | Alternatively, which target framework(s) (e.g. `net8.0`) does the project you're using the package with target? 63 | placeholder: 9.0.100 64 | validations: 65 | required: false 66 | - type: textarea 67 | attributes: 68 | label: Anything else? 69 | description: | 70 | Links? References? Anything that will give us more context about the issue you are encountering is useful. 71 | 72 | 💡Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 73 | validations: 74 | required: false 75 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/20_feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature request 2 | description: Suggest a feature request or improvement 3 | title: '[Feature request]: ' 4 | labels: ['feature-request'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/lambda-test-server/blob/main/README.md) before submitting a feature request. 10 | - type: textarea 11 | attributes: 12 | label: Is your feature request related to a specific problem? Or an existing feature? 13 | description: A clear and concise description of what the problem is. Motivating examples help prioritise things. 14 | placeholder: I am trying to [...] but [...] 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Describe the solution you'd like 20 | description: | 21 | A clear and concise description of what you want to happen. Include any alternative solutions you've considered. 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: Additional context 27 | description: | 28 | Add any other context or screenshots about the feature request here. 29 | validations: 30 | required: false 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/30_question.yml: -------------------------------------------------------------------------------- 1 | name: 🤔 Question? 2 | description: You have something specific to achieve and the existing documentation hasn't covered how. 3 | title: '[Question]: ' 4 | labels: ['question'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Please check for an existing issue and the [README](https://github.com/martincostello/lambda-test-server/blob/main/README.md) before asking a question. 10 | - type: textarea 11 | attributes: 12 | label: What do you want to achieve? 13 | description: A clear and concise description of what you're trying to do. 14 | placeholder: I am trying to [...] but [...] 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: What code or approach do you have so far? 20 | description: | 21 | Provide a [minimalistic project which shows what you have so far](https://stackoverflow.com/help/mcve) hosted in a **public** GitHub repository. 22 | Code snippets wrapped in a [fenced code block](https://docs.github.com/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks) are also acceptable. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Additional context 28 | description: | 29 | Add any other context or screenshots related to your question here. 30 | validations: 31 | required: false 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Contact me 4 | url: https://martincostello.com/bluesky 5 | about: You can also contact me on Bluesky. 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.github/actionlint-matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "actionlint", 5 | "pattern": [ 6 | { 7 | "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "message": 4, 12 | "code": 5 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "05:30" 8 | timezone: Europe/London 9 | - package-ecosystem: nuget 10 | directory: "/" 11 | groups: 12 | xunit: 13 | patterns: 14 | - xunit* 15 | schedule: 16 | interval: daily 17 | time: "05:30" 18 | timezone: Europe/London 19 | open-pull-requests-limit: 99 20 | ignore: 21 | - dependency-name: "AWSSDK.SQS" 22 | update-types: ["version-update:semver-patch"] 23 | - dependency-name: "Microsoft.AspNetCore.TestHost" 24 | -------------------------------------------------------------------------------- /.github/update-dotnet-sdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/martincostello/github-automation/main/.github/update-dotnet-sdk-schema.json", 3 | "update-nuget-packages": false 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ v* ] 7 | paths-ignore: 8 | - '**/*.gitattributes' 9 | - '**/*.gitignore' 10 | - '**/*.md' 11 | pull_request: 12 | branches: 13 | - main 14 | - dotnet-vnext 15 | - dotnet-nightly 16 | workflow_dispatch: 17 | 18 | env: 19 | DOTNET_CLI_TELEMETRY_OPTOUT: true 20 | DOTNET_NOLOGO: true 21 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true 22 | DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 23 | NUGET_XMLDOC_MODE: skip 24 | TERM: xterm 25 | 26 | permissions: 27 | contents: read 28 | 29 | jobs: 30 | build: 31 | name: ${{ matrix.os-name }} 32 | runs-on: ${{ matrix.runner }} 33 | timeout-minutes: 20 34 | 35 | outputs: 36 | dotnet-sdk-version: ${{ steps.setup-dotnet.outputs.dotnet-version }} 37 | dotnet-validate-version: ${{ steps.get-dotnet-validate-version.outputs.dotnet-validate-version }} 38 | package-names: ${{ steps.build.outputs.package-names }} 39 | package-version: ${{ steps.build.outputs.package-version }} 40 | 41 | permissions: 42 | attestations: write 43 | contents: write 44 | id-token: write 45 | 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | include: 50 | - os-name: macos 51 | runner: macos-latest 52 | - os-name: linux 53 | runner: ubuntu-latest 54 | - os-name: windows 55 | runner: windows-latest 56 | 57 | steps: 58 | 59 | - name: Checkout code 60 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 61 | with: 62 | filter: 'tree:0' 63 | show-progress: false 64 | 65 | - name: Setup .NET SDK 66 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 67 | id: setup-dotnet 68 | 69 | - name: Setup NuGet cache 70 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 71 | with: 72 | path: ~/.nuget/packages 73 | key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/*.props') }} 74 | restore-keys: ${{ runner.os }}-nuget- 75 | 76 | - name: Configure AWS credentials 77 | uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1 78 | if: | 79 | github.event.repository.fork == false && 80 | !contains('["costellobot[bot]", "dependabot[bot]", "github-actions[bot]"]', github.actor) 81 | with: 82 | role-to-assume: arn:aws:iam::492538393790:role/github-actions-martincostello-lambda-test-server 83 | role-session-name: ${{ github.event.repository.name }}-${{ github.run_id }}-build 84 | aws-region: eu-west-2 85 | 86 | - name: Build, Test and Package 87 | id: build 88 | shell: pwsh 89 | run: ./build.ps1 90 | 91 | - name: Upload coverage to Codecov 92 | uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 93 | with: 94 | flags: ${{ matrix.os-name }} 95 | token: ${{ secrets.CODECOV_TOKEN }} 96 | 97 | - name: Upload test results to Codecov 98 | uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 99 | if: ${{ !cancelled() }} 100 | with: 101 | flags: ${{ matrix.os-name }} 102 | token: ${{ secrets.CODECOV_TOKEN }} 103 | 104 | - name: Generate SBOM 105 | uses: anchore/sbom-action@e11c554f704a0b820cbf8c51673f6945e0731532 # v0.20.0 106 | with: 107 | artifact-name: build-${{ matrix.os-name }}.spdx.json 108 | output-file: ./artifacts/build.spdx.json 109 | path: ./artifacts/bin 110 | upload-release-assets: ${{ runner.os == 'Windows' }} 111 | 112 | - name: Attest artifacts 113 | uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 114 | if: | 115 | runner.os == 'Windows' && 116 | github.event.repository.fork == false && 117 | (github.ref_name == github.event.repository.default_branch || startsWith(github.ref, 'refs/tags/v')) 118 | with: 119 | subject-path: | 120 | ./artifacts/bin/MartinCostello.Testing.AwsLambdaTestServer/release*/*.dll 121 | ./artifacts/package/release/* 122 | 123 | - name: Publish artifacts 124 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 125 | with: 126 | name: artifacts-${{ matrix.os-name }} 127 | path: ./artifacts 128 | 129 | - name: Publish NuGet packages 130 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 131 | with: 132 | name: packages-${{ matrix.os-name }} 133 | path: ./artifacts/package/release 134 | if-no-files-found: error 135 | 136 | - name: Get dotnet-validate version 137 | id: get-dotnet-validate-version 138 | shell: pwsh 139 | run: | 140 | $dotnetValidateVersion = (Get-Content "./.config/dotnet-tools.json" | Out-String | ConvertFrom-Json).tools.'dotnet-validate'.version 141 | "dotnet-validate-version=${dotnetValidateVersion}" >> $env:GITHUB_OUTPUT 142 | 143 | validate-packages: 144 | needs: build 145 | runs-on: ubuntu-latest 146 | steps: 147 | 148 | - name: Download packages 149 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 150 | with: 151 | name: packages-windows 152 | 153 | - name: Setup .NET SDK 154 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 155 | with: 156 | dotnet-version: ${{ needs.build.outputs.dotnet-sdk-version }} 157 | 158 | - name: Validate NuGet packages 159 | shell: pwsh 160 | env: 161 | DOTNET_VALIDATE_VERSION: ${{ needs.build.outputs.dotnet-validate-version }} 162 | run: | 163 | dotnet tool install --global dotnet-validate --version ${env:DOTNET_VALIDATE_VERSION} --allow-roll-forward 164 | $packages = Get-ChildItem -Filter "*.nupkg" | ForEach-Object { $_.FullName } 165 | $invalidPackages = 0 166 | foreach ($package in $packages) { 167 | dotnet validate package local $package 168 | if ($LASTEXITCODE -ne 0) { 169 | $invalidPackages++ 170 | } 171 | } 172 | if ($invalidPackages -gt 0) { 173 | Write-Output "::error::$invalidPackages NuGet package(s) failed validation." 174 | exit 1 175 | } 176 | 177 | publish-feedz-io: 178 | needs: [ build, validate-packages ] 179 | runs-on: ubuntu-latest 180 | if: | 181 | github.event.repository.fork == false && 182 | (github.ref_name == github.event.repository.default_branch || 183 | startsWith(github.ref, 'refs/tags/v')) 184 | environment: 185 | name: feedz.io 186 | url: https://feedz.io/org/${{ github.repository_owner }}/repository/lambda-test-server/packages/MartinCostello.Testing.AwsLambdaTestServer 187 | 188 | steps: 189 | 190 | - name: Download packages 191 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 192 | with: 193 | name: packages-windows 194 | 195 | - name: Setup .NET SDK 196 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 197 | with: 198 | dotnet-version: ${{ needs.build.outputs.dotnet-sdk-version }} 199 | 200 | - name: Push NuGet packages to feedz.io 201 | shell: bash 202 | env: 203 | API_KEY: ${{ secrets.FEEDZ_IO_TOKEN }} 204 | PACKAGE_VERSION: ${{ needs.build.outputs.package-version }} 205 | SOURCE: "https://f.feedz.io/${{ github.repository }}/nuget/index.json" 206 | run: dotnet nuget push "*.nupkg" --api-key "${API_KEY}" --skip-duplicate --source "${SOURCE}" && echo "::notice title=feedz.io::Published version ${PACKAGE_VERSION} to feedz.io." 207 | 208 | publish-nuget: 209 | needs: [ build, validate-packages ] 210 | runs-on: ubuntu-latest 211 | if: | 212 | github.event.repository.fork == false && 213 | startsWith(github.ref, 'refs/tags/v') 214 | 215 | environment: 216 | name: NuGet.org 217 | url: https://www.nuget.org/packages/MartinCostello.Testing.AwsLambdaTestServer 218 | 219 | steps: 220 | 221 | - name: Download packages 222 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 223 | with: 224 | name: packages-windows 225 | 226 | - name: Setup .NET SDK 227 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 228 | with: 229 | dotnet-version: ${{ needs.build.outputs.dotnet-sdk-version }} 230 | 231 | - name: Push NuGet packages to NuGet.org 232 | shell: bash 233 | env: 234 | API_KEY: ${{ secrets.NUGET_TOKEN }} 235 | PACKAGE_VERSION: ${{ needs.build.outputs.package-version }} 236 | SOURCE: https://api.nuget.org/v3/index.json 237 | run: dotnet nuget push "*.nupkg" --api-key "${API_KEY}" --skip-duplicate --source "${SOURCE}" && echo "::notice title=nuget.org::Published version ${PACKAGE_VERSION} to NuGet.org." 238 | 239 | - name: Publish nuget_packages_published 240 | uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0 241 | with: 242 | event-type: nuget_packages_published 243 | repository: ${{ github.repository_owner }}/github-automation 244 | token: ${{ secrets.COSTELLOBOT_TOKEN }} 245 | client-payload: |- 246 | { 247 | "repository": "${{ github.repository }}", 248 | "packages": "${{ needs.build.outputs.package-names }}", 249 | "version": "${{ needs.build.outputs.package-version }}" 250 | } 251 | -------------------------------------------------------------------------------- /.github/workflows/bump-version.yml: -------------------------------------------------------------------------------- 1 | name: bump-version 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | workflow_dispatch: 7 | inputs: 8 | version: 9 | description: 'The optional version string for the next release.' 10 | required: false 11 | type: string 12 | default: '' 13 | 14 | permissions: {} 15 | 16 | jobs: 17 | bump-version: 18 | runs-on: [ ubuntu-latest ] 19 | 20 | concurrency: 21 | group: ${{ github.workflow }} 22 | cancel-in-progress: false 23 | 24 | steps: 25 | 26 | - name: Checkout code 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | with: 29 | filter: 'tree:0' 30 | show-progress: false 31 | token: ${{ secrets.COSTELLOBOT_TOKEN }} 32 | 33 | - name: Bump version 34 | id: bump-version 35 | shell: pwsh 36 | env: 37 | NEXT_VERSION: ${{ inputs.version }} 38 | run: | 39 | $properties = Join-Path "." "Directory.Build.props" 40 | 41 | $xml = [xml](Get-Content $properties) 42 | $versionPrefix = $xml.SelectSingleNode('Project/PropertyGroup/VersionPrefix') 43 | $publishedVersion = $versionPrefix.InnerText 44 | 45 | if (-Not [string]::IsNullOrEmpty(${env:NEXT_VERSION})) { 46 | $version = [System.Version]::new(${env:NEXT_VERSION}) 47 | $assemblyVersionProperty = $xml.SelectSingleNode('Project/PropertyGroup/AssemblyVersion') 48 | $assemblyVersion = [System.Version]::new($version.Major, ($version.Major -eq 0 ? $version.Minor : 0), 0, 0) 49 | $assemblyVersionProperty.InnerText = $assemblyVersion.ToString() 50 | } else { 51 | $version = [System.Version]::new($publishedVersion) 52 | $version = [System.Version]::new($version.Major, $version.Minor, $version.Build + 1) 53 | } 54 | 55 | $updatedVersion = $version.ToString() 56 | $versionPrefix.InnerText = $updatedVersion 57 | 58 | $packageValidationBaselineVersion = $xml.SelectSingleNode('Project/PropertyGroup/PackageValidationBaselineVersion') 59 | $packageValidationBaselineVersion.InnerText = $publishedVersion 60 | 61 | $settings = New-Object System.Xml.XmlWriterSettings 62 | $settings.Encoding = New-Object System.Text.UTF8Encoding($false) 63 | $settings.Indent = $true 64 | $settings.OmitXmlDeclaration = $true 65 | 66 | $writer = [System.Xml.XmlWriter]::Create($properties, $settings) 67 | 68 | $xml.Save($writer) 69 | 70 | $writer.Flush() 71 | $writer.Close() 72 | $writer = $null 73 | 74 | "" >> $properties 75 | 76 | "version=${updatedVersion}" >> $env:GITHUB_OUTPUT 77 | 78 | - name: Push changes to GitHub 79 | id: push-changes 80 | shell: pwsh 81 | env: 82 | GIT_COMMIT_USER_EMAIL: ${{ vars.GIT_COMMIT_USER_EMAIL }} 83 | GIT_COMMIT_USER_NAME: ${{ vars.GIT_COMMIT_USER_NAME }} 84 | NEXT_VERSION: ${{ steps.bump-version.outputs.version }} 85 | run: | 86 | $gitStatus = (git status --porcelain) 87 | 88 | if ([string]::IsNullOrEmpty($gitStatus)) { 89 | throw "No changes to commit." 90 | } 91 | 92 | git config color.diff always 93 | git --no-pager diff 94 | 95 | $branchName = "bump-version-${env:NEXT_VERSION}" 96 | 97 | git config user.email ${env:GIT_COMMIT_USER_EMAIL} | Out-Null 98 | git config user.name ${env:GIT_COMMIT_USER_NAME} | Out-Null 99 | git fetch origin --no-tags | Out-Null 100 | git rev-parse --verify --quiet "remotes/origin/${branchName}" | Out-Null 101 | 102 | if ($LASTEXITCODE -eq 0) { 103 | Write-Output "Branch ${branchName} already exists." 104 | exit 0 105 | } 106 | 107 | git checkout -b $branchName 108 | git add . 109 | git commit -m "Bump version`n`nBump version to ${env:NEXT_VERSION} for the next release." 110 | git push -u origin $branchName 111 | 112 | "branch-name=${branchName}" >> $env:GITHUB_OUTPUT 113 | "updated-version=true" >> $env:GITHUB_OUTPUT 114 | "version=${env:NEXT_VERSION}" >> $env:GITHUB_OUTPUT 115 | 116 | - name: Create pull request 117 | if: steps.push-changes.outputs.updated-version == 'true' 118 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 119 | env: 120 | BASE_BRANCH: ${{ github.event.repository.default_branch }} 121 | HEAD_BRANCH: ${{ steps.push-changes.outputs.branch-name }} 122 | NEXT_VERSION: ${{ steps.push-changes.outputs.version }} 123 | with: 124 | github-token: ${{ secrets.COSTELLOBOT_TOKEN }} 125 | script: | 126 | const nextVersion = process.env.NEXT_VERSION; 127 | const { repo, owner } = context.repo; 128 | const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; 129 | 130 | const { data: pr } = await github.rest.pulls.create({ 131 | title: 'Bump version', 132 | owner, 133 | repo, 134 | head: process.env.HEAD_BRANCH, 135 | base: process.env.BASE_BRANCH, 136 | draft: true, 137 | body: [ 138 | `Bump version to \`${nextVersion}\` for the next release.`, 139 | '', 140 | `This pull request was generated by [GitHub Actions](${workflowUrl}).` 141 | ].join('\n') 142 | }); 143 | 144 | core.notice(`Created pull request ${owner}/${repo}#${pr.number}: ${pr.html_url}`); 145 | 146 | try { 147 | const { data: milestones } = await github.rest.issues.listMilestones({ 148 | owner, 149 | repo, 150 | state: 'open', 151 | }); 152 | 153 | const title = `v${nextVersion}`; 154 | let milestone = milestones.find((p) => p.title === title); 155 | 156 | if (!milestone) { 157 | const created = await github.rest.issues.createMilestone({ 158 | owner, 159 | repo, 160 | title, 161 | }); 162 | milestone = created.data; 163 | } 164 | 165 | await github.rest.issues.update({ 166 | owner, 167 | repo, 168 | issue_number: pr.number, 169 | milestone: milestone.number 170 | }); 171 | } catch (error) { 172 | // Ignore 173 | } 174 | 175 | close-milestone: 176 | runs-on: [ ubuntu-latest ] 177 | if: github.event_name == 'release' 178 | 179 | concurrency: 180 | group: ${{ github.workflow }} 181 | cancel-in-progress: false 182 | 183 | steps: 184 | 185 | - name: Close milestone 186 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 187 | env: 188 | RELEASE_DATE: ${{ github.event.release.published_at }} 189 | RELEASE_VERSION: ${{ github.event.release.tag_name }} 190 | with: 191 | github-token: ${{ secrets.COSTELLOBOT_TOKEN }} 192 | script: | 193 | const { repo, owner } = context.repo; 194 | 195 | const { data: milestones } = await github.rest.issues.listMilestones({ 196 | owner, 197 | repo, 198 | state: 'open', 199 | }); 200 | 201 | const milestone = milestones.find((p) => p.title === process.env.RELEASE_VERSION); 202 | 203 | if (!milestone) { 204 | return; 205 | } 206 | 207 | try { 208 | await github.rest.issues.updateMilestone({ 209 | owner, 210 | repo, 211 | milestone_number: milestone.number, 212 | state: 'closed', 213 | due_on: process.env.RELEASE_DATE, 214 | }); 215 | } catch (error) { 216 | // Ignore 217 | } 218 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: codeql 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: 8 | - main 9 | - dotnet-vnext 10 | - dotnet-nightly 11 | schedule: 12 | - cron: '0 6 * * MON' 13 | workflow_dispatch: 14 | 15 | permissions: {} 16 | 17 | jobs: 18 | analysis: 19 | runs-on: ubuntu-latest 20 | 21 | permissions: 22 | actions: read 23 | contents: read 24 | security-events: write 25 | 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | language: [ 'actions', 'csharp' ] 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | with: 35 | filter: 'tree:0' 36 | show-progress: false 37 | 38 | - name: Initialize CodeQL 39 | uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 40 | with: 41 | build-mode: none 42 | languages: ${{ matrix.language }} 43 | queries: security-and-quality 44 | 45 | - name: Perform CodeQL Analysis 46 | uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 47 | with: 48 | category: '/language:${{ matrix.language }}' 49 | 50 | codeql: 51 | if: ${{ !cancelled() }} 52 | needs: [ analysis ] 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - name: Report status 57 | shell: bash 58 | env: 59 | SCAN_SUCCESS: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} 60 | run: | 61 | if [ "${SCAN_SUCCESS}" == "true" ] 62 | then 63 | echo 'CodeQL analysis successful ✅' 64 | else 65 | echo 'CodeQL analysis failed ❌' 66 | exit 1 67 | fi 68 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: dependency-review 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - dotnet-vnext 8 | - dotnet-nightly 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | with: 22 | filter: 'tree:0' 23 | show-progress: false 24 | 25 | - name: Review dependencies 26 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: 7 | - '**/*.gitattributes' 8 | - '**/*.gitignore' 9 | - '**/*.md' 10 | pull_request: 11 | branches: 12 | - main 13 | - dotnet-vnext 14 | - dotnet-nightly 15 | workflow_dispatch: 16 | 17 | permissions: 18 | contents: read 19 | 20 | env: 21 | FORCE_COLOR: 3 22 | POWERSHELL_YAML_VERSION: '0.4.12' 23 | PSSCRIPTANALYZER_VERSION: '1.24.0' 24 | TERM: xterm 25 | 26 | jobs: 27 | lint: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | 32 | - name: Checkout code 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | with: 35 | filter: 'tree:0' 36 | show-progress: false 37 | 38 | - name: Add actionlint problem matcher 39 | run: echo "::add-matcher::.github/actionlint-matcher.json" 40 | 41 | - name: Lint workflows 42 | uses: docker://rhysd/actionlint@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9 # v1.7.7 43 | with: 44 | args: -color 45 | 46 | - name: Lint markdown 47 | uses: DavidAnson/markdownlint-cli2-action@992badcdf24e3b8eb7e87ff9287fe931bcb00c6e # v20.0.0 48 | with: 49 | config: '.markdownlint.json' 50 | globs: | 51 | **/*.md 52 | 53 | - name: Lint PowerShell in workflows 54 | uses: martincostello/lint-actions-powershell@5942e3350ee5bd8f8933cec4e1185d13f0ea688f # v1.0.0 55 | with: 56 | powershell-yaml-version: ${{ env.POWERSHELL_YAML_VERSION }} 57 | psscriptanalyzer-version: ${{ env.PSSCRIPTANALYZER_VERSION }} 58 | treat-warnings-as-errors: true 59 | 60 | - name: Lint PowerShell scripts 61 | shell: pwsh 62 | run: | 63 | $settings = @{ 64 | IncludeDefaultRules = $true 65 | Severity = @("Error", "Warning") 66 | } 67 | $issues = Invoke-ScriptAnalyzer -Path ${env:GITHUB_WORKSPACE} -Recurse -ReportSummary -Settings $settings 68 | foreach ($issue in $issues) { 69 | $severity = $issue.Severity.ToString() 70 | $level = $severity.Contains("Error") ? "error" : $severity.Contains("Warning") ? "warning" : "notice" 71 | Write-Output "::${level} file=$($issue.ScriptName),line=$($issue.Line),title=PSScriptAnalyzer::$($issue.Message)" 72 | } 73 | if ($issues.Count -gt 0) { 74 | exit 1 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/ossf-scorecard.yml: -------------------------------------------------------------------------------- 1 | name: ossf-scorecard 2 | 3 | on: 4 | branch_protection_rule: 5 | push: 6 | branches: [ main ] 7 | schedule: 8 | - cron: '0 5 * * MON' 9 | workflow_dispatch: 10 | 11 | permissions: read-all 12 | 13 | jobs: 14 | analysis: 15 | name: analysis 16 | runs-on: ubuntu-latest 17 | 18 | permissions: 19 | id-token: write 20 | security-events: write 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | filter: 'tree:0' 27 | persist-credentials: false 28 | show-progress: false 29 | 30 | - name: Run analysis 31 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 32 | with: 33 | publish_results: true 34 | results_file: results.sarif 35 | results_format: sarif 36 | 37 | - name: Upload artifact 38 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 39 | with: 40 | name: SARIF 41 | path: results.sarif 42 | retention-days: 5 43 | 44 | - name: Upload to code-scanning 45 | uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 46 | with: 47 | sarif_file: results.sarif 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | publish: 7 | description: 'If true, does not create the release as a draft.' 8 | required: false 9 | type: boolean 10 | default: false 11 | 12 | permissions: {} 13 | 14 | jobs: 15 | release: 16 | runs-on: [ ubuntu-latest ] 17 | 18 | concurrency: 19 | group: ${{ github.workflow }} 20 | cancel-in-progress: false 21 | 22 | steps: 23 | 24 | - name: Checkout code 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | with: 27 | filter: 'tree:0' 28 | show-progress: false 29 | token: ${{ secrets.COSTELLOBOT_TOKEN }} 30 | 31 | - name: Get version 32 | id: get-version 33 | shell: pwsh 34 | run: | 35 | $properties = Join-Path "." "Directory.Build.props" 36 | $xml = [xml](Get-Content $properties) 37 | $version = $xml.SelectSingleNode('Project/PropertyGroup/VersionPrefix').InnerText 38 | "version=${version}" >> $env:GITHUB_OUTPUT 39 | 40 | - name: Create release 41 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 42 | env: 43 | DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} 44 | DRAFT: ${{ inputs.publish != true }} 45 | VERSION: ${{ steps.get-version.outputs.version }} 46 | with: 47 | github-token: ${{ secrets.COSTELLOBOT_TOKEN }} 48 | script: | 49 | const { repo, owner } = context.repo; 50 | const draft = process.env.DRAFT === 'true'; 51 | const version = process.env.VERSION; 52 | const tag_name = `v${version}`; 53 | const name = tag_name; 54 | 55 | const { data: notes } = await github.rest.repos.generateReleaseNotes({ 56 | owner, 57 | repo, 58 | tag_name, 59 | target_commitish: process.env.DEFAULT_BRANCH, 60 | }); 61 | 62 | const body = notes.body 63 | .split('\n') 64 | .filter((line) => !line.includes(' @costellobot ')) 65 | .filter((line) => !line.includes(' @dependabot ')) 66 | .filter((line) => !line.includes(' @github-actions ')) 67 | .join('\n'); 68 | 69 | const { data: release } = await github.rest.repos.createRelease({ 70 | owner, 71 | repo, 72 | tag_name, 73 | name, 74 | body, 75 | draft, 76 | }); 77 | 78 | core.notice(`Created release ${release.name}: ${release.html_url}`); 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dotnet 3 | .idea 4 | .metadata 5 | .settings 6 | .vs 7 | _ReSharper* 8 | _reports 9 | _UpgradeReport_Files/ 10 | artifacts/ 11 | Backup*/ 12 | bin 13 | Bin 14 | coverage 15 | coverage.* 16 | junit.xml 17 | MSBuild_Logs/ 18 | obj 19 | packages 20 | TestResults 21 | UpgradeLog*.htm 22 | UpgradeLog*.XML 23 | *.binlog 24 | *.coverage 25 | *.DotSettings 26 | *.log 27 | *.nupkg 28 | *.opensdf 29 | *.[Pp]ublish.xml 30 | *.publishproj 31 | *.pubxml 32 | *.sdf 33 | *.sln.cache 34 | *.sln.docstates 35 | *.sln.ide 36 | *.suo 37 | *.user 38 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD040": false 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "editorconfig.editorconfig", 4 | "ms-dotnettools.csharp", 5 | "ms-vscode.PowerShell" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run tests", 6 | "type": "coreclr", 7 | "request": "launch", 8 | "preLaunchTask": "build", 9 | "program": "dotnet", 10 | "args": [ 11 | "test" 12 | ], 13 | "cwd": "${workspaceFolder}/tests/AwsLambdaTestServer.Tests", 14 | "console": "internalConsole", 15 | "stopAtEntry": false, 16 | "internalConsoleOptions": "openOnSessionStart" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet build", 7 | "type": "shell", 8 | "group": "build", 9 | "presentation": { 10 | "reveal": "silent" 11 | }, 12 | "problemMatcher": "$msCompile" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vsconfig: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "components": [ 4 | "Microsoft.VisualStudio.Component.CoreEditor", 5 | "Microsoft.VisualStudio.Workload.CoreEditor", 6 | "Microsoft.NetCore.Component.Runtime.9.0", 7 | "Microsoft.NetCore.Component.SDK", 8 | "Microsoft.VisualStudio.Component.Roslyn.Compiler", 9 | "Microsoft.VisualStudio.Component.Roslyn.LanguageServices" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /AwsLambdaTestServer.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /AwsLambdaTestServer.slnx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /AwsLambdaTestServer.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/lambda-test-server/f886c3c2582a893cbba5c363167b594f74ad30cd/AwsLambdaTestServer.snk -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team [through a GitHub issue](https://github.com/martincostello/lambda-test-server/issues). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | $(MSBuildThisFileDirectory)AwsLambdaTestServer.snk 5 | $(MSBuildThisFileDirectory)AwsLambdaTestServer.ruleset 6 | true 7 | aws;lambda;testserver;testing 8 | true 9 | false 10 | 0.9.0.0 11 | 0.9.0 12 | 0.9.1 13 | 14 | 15 | true 16 | $(NoWarn);419;1570;1573;1574;1584;1591;SA0001;SA1602 17 | 18 | 19 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | https://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | https://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Lambda Test Server for .NET 2 | 3 | [![NuGet](https://img.shields.io/nuget/v/MartinCostello.Testing.AwsLambdaTestServer?logo=nuget&label=Latest&color=blue)](https://www.nuget.org/packages/MartinCostello.Testing.AwsLambdaTestServer "Download MartinCostello.Testing.AwsLambdaTestServer from NuGet") 4 | [![NuGet Downloads](https://img.shields.io/nuget/dt/MartinCostello.Testing.AwsLambdaTestServer?logo=nuget&label=Downloads&color=blue)](https://www.nuget.org/packages/MartinCostello.Testing.AwsLambdaTestServer "Download MartinCostello.Testing.AwsLambdaTestServer from NuGet") 5 | 6 | [![Build status](https://github.com/martincostello/lambda-test-server/actions/workflows/build.yml/badge.svg?branch=main&event=push)](https://github.com/martincostello/lambda-test-server/actions/workflows/build.yml?query=branch%3Amain+event%3Apush) 7 | [![codecov](https://codecov.io/gh/martincostello/lambda-test-server/branch/main/graph/badge.svg)](https://codecov.io/gh/martincostello/lambda-test-server) 8 | [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/martincostello/lambda-test-server/badge)](https://securityscorecards.dev/viewer/?uri=github.com/martincostello/lambda-test-server) 9 | 10 | ## Introduction 11 | 12 | A NuGet package that builds on top of the `TestServer` class in the [Microsoft.AspNetCore.TestHost](https://www.nuget.org/packages/Microsoft.AspNetCore.TestHost) NuGet package to provide infrastructure to use with end-to-end/integration tests of .NET 6 AWS Lambda Functions using a custom runtime with the `LambdaBootstrap` class from the [Amazon.Lambda.RuntimeSupport](https://www.nuget.org/packages/Amazon.Lambda.RuntimeSupport/) NuGet package. 13 | 14 | [_.NET Core 3.0 on Lambda with AWS Lambda's Custom Runtime_](https://aws.amazon.com/blogs/developer/net-core-3-0-on-lambda-with-aws-lambdas-custom-runtime/ ".NET Core 3.0 on Lambda with AWS Lambda's Custom Runtime on the AWS Developer Blog") 15 | 16 | ### Installation 17 | 18 | To install the library from [NuGet](https://www.nuget.org/packages/MartinCostello.Testing.AwsLambdaTestServer/ "MartinCostello.Testing.AwsLambdaTestServer on NuGet.org") using the .NET SDK run: 19 | 20 | ```sh 21 | dotnet add package MartinCostello.Testing.AwsLambdaTestServer 22 | ``` 23 | 24 | ### Usage 25 | 26 | Before you can use the Lambda test server to test your function, you need to factor your function entry-point 27 | in such a way that you can supply both a `HttpClient` and `CancellationToken` to it from your tests. This is to allow you to both plug in the `HttpClient` for the test server into `LambdaBootstrap`, and to stop the Lambda function running at a time of your choosing by signalling the `CancellationToken`. 28 | 29 | Here's an example of how to do this with a simple Lambda function that takes an array of integers and returns them in reverse order: 30 | 31 | ```csharp 32 | using System.Linq; 33 | using System.Net.Http; 34 | using System.Threading; 35 | using System.Threading.Tasks; 36 | using Amazon.Lambda.RuntimeSupport; 37 | using Amazon.Lambda.Serialization.Json; 38 | 39 | namespace MyFunctions; 40 | 41 | public static class ReverseFunction 42 | { 43 | public static async Task Main() 44 | => await RunAsync(); 45 | 46 | public static async Task RunAsync( 47 | HttpClient httpClient = null, 48 | CancellationToken cancellationToken = default) 49 | { 50 | var serializer = new JsonSerializer(); 51 | 52 | using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(ReverseAsync, serializer); 53 | using var bootstrap = new LambdaBootstrap(httpClient ?? new HttpClient(), handlerWrapper); 54 | 55 | await bootstrap.RunAsync(cancellationToken); 56 | } 57 | 58 | public static Task ReverseAsync(int[] values) 59 | => Task.FromResult(values.Reverse().ToArray()); 60 | } 61 | ``` 62 | 63 | Once you've done that, you can use `LambdaTestServer` in your tests with your function to verify how it processes requests. 64 | 65 | Here's an example using xunit to verify that `ReverseFunction` works as intended: 66 | 67 | ```csharp 68 | using System; 69 | using System.Text.Json; 70 | using System.Threading; 71 | using System.Threading.Tasks; 72 | using MartinCostello.Testing.AwsLambdaTestServer; 73 | using Xunit; 74 | 75 | namespace MyFunctions; 76 | 77 | public static class ReverseFunctionTests 78 | { 79 | [Fact] 80 | public static async Task Function_Reverses_Numbers() 81 | { 82 | // Arrange 83 | using var server = new LambdaTestServer(); 84 | using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(1)); 85 | 86 | await server.StartAsync(cancellationTokenSource.Token); 87 | 88 | int[] value = [1, 2, 3]; 89 | string json = JsonSerializer.Serialize(value); 90 | 91 | LambdaTestContext context = await server.EnqueueAsync(json); 92 | 93 | using var httpClient = server.CreateClient(); 94 | 95 | // Act 96 | await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token); 97 | 98 | // Assert 99 | Assert.True(context.Response.TryRead(out LambdaTestResponse response)); 100 | Assert.True(response.IsSuccessful); 101 | 102 | json = await response.ReadAsStringAsync(); 103 | int[] actual = JsonSerializer.Deserialize(json); 104 | 105 | Assert.Equal([3, 2, 1], actual); 106 | } 107 | } 108 | ``` 109 | 110 | The key parts to call out here are: 111 | 112 | 1. An instance of `LambdaTestServer` is created and then the `StartAsync()` method called with a `CancellationToken` that allows the test to stop the function. In the example here the token is signalled with a timeout, but you could also write code to stop the processing based on arbitrary criteria. 113 | 1. The request that the Lambda function should be invoked with is passed to `EnqueueAsync()`. This can be specified with an instance of `LambdaTestRequest` for fine-grained control, but there are overloads that accept `byte[]` and `string`. You could also make your own extensions to serialize objects to JSON using the serializer of your choice. 114 | 1. `EnqueueAsync()` returns a `LambdaTestContext`. This contains a reference to the `LambdaTestRequest` and a `ChannelReader`. This channel reader can be used to await the request being processed by the function under test. 115 | 1. Once the request is enqueued, an `HttpClient` is obtained from the test server and passed to the function to test with the cancellation token and run by calling `RunAsync()`. 116 | 1. Once the function processing completes after the `CancellationToken` is signalled, the channel reader is read to obtain the `LambdaTestResponse` for the request that was enqueued. 117 | 1. Once this is returned from the channel reader, the response is checked for success using `IsSuccessful` and then the `Content` (which is a `byte[]`) is deserialized into the expected response to be asserted on. Again, you could make your own extensions to deserialize the response content into `string` or objects from JSON. 118 | 119 | The library itself targets `net8.0` and `net9.0` so requires your test project to target at least .NET 8. 120 | 121 | #### Sequence Diagram 122 | 123 | The sequence diagram below illustrates the flow of events for a test using the test server for the above example. 124 | 125 | ```mermaid 126 | sequenceDiagram 127 | autonumber 128 | 129 | participant T as Test Method 130 | participant S as Lambda Test Server 131 | participant F as Lambda Function 132 | participant H as Handler 133 | 134 | title How AWS Lambda Test Server Works 135 | 136 | note over T:Arrange 137 | 138 | T->>+S: Start test server 139 | 140 | S->>S:Start HTTP server 141 | 142 | S-->>T: 143 | 144 | T->>S:Queue request 145 | 146 | note over S:Request is queued 147 | 148 | S-->>T:LambdaTestContext 149 | 150 | T->>+F:Create function with HttpClient for Test Server 151 | 152 | note over T:Act 153 | 154 | note over T:Wait for request(s)
to be handled 155 | 156 | loop Poll for Lambda invocations 157 | 158 | F->>S:GET /{LambdaVersion}/runtime/invocation/next 159 | 160 | note over S:Request is dequeued 161 | 162 | S-->>F:HTTP 200 163 | 164 | F->>+H:Invoke Handler 165 | 166 | note over H:System Under Test 167 | 168 | H-->>-F:Response 169 | 170 | alt Invocation is handled successfully 171 | 172 | F->>S:POST /{LambdaVersion}/runtime/invocation/{AwsRequestId}/response 173 | 174 | else Invocation throws an exception 175 | 176 | F->>S:POST /{LambdaVersion}/runtime/invocation/{AwsRequestId}/error 177 | 178 | end 179 | 180 | note over S:Associate response with
LambdaTestContext 181 | 182 | S-)T:Signal request handled
on LambdaTestContext 183 | 184 | S-->>F:HTTP 204 185 | 186 | T->>F:Stop Lambda function 187 | 188 | note over F:Terminate client
listen loop 189 | 190 | deactivate F 191 | 192 | end 193 | 194 | T->>S:Stop server 195 | 196 | S->>S:Stop HTTP server 197 | 198 | S-->> T: 199 | 200 | deactivate S 201 | 202 | note over T:Assert 203 | ``` 204 | 205 | 208 | 209 | ### Examples 210 | 211 | You can find examples of how to factor your Lambda function and how to test it: 212 | 213 | 1. In the [samples](https://github.com/martincostello/lambda-test-server/tree/main/samples "Sample functions and tests"); 214 | 1. In the [unit tests](https://github.com/martincostello/lambda-test-server/blob/main/tests/AwsLambdaTestServer.Tests/Examples.cs "Unit test examples") for this project; 215 | 1. How I use the library in the tests for my own [Alexa skill](https://github.com/martincostello/alexa-london-travel/blob/e363ff77a1368e9da694c37fff33a1102ea6accf/test/LondonTravel.Skill.Tests/EndToEndTests.cs#L22 "Alexa London Travel's end-to-end tests"). 216 | 217 | ### Advanced Usage 218 | 219 | #### AWS Mobile SDK with Cognito 220 | 221 | If you use either the `ClientContext` or `Identity` properties on `ILambdaContext` in your function, you can specify the serialized JSON for either property as a `string` when enqueueing a request to the test server to be made available to the function invocation. 222 | 223 | An example of providing these values from an xunit test is shown below: 224 | 225 | ```csharp 226 | using System; 227 | using System.Text.Json; 228 | using System.Threading; 229 | using System.Threading.Tasks; 230 | using MartinCostello.Testing.AwsLambdaTestServer; 231 | using Xunit; 232 | 233 | namespace MyFunctions; 234 | 235 | public static class ReverseFunctionWithMobileSdkTests 236 | { 237 | [Fact] 238 | public static async Task Function_Reverses_Numbers_With_Mobile_Sdk() 239 | { 240 | // Arrange 241 | using var server = new LambdaTestServer(); 242 | using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(1)); 243 | 244 | await server.StartAsync(cancellationTokenSource.Token); 245 | 246 | int[] value = [1, 2, 3]; 247 | string json = JsonSerializer.Serialize(value); 248 | byte[] content = Encoding.UTF8.GetBytes(json); 249 | 250 | var request = new LambdaTestRequest(content) 251 | { 252 | ClientContext = """{ "client": { "app_title": "my-app" } }""", 253 | CognitoIdentity = """{ "identityId": "my-identity" }""", 254 | }; 255 | 256 | LambdaTestContext context = await server.EnqueueAsync(json); 257 | 258 | using var httpClient = server.CreateClient(); 259 | 260 | // Act 261 | await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token); 262 | 263 | // Assert 264 | Assert.True(context.Response.TryRead(out LambdaTestResponse response)); 265 | Assert.True(response.IsSuccessful); 266 | 267 | json = await response.ReadAsStringAsync(); 268 | int[] actual = JsonSerializer.Deserialize(json); 269 | 270 | Assert.Equal([3, 2, 1], actual); 271 | } 272 | } 273 | ``` 274 | 275 | #### Lambda Runtime Options 276 | 277 | If your function makes use of the various other properties in the `ILambdaContext` passed to the function, you can pass an instance of `LambdaTestServerOptions` to the constructor of `LambdaTestServer` to change the values the server provides to `LambdaBootstrap` before it invokes your function. 278 | 279 | Options you can specify include the function memory size, timeout and ARN. 280 | 281 | > The test server does not enforce these values at runtime, unlike the production AWS Lambda environment. They are provided for you to drive the usage of such properties in the code you are testing and should not be relied on to ensure that your function does not take too long to execute or uses too much memory during execution or any other constraints, as appropriate. 282 | 283 | An example of this customisation for an xunit test is shown below: 284 | 285 | ```csharp 286 | using System; 287 | using System.Text.Json; 288 | using System.Threading; 289 | using System.Threading.Tasks; 290 | using MartinCostello.Testing.AwsLambdaTestServer; 291 | using Xunit; 292 | 293 | namespace MyFunctions; 294 | 295 | public static class ReverseFunctionWithCustomOptionsTests 296 | { 297 | [Fact] 298 | public static async Task Function_Reverses_Numbers_With_Custom_Options() 299 | { 300 | // Arrange 301 | var options = new LambdaTestServerOptions() 302 | { 303 | FunctionMemorySize = 256, 304 | FunctionTimeout = TimeSpan.FromSeconds(30), 305 | FunctionVersion = 42, 306 | }; 307 | 308 | using var server = new LambdaTestServer(options); 309 | using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(1)); 310 | 311 | await server.StartAsync(cancellationTokenSource.Token); 312 | 313 | int[] value = [1, 2, 3]; 314 | string json = JsonSerializer.Serialize(value); 315 | 316 | LambdaTestContext context = await server.EnqueueAsync(json); 317 | 318 | using var httpClient = server.CreateClient(); 319 | 320 | // Act 321 | await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token); 322 | 323 | // Assert 324 | Assert.True(context.Response.TryRead(out LambdaTestResponse response)); 325 | Assert.True(response.IsSuccessful); 326 | 327 | json = await response.ReadAsStringAsync(); 328 | int[] actual = JsonSerializer.Deserialize(json); 329 | 330 | Assert.Equal([3, 2, 1], actual); 331 | } 332 | } 333 | ``` 334 | 335 | #### Logging from the Test Server 336 | 337 | To help diagnose failing tests, the `LambdaTestServer` outputs logs of the requests it receives to the emulated AWS Lambda Runtime it provides. To route the logging output to a location of your choosing, you can use the configuration callbacks, such as the constructor overload that accepts an `Action` or the `Configure` property on the `LambdaTestServerOptions` class. 338 | 339 | Here's an example of configuring the test server to route its logs to xunit using the [xunit-logging](https://www.nuget.org/packages/MartinCostello.Logging.XUnit) library: 340 | 341 | ```csharp 342 | using System; 343 | using System.Text.Json; 344 | using System.Threading; 345 | using System.Threading.Tasks; 346 | using MartinCostello.Logging.XUnit; 347 | using MartinCostello.Testing.AwsLambdaTestServer; 348 | using Microsoft.Extensions.DependencyInjection; 349 | using Microsoft.Extensions.Logging; 350 | using Xunit; 351 | using Xunit.Abstractions; 352 | 353 | namespace MartinCostello.Testing.AwsLambdaTestServer; 354 | 355 | public class ReverseFunctionWithLoggingTests : ITestOutputHelperAccessor 356 | { 357 | public ReverseFunctionWithLoggingTests(ITestOutputHelper outputHelper) 358 | { 359 | OutputHelper = outputHelper; 360 | } 361 | 362 | public ITestOutputHelper OutputHelper { get; set; } 363 | 364 | [Fact] 365 | public async Task Function_Reverses_Numbers_With_Logging() 366 | { 367 | // Arrange 368 | using var server = new LambdaTestServer( 369 | (services) => services.AddLogging( 370 | (builder) => builder.AddXUnit(this))); 371 | 372 | using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(1)); 373 | 374 | await server.StartAsync(cancellationTokenSource.Token); 375 | 376 | int[] value = [1, 2, 3]; 377 | string json = JsonSerializer.Serialize(value); 378 | 379 | LambdaTestContext context = await server.EnqueueAsync(json); 380 | 381 | using var httpClient = server.CreateClient(); 382 | 383 | // Act 384 | await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token); 385 | 386 | // Assert 387 | Assert.True(context.Response.TryRead(out LambdaTestResponse response)); 388 | Assert.True(response.IsSuccessful); 389 | 390 | json = await response.ReadAsStringAsync(); 391 | int[] actual = JsonSerializer.Deserialize(json); 392 | 393 | Assert.Equal([3, 2, 1], actual); 394 | } 395 | } 396 | ``` 397 | 398 | This then outputs logs similar to the below into the xunit test results: 399 | 400 | ```sh 401 | Test Name: Function_Reverses_Numbers_With_Logging 402 | Test Outcome: Passed 403 | Result StandardOutput: 404 | [2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Hosting.Diagnostics[1] 405 | Request starting HTTP/1.1 GET http://localhost/2018-06-01/runtime/invocation/next 406 | [2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0] 407 | Executing endpoint '/{LambdaVersion}/runtime/invocation/next HTTP: GET' 408 | [2019-11-04 15:21:06Z] info: MartinCostello.Testing.AwsLambdaTestServer.RuntimeHandler[0] 409 | Waiting for new request for Lambda function with ARN arn:aws:lambda:eu-west-1:123456789012:function:test-function. 410 | [2019-11-04 15:21:06Z] info: MartinCostello.Testing.AwsLambdaTestServer.RuntimeHandler[0] 411 | Invoking Lambda function with ARN arn:aws:lambda:eu-west-1:123456789012:function:test-function for request Id 7e1a283d-6268-4401-921c-0d0d67da1da4 and trace Id 51792f7f-2c1e-4934-bfd9-f5f7c6f0d628. 412 | [2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1] 413 | Executed endpoint '/{LambdaVersion}/runtime/invocation/next HTTP: GET' 414 | [2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Hosting.Diagnostics[2] 415 | Request finished in 71.9334ms 200 application/json 416 | [2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Hosting.Diagnostics[1] 417 | Request starting HTTP/1.1 POST http://localhost/2018-06-01/runtime/invocation/7e1a283d-6268-4401-921c-0d0d67da1da4/response application/json 418 | [2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0] 419 | Executing endpoint '/{LambdaVersion}/runtime/invocation/{AwsRequestId}/response HTTP: POST' 420 | [2019-11-04 15:21:06Z] info: MartinCostello.Testing.AwsLambdaTestServer.RuntimeHandler[0] 421 | Invoked Lambda function with ARN arn:aws:lambda:eu-west-1:123456789012:function:test-function for request Id 7e1a283d-6268-4401-921c-0d0d67da1da4: [3,2,1]. 422 | [2019-11-04 15:21:06Z] info: MartinCostello.Testing.AwsLambdaTestServer.RuntimeHandler[0] 423 | Completed processing AWS request Id 7e1a283d-6268-4401-921c-0d0d67da1da4 for Lambda function with ARN arn:aws:lambda:eu-west-1:123456789012:function:test-function in 107 milliseconds. 424 | [2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1] 425 | Executed endpoint '/{LambdaVersion}/runtime/invocation/{AwsRequestId}/response HTTP: POST' 426 | [2019-11-04 15:21:06Z] info: Microsoft.AspNetCore.Hosting.Diagnostics[2] 427 | Request finished in 26.6306ms 204 428 | ``` 429 | 430 | #### Custom Lambda Server 431 | 432 | It is also possible to use `LambdaTestServer` with a custom [`IServer`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.hosting.server.iserver "IServer Interface on docs.microsoft.com") implementation by overriding the [`CreateServer()`](https://github.com/martincostello/lambda-test-server/blob/cd5e038660d6e607d06833c03a4a0e8740d643a2/src/AwsLambdaTestServer/LambdaTestServer.cs#L209-L217 "LambdaTestServer.CreateServer() method") method in a derived class. 433 | 434 | This can be used, for example, to host the Lambda test server in a real HTTP server that can be accessed remotely instead of being hosted in-memory with the [`TestServer`](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.testhost.testserver "TestServer Class on docs.microsoft.com") class. 435 | 436 | For examples of this use case, see the `MinimalApi` example project and its test project in the [samples](https://github.com/martincostello/lambda-test-server/tree/main/samples "Sample functions and tests"). 437 | 438 | ## Feedback 439 | 440 | Any feedback or issues can be added to the issues for this project in [GitHub](https://github.com/martincostello/lambda-test-server/issues "Issues for this project on GitHub.com"). 441 | 442 | ## Repository 443 | 444 | The repository is hosted in [GitHub](https://github.com/martincostello/lambda-test-server "This project on GitHub.com"): 445 | 446 | ## License 447 | 448 | This project is licensed under the [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt "The Apache 2.0 license") license. 449 | 450 | ## Building and Testing 451 | 452 | Compiling the library yourself requires Git and the [.NET SDK](https://dotnet.microsoft.com/download "Download the .NET SDK") to be installed. 453 | 454 | To build and test the library locally from a terminal/command-line, run one of the following set of commands: 455 | 456 | ```powershell 457 | git clone https://github.com/martincostello/lambda-test-server.git 458 | cd lambda-test-server 459 | ./build.ps1 460 | ``` 461 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | To privately report a security vulnerability, please create a security advisory in the [repository's Security tab](https://github.com/martincostello/lambda-test-server/security/advisories). 6 | 7 | Further details can be found in the [GitHub documentation](https://docs.github.com/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability). 8 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env pwsh 2 | param( 3 | [Parameter(Mandatory = $false)][switch] $SkipTests 4 | ) 5 | 6 | $ErrorActionPreference = "Stop" 7 | $InformationPreference = "Continue" 8 | $ProgressPreference = "SilentlyContinue" 9 | 10 | $solutionPath = $PSScriptRoot 11 | $sdkFile = Join-Path $solutionPath "global.json" 12 | 13 | $libraryProject = Join-Path $solutionPath "src" "AwsLambdaTestServer" "MartinCostello.Testing.AwsLambdaTestServer.csproj" 14 | 15 | $testProjects = @( 16 | (Join-Path $solutionPath "tests" "AwsLambdaTestServer.Tests" "MartinCostello.Testing.AwsLambdaTestServer.Tests.csproj"), 17 | (Join-Path $solutionPath "samples" "MathsFunctions.Tests" "MathsFunctions.Tests.csproj"), 18 | (Join-Path $solutionPath "samples" "MinimalApi.Tests" "MinimalApi.Tests.csproj") 19 | ) 20 | 21 | $dotnetVersion = (Get-Content $sdkFile | Out-String | ConvertFrom-Json).sdk.version 22 | 23 | $installDotNetSdk = $false; 24 | 25 | if (($null -eq (Get-Command "dotnet" -ErrorAction SilentlyContinue)) -and ($null -eq (Get-Command "dotnet.exe" -ErrorAction SilentlyContinue))) { 26 | Write-Information "The .NET SDK is not installed." 27 | $installDotNetSdk = $true 28 | } 29 | else { 30 | Try { 31 | $installedDotNetVersion = (dotnet --version 2>&1 | Out-String).Trim() 32 | } 33 | Catch { 34 | $installedDotNetVersion = "?" 35 | } 36 | 37 | if ($installedDotNetVersion -ne $dotnetVersion) { 38 | Write-Information "The required version of the .NET SDK is not installed. Expected $dotnetVersion." 39 | $installDotNetSdk = $true 40 | } 41 | } 42 | 43 | if ($installDotNetSdk) { 44 | 45 | ${env:DOTNET_INSTALL_DIR} = Join-Path $solutionPath ".dotnet" 46 | $sdkPath = Join-Path ${env:DOTNET_INSTALL_DIR} "sdk" $dotnetVersion 47 | 48 | if (!(Test-Path $sdkPath)) { 49 | if (!(Test-Path ${env:DOTNET_INSTALL_DIR})) { 50 | mkdir ${env:DOTNET_INSTALL_DIR} | Out-Null 51 | } 52 | [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor "Tls12" 53 | if (($PSVersionTable.PSVersion.Major -ge 6) -And !$IsWindows) { 54 | $installScript = Join-Path ${env:DOTNET_INSTALL_DIR} "install.sh" 55 | Invoke-WebRequest "https://dot.net/v1/dotnet-install.sh" -OutFile $installScript -UseBasicParsing 56 | chmod +x $installScript 57 | & $installScript --jsonfile $sdkFile --install-dir ${env:DOTNET_INSTALL_DIR} --no-path --skip-non-versioned-files 58 | } 59 | else { 60 | $installScript = Join-Path ${env:DOTNET_INSTALL_DIR} "install.ps1" 61 | Invoke-WebRequest "https://dot.net/v1/dotnet-install.ps1" -OutFile $installScript -UseBasicParsing 62 | & $installScript -JsonFile $sdkFile -InstallDir ${env:DOTNET_INSTALL_DIR} -NoPath -SkipNonVersionedFiles 63 | } 64 | } 65 | } 66 | else { 67 | ${env:DOTNET_INSTALL_DIR} = Split-Path -Path (Get-Command dotnet).Path 68 | } 69 | 70 | $dotnet = Join-Path ${env:DOTNET_INSTALL_DIR} "dotnet" 71 | 72 | if ($installDotNetSdk) { 73 | ${env:PATH} = "${env:DOTNET_INSTALL_DIR};${env:PATH}" 74 | } 75 | 76 | function DotNetPack { 77 | param([string]$Project) 78 | 79 | & $dotnet pack $Project --include-symbols --include-source 80 | 81 | if ($LASTEXITCODE -ne 0) { 82 | throw "dotnet pack failed with exit code $LASTEXITCODE" 83 | } 84 | } 85 | 86 | function DotNetTest { 87 | param([string]$Project) 88 | 89 | $additionalArgs = @() 90 | 91 | if (-Not [string]::IsNullOrEmpty($env:GITHUB_SHA)) { 92 | $additionalArgs += "--logger:GitHubActions;report-warnings=false" 93 | $additionalArgs += "--logger:junit;LogFilePath=junit.xml" 94 | } 95 | 96 | & $dotnet test $Project --configuration "Release" $additionalArgs -- RunConfiguration.TestSessionTimeout=300000 97 | 98 | if ($LASTEXITCODE -ne 0) { 99 | throw "dotnet test failed with exit code $LASTEXITCODE" 100 | } 101 | } 102 | 103 | Write-Information "Packaging library..." 104 | DotNetPack $libraryProject 105 | 106 | if (-Not $SkipTests) { 107 | Write-Information "Running tests..." 108 | ForEach ($testProject in $testProjects) { 109 | DotNetTest $testProject 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.300", 4 | "allowPrerelease": false, 5 | "paths": [ ".dotnet", "$host$" ], 6 | "errorMessage": "The required version of the .NET SDK could not be found. Please run ./build.ps1 to bootstrap the .NET SDK." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/lambda-test-server/f886c3c2582a893cbba5c363167b594f74ad30cd/package-icon.png -------------------------------------------------------------------------------- /package-readme.md: -------------------------------------------------------------------------------- 1 | # AWS Lambda Test Server for .NET 2 | 3 | [![NuGet](https://img.shields.io/nuget/v/MartinCostello.Testing.AwsLambdaTestServer?logo=nuget&label=Latest&color=blue)](https://www.nuget.org/packages/MartinCostello.Testing.AwsLambdaTestServer "Download MartinCostello.Testing.AwsLambdaTestServer from NuGet") 4 | [![NuGet Downloads](https://img.shields.io/nuget/dt/MartinCostello.Testing.AwsLambdaTestServer?logo=nuget&label=Downloads&color=blue)](https://www.nuget.org/packages/MartinCostello.Testing.AwsLambdaTestServer "Download MartinCostello.Testing.AwsLambdaTestServer from NuGet") 5 | 6 | [![Build status](https://github.com/martincostello/lambda-test-server/actions/workflows/build.yml/badge.svg?branch=main&event=push)](https://github.com/martincostello/lambda-test-server/actions/workflows/build.yml?query=branch%3Amain+event%3Apush) 7 | [![codecov](https://codecov.io/gh/martincostello/lambda-test-server/branch/main/graph/badge.svg)](https://codecov.io/gh/martincostello/lambda-test-server) 8 | 9 | ## Introduction 10 | 11 | A NuGet package that builds on top of the `TestServer` class in the [Microsoft.AspNetCore.TestHost](https://www.nuget.org/packages/Microsoft.AspNetCore.TestHost) NuGet package to provide infrastructure to use with end-to-end/integration tests of .NET 6 AWS Lambda Functions using a custom runtime with the `LambdaBootstrap` class from the [Amazon.Lambda.RuntimeSupport](https://www.nuget.org/packages/Amazon.Lambda.RuntimeSupport/) NuGet package. 12 | 13 | [_.NET Core 3.0 on Lambda with AWS Lambda's Custom Runtime_](https://aws.amazon.com/blogs/developer/net-core-3-0-on-lambda-with-aws-lambdas-custom-runtime/ ".NET Core 3.0 on Lambda with AWS Lambda's Custom Runtime on the AWS Developer Blog") 14 | 15 | ## Feedback 16 | 17 | Any feedback or issues for this package can be added to the issues in [GitHub](https://github.com/martincostello/lambda-test-server/issues "Issues for this package on GitHub.com"). 18 | 19 | ## License 20 | 21 | This package is licensed under the [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt "The Apache 2.0 license") license. 22 | -------------------------------------------------------------------------------- /samples/MathsFunctions.Tests/MathsFunctionTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | using MartinCostello.Testing.AwsLambdaTestServer; 6 | 7 | namespace MathsFunctions; 8 | 9 | public static class MathsFunctionTests 10 | { 11 | [Theory] 12 | [InlineData(1, "+", 1, 2)] 13 | [InlineData(9, "-", 6, 3)] 14 | [InlineData(2, "*", 2, 4)] 15 | [InlineData(9, "/", 3, 3)] 16 | [InlineData(7, "%", 2, 1)] 17 | [InlineData(3, "^", 3, 27)] 18 | public static async Task Function_Computes_Results(double left, string op, double right, double expected) 19 | { 20 | // Arrange 21 | using var server = new LambdaTestServer(); 22 | using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); 23 | 24 | await server.StartAsync(cancellationTokenSource.Token); 25 | 26 | var value = new MathsRequest() { Left = left, Operator = op, Right = right }; 27 | string json = JsonSerializer.Serialize(value); 28 | 29 | var context = await server.EnqueueAsync(json); 30 | 31 | using var httpClient = server.CreateClient(); 32 | 33 | // Act 34 | await MathsFunction.RunAsync(httpClient, cancellationTokenSource.Token); 35 | 36 | // Assert 37 | Assert.True(context.Response.TryRead(out var response)); 38 | Assert.NotNull(response); 39 | Assert.True(response!.IsSuccessful); 40 | 41 | json = await response.ReadAsStringAsync(); 42 | var actual = JsonSerializer.Deserialize(json); 43 | 44 | Assert.NotNull(actual); 45 | Assert.Equal(expected, actual!.Result); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /samples/MathsFunctions.Tests/MathsFunctions.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | MathsFunctions 5 | net9.0 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /samples/MathsFunctions/MathsFunction.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Amazon.Lambda.RuntimeSupport; 5 | using Amazon.Lambda.Serialization.Json; 6 | 7 | namespace MathsFunctions; 8 | 9 | public static class MathsFunction 10 | { 11 | public static async Task Main() 12 | => await RunAsync(); 13 | 14 | public static async Task RunAsync(HttpClient? httpClient = null, CancellationToken cancellationToken = default) 15 | { 16 | var serializer = new JsonSerializer(); 17 | 18 | using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(Evaluate, serializer); 19 | using var bootstrap = new LambdaBootstrap(httpClient ?? new HttpClient(), handlerWrapper); 20 | 21 | await bootstrap.RunAsync(cancellationToken); 22 | } 23 | 24 | private static MathsResponse Evaluate(MathsRequest request) 25 | { 26 | double result = request.Operator switch 27 | { 28 | "+" => request.Left + request.Right, 29 | "-" => request.Left - request.Right, 30 | "*" => request.Left * request.Right, 31 | "/" => request.Left / request.Right, 32 | "%" => request.Left % request.Right, 33 | "^" => Math.Pow(request.Left, request.Right), 34 | _ => throw new NotSupportedException($"The '{request.Operator}' operator is not supported."), 35 | }; 36 | 37 | return new() { Result = result }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /samples/MathsFunctions/MathsFunctions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | $(NoWarn);CA2000;CA2007;SA1600 5 | Library 6 | MathsFunctions 7 | net8.0;net9.0 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /samples/MathsFunctions/MathsRequest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MathsFunctions; 5 | 6 | public class MathsRequest 7 | { 8 | public double Left { get; set; } 9 | 10 | public double Right { get; set; } 11 | 12 | public string? Operator { get; set; } 13 | } 14 | -------------------------------------------------------------------------------- /samples/MathsFunctions/MathsResponse.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MathsFunctions; 5 | 6 | public class MathsResponse 7 | { 8 | public double Result { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /samples/MinimalApi.Tests/ApiTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net.Sockets; 5 | using System.Reflection; 6 | using System.Text.Json; 7 | using Amazon.Lambda.APIGatewayEvents; 8 | using MartinCostello.Testing.AwsLambdaTestServer; 9 | using Microsoft.AspNetCore.Http; 10 | 11 | namespace MinimalApi; 12 | 13 | public sealed class ApiTests : IAsyncLifetime, IDisposable 14 | { 15 | private readonly HttpLambdaTestServer _server; 16 | 17 | public ApiTests(ITestOutputHelper outputHelper) 18 | { 19 | _server = new() { OutputHelper = outputHelper }; 20 | } 21 | 22 | public void Dispose() 23 | => _server.Dispose(); 24 | 25 | public async ValueTask DisposeAsync() 26 | => await _server.DisposeAsync(); 27 | 28 | public async ValueTask InitializeAsync() 29 | => await _server.InitializeAsync(); 30 | 31 | [Fact(Timeout = 5_000)] 32 | public async Task Can_Hash_String() 33 | { 34 | // Arrange 35 | var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); 36 | 37 | var body = new 38 | { 39 | algorithm = "sha256", 40 | format = "base64", 41 | plaintext = "ASP.NET Core", 42 | }; 43 | 44 | var request = new APIGatewayProxyRequest() 45 | { 46 | Body = JsonSerializer.Serialize(body), 47 | Headers = new Dictionary() 48 | { 49 | ["content-type"] = "application/json", 50 | }, 51 | HttpMethod = HttpMethods.Post, 52 | Path = "/hash", 53 | }; 54 | 55 | // Arrange 56 | string json = JsonSerializer.Serialize(request, options); 57 | 58 | LambdaTestContext context = await _server.EnqueueAsync(json); 59 | 60 | using var cts = GetCancellationTokenSourceForResponseAvailable(context); 61 | 62 | // Act 63 | _ = Task.Run( 64 | () => 65 | { 66 | try 67 | { 68 | typeof(HashRequest).Assembly.EntryPoint!.Invoke(null, [Array.Empty()]); 69 | } 70 | catch (Exception ex) when (LambdaServerWasShutDown(ex)) 71 | { 72 | // The Lambda runtime server was shut down 73 | } 74 | }, 75 | cts.Token); 76 | 77 | // Assert 78 | await context.Response.WaitToReadAsync(cts.IsCancellationRequested ? default : cts.Token); 79 | 80 | context.Response.TryRead(out LambdaTestResponse? response).ShouldBeTrue(); 81 | response.IsSuccessful.ShouldBeTrue($"Failed to process request: {await response.ReadAsStringAsync()}"); 82 | response.Duration.ShouldBeInRange(TimeSpan.Zero, TimeSpan.FromSeconds(2)); 83 | response.Content.ShouldNotBeEmpty(); 84 | 85 | // Assert 86 | var actual = JsonSerializer.Deserialize(response.Content, options); 87 | 88 | actual.ShouldNotBeNull(); 89 | 90 | actual.ShouldNotBeNull(); 91 | actual.StatusCode.ShouldBe(StatusCodes.Status200OK); 92 | actual.MultiValueHeaders.ShouldContainKey("Content-Type"); 93 | actual.MultiValueHeaders["Content-Type"].ShouldBe(["application/json; charset=utf-8"]); 94 | 95 | var hash = JsonSerializer.Deserialize(actual.Body, options); 96 | 97 | hash.ShouldNotBeNull(); 98 | hash.Hash.ShouldBe("XXE/IcKhlw/yjLTH7cCWPSr7JfOw5LuYXeBuE5skNfA="); 99 | } 100 | 101 | private static CancellationTokenSource GetCancellationTokenSourceForResponseAvailable( 102 | LambdaTestContext context, 103 | TimeSpan? timeout = null) 104 | { 105 | if (timeout == null) 106 | { 107 | timeout = System.Diagnostics.Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(3); 108 | } 109 | 110 | var cts = new CancellationTokenSource(timeout.Value); 111 | 112 | // Queue a task to stop the test server from listening as soon as the response is available 113 | _ = Task.Run( 114 | async () => 115 | { 116 | await context.Response.WaitToReadAsync(cts.Token); 117 | 118 | if (!cts.IsCancellationRequested) 119 | { 120 | await cts.CancelAsync(); 121 | } 122 | }, 123 | cts.Token); 124 | 125 | return cts; 126 | } 127 | 128 | private static bool LambdaServerWasShutDown(Exception exception) 129 | { 130 | if (exception is not TargetInvocationException targetException || 131 | targetException.InnerException is not HttpRequestException httpException || 132 | httpException.InnerException is not SocketException socketException) 133 | { 134 | return false; 135 | } 136 | 137 | return socketException.SocketErrorCode == SocketError.ConnectionRefused; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /samples/MinimalApi.Tests/HttpLambdaTestServer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Logging.XUnit; 5 | using MartinCostello.Testing.AwsLambdaTestServer; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.Hosting.Server; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace MinimalApi; 12 | 13 | internal sealed class HttpLambdaTestServer : LambdaTestServer, IAsyncLifetime, ITestOutputHelperAccessor 14 | { 15 | private readonly CancellationTokenSource _cts = new(); 16 | private bool _disposed; 17 | private IWebHost? _webHost; 18 | 19 | public ITestOutputHelper? OutputHelper { get; set; } 20 | 21 | public async ValueTask DisposeAsync() 22 | { 23 | if (_webHost is not null) 24 | { 25 | await _webHost.StopAsync(); 26 | } 27 | 28 | Dispose(); 29 | } 30 | 31 | public async ValueTask InitializeAsync() 32 | => await StartAsync(_cts.Token); 33 | 34 | protected override IServer CreateServer(WebHostBuilder builder) 35 | { 36 | _webHost = builder 37 | .UseKestrel() 38 | .ConfigureServices((services) => services.AddLogging((builder) => builder.AddXUnit(this))) 39 | .UseUrls("http://127.0.0.1:0") 40 | .Build(); 41 | 42 | _webHost.Start(); 43 | 44 | return _webHost.Services.GetRequiredService(); 45 | } 46 | 47 | protected override void Dispose(bool disposing) 48 | { 49 | if (!_disposed) 50 | { 51 | if (disposing) 52 | { 53 | _webHost?.Dispose(); 54 | 55 | _cts.Cancel(); 56 | _cts.Dispose(); 57 | } 58 | 59 | _disposed = true; 60 | } 61 | 62 | base.Dispose(disposing); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /samples/MinimalApi.Tests/MinimalApi.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | MinimalApi 5 | net9.0 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /samples/MinimalApi/HashRequest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MinimalApi; 5 | 6 | public class HashRequest 7 | { 8 | public string Algorithm { get; set; } = string.Empty; 9 | 10 | public string Format { get; set; } = string.Empty; 11 | 12 | public string Plaintext { get; set; } = string.Empty; 13 | } 14 | -------------------------------------------------------------------------------- /samples/MinimalApi/HashResponse.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MinimalApi; 5 | 6 | public class HashResponse 7 | { 8 | public string Hash { get; set; } = string.Empty; 9 | } 10 | -------------------------------------------------------------------------------- /samples/MinimalApi/MinimalApi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | $(NoWarn);CA1050;CA1812;CA2007;CA5350;CA5351;SA1600 4 | MinimalApi 5 | net9.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /samples/MinimalApi/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Security.Cryptography; 5 | using System.Text; 6 | using MinimalApi; 7 | 8 | var builder = WebApplication.CreateBuilder(args); 9 | 10 | builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi); 11 | 12 | var app = builder.Build(); 13 | 14 | app.MapGet("/", () => "Hello World!"); 15 | 16 | app.MapPost("/hash", async (HttpRequest httpRequest) => 17 | { 18 | var request = await httpRequest.ReadFromJsonAsync(); 19 | 20 | if (string.IsNullOrWhiteSpace(request?.Algorithm)) 21 | { 22 | return Results.Problem( 23 | "No hash algorithm name specified.", 24 | statusCode: StatusCodes.Status400BadRequest); 25 | } 26 | 27 | if (string.IsNullOrWhiteSpace(request.Format)) 28 | { 29 | return Results.Problem( 30 | "No hash output format specified.", 31 | statusCode: StatusCodes.Status400BadRequest); 32 | } 33 | 34 | bool? formatAsBase64 = request.Format.ToUpperInvariant() switch 35 | { 36 | "BASE64" => true, 37 | "HEXADECIMAL" => false, 38 | _ => null, 39 | }; 40 | 41 | if (formatAsBase64 is null) 42 | { 43 | return Results.Problem( 44 | $"The specified hash format '{request.Format}' is invalid.", 45 | statusCode: StatusCodes.Status400BadRequest); 46 | } 47 | 48 | const int MaxPlaintextLength = 4096; 49 | 50 | if (request.Plaintext?.Length > MaxPlaintextLength) 51 | { 52 | return Results.Problem( 53 | $"The plaintext to hash cannot be more than {MaxPlaintextLength} characters in length.", 54 | statusCode: StatusCodes.Status400BadRequest); 55 | } 56 | 57 | byte[] buffer = Encoding.UTF8.GetBytes(request.Plaintext ?? string.Empty); 58 | HashAlgorithmName? hashAlgorithm = request.Algorithm.ToUpperInvariant() switch 59 | { 60 | "MD5" => HashAlgorithmName.MD5, 61 | "SHA1" => HashAlgorithmName.SHA1, 62 | "SHA256" => HashAlgorithmName.SHA256, 63 | "SHA384" => HashAlgorithmName.SHA384, 64 | "SHA512" => HashAlgorithmName.SHA512, 65 | _ => null, 66 | }; 67 | 68 | if (hashAlgorithm is not { } algorithm) 69 | { 70 | return Results.Problem( 71 | $"The specified hash algorithm '{request.Algorithm}' is not supported.", 72 | statusCode: StatusCodes.Status400BadRequest); 73 | } 74 | 75 | byte[] hash = CryptographicOperations.HashData(algorithm, buffer); 76 | 77 | var result = new HashResponse() 78 | { 79 | Hash = formatAsBase64 == true ? Convert.ToBase64String(hash) : Convert.ToHexString(hash), 80 | }; 81 | 82 | return Results.Json(result); 83 | }); 84 | 85 | app.Run(); 86 | -------------------------------------------------------------------------------- /samples/MinimalApi/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "MinimalApi": { 4 | "commandName": "Project", 5 | "dotnetRunMessages": true, 6 | "launchBrowser": true, 7 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 8 | "environmentVariables": { 9 | "ASPNETCORE_ENVIRONMENT": "Development" 10 | } 11 | }, 12 | "MinimalApi.Lambda": { 13 | "commandName": "Project", 14 | "commandLineArgs": "", 15 | "launchBrowser": true, 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development", 18 | "AWS_LAMBDA_FUNCTION_NAME": "MinimalApi", 19 | "AWS_LAMBDA_RUNTIME_API": "localhost:5050", 20 | "AWS_PROFILE": "default", 21 | "AWS_REGION": "eu-west-1" 22 | }, 23 | "applicationUrl": "http://localhost:5050/runtime" 24 | }, 25 | "Lambda Test Tool": { 26 | "commandName": "Executable", 27 | "commandLineArgs": "--port 5050", 28 | "workingDirectory": ".\\bin\\$(Configuration)\\net9.0", 29 | "executablePath": "%USERPROFILE%\\.dotnet\\tools\\dotnet-lambda-test-tool-9.0.exe" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /samples/MinimalApi/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /samples/MinimalApi/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/LambdaTestContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Threading.Channels; 5 | 6 | namespace MartinCostello.Testing.AwsLambdaTestServer; 7 | 8 | /// 9 | /// A class representing the context for an AWS request enqueued to be processed by an AWS Lambda function. This class cannot be inherited. 10 | /// 11 | public sealed class LambdaTestContext 12 | { 13 | /// 14 | /// Initializes a new instance of the class. 15 | /// 16 | /// The request to invoke the Lambda function with. 17 | /// The channel reader associated with the response. 18 | internal LambdaTestContext(LambdaTestRequest request, ChannelReader reader) 19 | { 20 | Request = request; 21 | Response = reader; 22 | } 23 | 24 | /// 25 | /// Gets the request to invoke the Lambda function with. 26 | /// 27 | public LambdaTestRequest Request { get; } 28 | 29 | /// 30 | /// Gets the channel reader which completes once the request is processed by the function. 31 | /// 32 | public ChannelReader Response { get; } 33 | } 34 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/LambdaTestRequest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Testing.AwsLambdaTestServer; 5 | 6 | #pragma warning disable CA1819 7 | 8 | /// 9 | /// A class representing a test request to an AWS Lambda function. 10 | /// 11 | public class LambdaTestRequest 12 | { 13 | /// 14 | /// Initializes a new instance of the class. 15 | /// 16 | /// The raw content of the request to invoke the Lambda function with. 17 | /// The optional AWS request Id to invoke the Lambda function with. 18 | /// 19 | /// is . 20 | /// 21 | public LambdaTestRequest(byte[] content, string? awsRequestId = null) 22 | { 23 | ArgumentNullException.ThrowIfNull(content); 24 | Content = content; 25 | AwsRequestId = awsRequestId ?? Guid.NewGuid().ToString(); 26 | } 27 | 28 | /// 29 | /// Gets the AWS request Id for the request to the function. 30 | /// 31 | public string AwsRequestId { get; } 32 | 33 | /// 34 | /// Gets the raw byte content of the request to the function. 35 | /// 36 | public byte[] Content { get; } 37 | 38 | /// 39 | /// Gets or sets an optional string containing the serialized JSON 40 | /// for the client context when invoked through the AWS Mobile SDK. 41 | /// 42 | public string? ClientContext { get; set; } 43 | 44 | /// 45 | /// Gets or sets an optional string containing the serialized JSON for the 46 | /// Amazon Cognito identity provider when invoked through the AWS Mobile SDK. 47 | /// 48 | public string? CognitoIdentity { get; set; } 49 | } 50 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/LambdaTestResponse.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Testing.AwsLambdaTestServer; 5 | 6 | #pragma warning disable CA1819 7 | 8 | /// 9 | /// A class representing a test response from an AWS Lambda function. This class cannot be inherited. 10 | /// 11 | public sealed class LambdaTestResponse 12 | { 13 | /// 14 | /// Initializes a new instance of the class. 15 | /// 16 | /// The raw content of the response from the Lambda function. 17 | /// Whether the response indicates the request was successfully handled. 18 | /// The duration of the Lambda function invocation. 19 | internal LambdaTestResponse(byte[] content, bool isSuccessful, TimeSpan duration) 20 | { 21 | Content = content; 22 | Duration = duration; 23 | IsSuccessful = isSuccessful; 24 | } 25 | 26 | /// 27 | /// Gets the raw byte content of the response from the function. 28 | /// 29 | public byte[] Content { get; } 30 | 31 | /// 32 | /// Gets the approximate duration of the Lambda function invocation. 33 | /// 34 | public TimeSpan Duration { get; } 35 | 36 | /// 37 | /// Gets a value indicating whether the response indicates the request was successfully handled. 38 | /// 39 | public bool IsSuccessful { get; } 40 | } 41 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/LambdaTestResponseExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.ComponentModel; 5 | 6 | namespace MartinCostello.Testing.AwsLambdaTestServer; 7 | 8 | /// 9 | /// A class containing extension methods for the class. This class cannot be inherited. 10 | /// 11 | [EditorBrowsable(EditorBrowsableState.Never)] 12 | public static class LambdaTestResponseExtensions 13 | { 14 | /// 15 | /// Reads the content of the specified response as a string as an asynchronous operation. 16 | /// 17 | /// The response to read as a string. 18 | /// 19 | /// A representing the asynchronous operation to 20 | /// read the content of the specified response as a . 21 | /// 22 | /// 23 | /// is . 24 | /// 25 | public static async Task ReadAsStringAsync(this LambdaTestResponse response) 26 | { 27 | ArgumentNullException.ThrowIfNull(response); 28 | 29 | using var stream = new MemoryStream(response.Content); 30 | using var reader = new StreamReader(stream); 31 | 32 | return await reader.ReadToEndAsync().ConfigureAwait(false); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/LambdaTestServer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Threading.Channels; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.Hosting.Server; 8 | using Microsoft.AspNetCore.Hosting.Server.Features; 9 | using Microsoft.AspNetCore.TestHost; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Hosting; 12 | using Microsoft.Extensions.Logging; 13 | 14 | namespace MartinCostello.Testing.AwsLambdaTestServer; 15 | 16 | /// 17 | /// A class representing a test AWS Lambda runtime HTTP server for an AWS Lambda function. 18 | /// 19 | public class LambdaTestServer : IDisposable 20 | { 21 | private readonly CancellationTokenSource _onDisposed; 22 | 23 | private bool _disposed; 24 | private RuntimeHandler? _handler; 25 | private bool _isStarted; 26 | private IServer? _server; 27 | private CancellationTokenSource? _onStopped; 28 | 29 | /// 30 | /// Initializes a new instance of the class. 31 | /// 32 | public LambdaTestServer() 33 | : this(new LambdaTestServerOptions()) 34 | { 35 | } 36 | 37 | /// 38 | /// Initializes a new instance of the class. 39 | /// 40 | /// An optional delegate to invoke when configuring the test Lambda runtime server. 41 | public LambdaTestServer(Action configure) 42 | : this(new LambdaTestServerOptions() { Configure = configure }) 43 | { 44 | } 45 | 46 | /// 47 | /// Initializes a new instance of the class. 48 | /// 49 | /// The options to use to configure the test Lambda runtime server. 50 | /// 51 | /// is . 52 | /// 53 | public LambdaTestServer(LambdaTestServerOptions options) 54 | { 55 | ArgumentNullException.ThrowIfNull(options); 56 | Options = options; 57 | _onDisposed = new CancellationTokenSource(); 58 | } 59 | 60 | /// 61 | /// Finalizes an instance of the class. 62 | /// 63 | ~LambdaTestServer() 64 | { 65 | Dispose(false); 66 | } 67 | 68 | /// 69 | /// Gets a value indicating whether the test Lambda runtime server has been started. 70 | /// 71 | public bool IsStarted => _isStarted; 72 | 73 | /// 74 | /// Gets the options in use by the test Lambda runtime server. 75 | /// 76 | public LambdaTestServerOptions Options { get; } 77 | 78 | /// 79 | /// Clears any AWS Lambda environment variables set by instances of . 80 | /// 81 | public static void ClearLambdaEnvironmentVariables() 82 | { 83 | Environment.SetEnvironmentVariable("AWS_LAMBDA_DOTNET_DISABLE_MEMORY_LIMIT_CHECK", null); 84 | Environment.SetEnvironmentVariable("AWS_LAMBDA_FUNCTION_MEMORY_SIZE", null); 85 | Environment.SetEnvironmentVariable("AWS_LAMBDA_FUNCTION_NAME", null); 86 | Environment.SetEnvironmentVariable("AWS_LAMBDA_FUNCTION_VERSION", null); 87 | Environment.SetEnvironmentVariable("AWS_LAMBDA_LOG_GROUP_NAME", null); 88 | Environment.SetEnvironmentVariable("AWS_LAMBDA_LOG_STREAM_NAME", null); 89 | Environment.SetEnvironmentVariable("AWS_LAMBDA_RUNTIME_API", null); 90 | Environment.SetEnvironmentVariable("_HANDLER", null); 91 | } 92 | 93 | /// 94 | public void Dispose() 95 | { 96 | Dispose(true); 97 | GC.SuppressFinalize(this); 98 | } 99 | 100 | /// 101 | /// Creates an to use to interact with the test Lambda runtime server. 102 | /// 103 | /// 104 | /// An that can be used to process Lambda runtime HTTP requests. 105 | /// 106 | /// 107 | /// The test server has not been started. 108 | /// 109 | /// 110 | /// The instance has been disposed. 111 | /// 112 | public virtual HttpClient CreateClient() 113 | { 114 | ThrowIfDisposed(); 115 | ThrowIfNotStarted(); 116 | 117 | if (_server is TestServer testServer) 118 | { 119 | return testServer.CreateClient(); 120 | } 121 | 122 | var baseAddress = GetServerBaseAddress(); 123 | 124 | return new() { BaseAddress = baseAddress }; 125 | } 126 | 127 | /// 128 | /// Enqueues a request for the Lambda function to process as an asynchronous operation. 129 | /// 130 | /// The request to invoke the function with. 131 | /// 132 | /// A representing the asynchronous operation to 133 | /// enqueue the request which returns a context containing a 134 | /// which completes once the request is processed by the function. 135 | /// 136 | /// 137 | /// is . 138 | /// 139 | /// 140 | /// A request with the Id specified by is currently in-flight or the test server has not been started. 141 | /// 142 | /// 143 | /// The instance has been disposed. 144 | /// 145 | public async Task EnqueueAsync(LambdaTestRequest request) 146 | { 147 | ArgumentNullException.ThrowIfNull(request); 148 | 149 | ThrowIfDisposed(); 150 | ThrowIfNotStarted(); 151 | 152 | var reader = await _handler!.EnqueueAsync(request, _onStopped!.Token).ConfigureAwait(false); 153 | 154 | return new LambdaTestContext(request, reader); 155 | } 156 | 157 | /// 158 | /// Starts the test Lambda runtime server as an asynchronous operation. 159 | /// 160 | /// 161 | /// The optional cancellation token to use to signal the server should stop listening to invocation requests. 162 | /// 163 | /// 164 | /// A representing the asynchronous operation to start the test Lambda runtime server. 165 | /// 166 | /// 167 | /// The test server has already been started. 168 | /// 169 | /// 170 | /// The instance has been disposed. 171 | /// 172 | public virtual Task StartAsync(CancellationToken cancellationToken = default) 173 | { 174 | ThrowIfDisposed(); 175 | 176 | if (_server != null) 177 | { 178 | throw new InvalidOperationException("The test server has already been started."); 179 | } 180 | 181 | _onStopped = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _onDisposed.Token); 182 | _handler = new RuntimeHandler(Options, _onStopped.Token); 183 | 184 | var builder = new WebHostBuilder(); 185 | 186 | ConfigureWebHost(builder); 187 | 188 | _server = CreateServer(builder) ?? throw new InvalidOperationException($"No {nameof(IServer)} was returned by the {nameof(CreateServer)}() method."); 189 | 190 | Uri baseAddress; 191 | 192 | if (_server is TestServer testServer) 193 | { 194 | _handler.Logger = testServer.Services.GetRequiredService>(); 195 | baseAddress = testServer.BaseAddress; 196 | } 197 | else 198 | { 199 | baseAddress = GetServerBaseAddress(); 200 | } 201 | 202 | SetLambdaEnvironmentVariables(baseAddress); 203 | 204 | _isStarted = true; 205 | 206 | return Task.CompletedTask; 207 | } 208 | 209 | /// 210 | /// Creates the server to use for the Lambda runtime. 211 | /// 212 | /// The to use to create the server. 213 | /// 214 | /// The to use. 215 | /// 216 | protected virtual IServer CreateServer(WebHostBuilder builder) 217 | => new TestServer(builder); 218 | 219 | /// 220 | /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. 221 | /// 222 | /// 223 | /// to release both managed and unmanaged resources; 224 | /// to release only unmanaged resources. 225 | /// 226 | protected virtual void Dispose(bool disposing) 227 | { 228 | if (!_disposed) 229 | { 230 | if (disposing) 231 | { 232 | if (_onDisposed != null) 233 | { 234 | // The token for _onStopped is linked to this token, so this will cancel both 235 | if (!_onDisposed.IsCancellationRequested) 236 | { 237 | _onDisposed.Cancel(); 238 | } 239 | 240 | _onDisposed.Dispose(); 241 | _onStopped?.Dispose(); 242 | } 243 | 244 | _server?.Dispose(); 245 | _handler?.Dispose(); 246 | } 247 | 248 | _disposed = true; 249 | } 250 | } 251 | 252 | /// 253 | /// Configures the application for the test Lambda runtime server. 254 | /// 255 | /// The to configure. 256 | protected virtual void Configure(IApplicationBuilder app) 257 | { 258 | app.UseRouting(); 259 | app.UseEndpoints((endpoints) => 260 | { 261 | #pragma warning disable ASP0018 262 | // See https://github.com/aws/aws-lambda-dotnet/blob/4f9142b95b376bd238bce6be43f4e1ec1f983592/Libraries/src/Amazon.Lambda.RuntimeSupport/Client/InternalClientAdapted.cs#L75 263 | endpoints.MapGet("/{LambdaVersion}/runtime/invocation/next", _handler!.HandleNextAsync); 264 | endpoints.MapPost("/{LambdaVersion}/runtime/init/error", _handler.HandleInitializationErrorAsync); 265 | endpoints.MapPost("/{LambdaVersion}/runtime/invocation/{AwsRequestId}/error", _handler.HandleInvocationErrorAsync); 266 | endpoints.MapPost("/{LambdaVersion}/runtime/invocation/{AwsRequestId}/response", _handler.HandleResponseAsync); 267 | #pragma warning restore ASP0018 268 | }); 269 | } 270 | 271 | /// 272 | /// Configures the services for the test Lambda runtime server application. 273 | /// 274 | /// The to use. 275 | protected virtual void ConfigureServices(IServiceCollection services) 276 | { 277 | services.AddRouting(); 278 | 279 | Options.Configure?.Invoke(services); 280 | } 281 | 282 | /// 283 | /// Configures the web host builder for the test Lambda runtime server. 284 | /// 285 | /// The to configure. 286 | /// 287 | /// is . 288 | /// 289 | protected virtual void ConfigureWebHost(IWebHostBuilder builder) 290 | { 291 | ArgumentNullException.ThrowIfNull(builder); 292 | 293 | builder.UseContentRoot(Environment.CurrentDirectory); 294 | builder.UseShutdownTimeout(TimeSpan.Zero); 295 | 296 | builder.ConfigureServices(ConfigureServices); 297 | builder.Configure(Configure); 298 | } 299 | 300 | private void SetLambdaEnvironmentVariables(Uri baseAddress) 301 | { 302 | var provider = CultureInfo.InvariantCulture; 303 | 304 | // See https://github.com/aws/aws-lambda-dotnet/blob/4f9142b95b376bd238bce6be43f4e1ec1f983592/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaEnvironment.cs#L46-L52 305 | Environment.SetEnvironmentVariable("AWS_LAMBDA_FUNCTION_MEMORY_SIZE", Options.FunctionMemorySize.ToString(provider)); 306 | Environment.SetEnvironmentVariable("AWS_LAMBDA_FUNCTION_NAME", Options.FunctionName); 307 | Environment.SetEnvironmentVariable("AWS_LAMBDA_FUNCTION_VERSION", Options.FunctionVersion.ToString(provider)); 308 | Environment.SetEnvironmentVariable("AWS_LAMBDA_LOG_GROUP_NAME", Options.LogGroupName); 309 | Environment.SetEnvironmentVariable("AWS_LAMBDA_LOG_STREAM_NAME", Options.LogStreamName); 310 | Environment.SetEnvironmentVariable("AWS_LAMBDA_RUNTIME_API", $"{baseAddress.Host}:{baseAddress.Port}"); 311 | Environment.SetEnvironmentVariable("_HANDLER", Options.FunctionHandler); 312 | 313 | // See https://github.com/aws/aws-lambda-dotnet/pull/1595 314 | if (Options.DisableMemoryLimitCheck) 315 | { 316 | Environment.SetEnvironmentVariable("AWS_LAMBDA_DOTNET_DISABLE_MEMORY_LIMIT_CHECK", bool.TrueString); 317 | } 318 | } 319 | 320 | private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); 321 | 322 | [System.Diagnostics.CodeAnalysis.MemberNotNull(nameof(_server))] 323 | private void ThrowIfNotStarted() 324 | { 325 | if (_server is null) 326 | { 327 | throw new InvalidOperationException("The test server has not been started."); 328 | } 329 | } 330 | 331 | private Uri GetServerBaseAddress() 332 | { 333 | var serverAddresses = _server!.Features.Get(); 334 | var serverUrl = serverAddresses?.Addresses?.FirstOrDefault(); 335 | 336 | return serverUrl is null 337 | ? throw new InvalidOperationException("No server addresses are available.") 338 | : new Uri(serverUrl, UriKind.Absolute); 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/LambdaTestServerExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.ComponentModel; 5 | using System.Text; 6 | using System.Threading.Channels; 7 | 8 | namespace MartinCostello.Testing.AwsLambdaTestServer; 9 | 10 | /// 11 | /// A class containing extension methods for the class. This class cannot be inherited. 12 | /// 13 | [EditorBrowsable(EditorBrowsableState.Never)] 14 | public static class LambdaTestServerExtensions 15 | { 16 | /// 17 | /// Enqueues a request for the Lambda function to process as an asynchronous operation. 18 | /// 19 | /// The server to enqueue the request with. 20 | /// The request content to process. 21 | /// 22 | /// A representing the asynchronous operation to 23 | /// enqueue the request which returns a context containing a 24 | /// which completes once the request is processed by the function. 25 | /// 26 | /// 27 | /// or is . 28 | /// 29 | /// 30 | /// The instance has been disposed. 31 | /// 32 | public static async Task EnqueueAsync( 33 | this LambdaTestServer server, 34 | string value) 35 | { 36 | ArgumentNullException.ThrowIfNull(server); 37 | ArgumentNullException.ThrowIfNull(value); 38 | 39 | byte[] content = Encoding.UTF8.GetBytes(value); 40 | 41 | return await server.EnqueueAsync(content).ConfigureAwait(false); 42 | } 43 | 44 | /// 45 | /// Enqueues a request for the Lambda function to process as an asynchronous operation. 46 | /// 47 | /// The server to enqueue the request with. 48 | /// The request content to process. 49 | /// 50 | /// A representing the asynchronous operation to 51 | /// enqueue the request which returns a context containing a 52 | /// which completes once the request is processed by the function. 53 | /// 54 | /// 55 | /// or is . 56 | /// 57 | /// 58 | /// The instance has been disposed. 59 | /// 60 | public static async Task EnqueueAsync( 61 | this LambdaTestServer server, 62 | byte[] content) 63 | { 64 | ArgumentNullException.ThrowIfNull(server); 65 | 66 | var request = new LambdaTestRequest(content); 67 | 68 | return await server.EnqueueAsync(request).ConfigureAwait(false); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/LambdaTestServerOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace MartinCostello.Testing.AwsLambdaTestServer; 7 | 8 | /// 9 | /// A class representing options for the class. This class cannot be inherited. 10 | /// 11 | public sealed class LambdaTestServerOptions 12 | { 13 | /// 14 | /// Initializes a new instance of the class. 15 | /// 16 | public LambdaTestServerOptions() 17 | { 18 | FunctionName = "test-function"; 19 | FunctionArn = $"arn:aws:lambda:eu-west-1:123456789012:function:{FunctionName}"; 20 | LogGroupName = "test-function-log-group"; 21 | LogStreamName = "test-function-log-stream"; 22 | 23 | FunctionHandler = string.Empty; 24 | FunctionMemorySize = 128; 25 | FunctionTimeout = TimeSpan.FromSeconds(3); 26 | FunctionVersion = 1; 27 | } 28 | 29 | /// 30 | /// Gets or sets an optional delegate to invoke when configuring the test Lambda runtime server. 31 | /// 32 | public Action? Configure { get; set; } 33 | 34 | /// 35 | /// Gets or sets a value indicating whether to disable the memory limit check for the Lambda function. 36 | /// 37 | public bool DisableMemoryLimitCheck { get; set; } 38 | 39 | /// 40 | /// Gets or sets the ARN of the Lambda function being tested. 41 | /// 42 | public string FunctionArn { get; set; } 43 | 44 | /// 45 | /// Gets or sets the optional handler for the Lambda function being tested. 46 | /// 47 | public string FunctionHandler { get; set; } 48 | 49 | /// 50 | /// Gets or sets the amount of memory available to the function in megabytes during execution. 51 | /// 52 | /// 53 | /// To disable enforcement of this limit by the AWS Lambda runtime, set to . 54 | /// 55 | public int FunctionMemorySize { get; set; } 56 | 57 | /// 58 | /// Gets or sets the name of the Lambda function being tested. 59 | /// 60 | public string FunctionName { get; set; } 61 | 62 | /// 63 | /// Gets or sets the function's timeout. The default value is 3 seconds. 64 | /// 65 | /// 66 | /// This limit is not enforced and is only used for reporting into the Lambda context. 67 | /// 68 | public TimeSpan FunctionTimeout { get; set; } 69 | 70 | /// 71 | /// Gets or sets the version of the Lambda function being tested. 72 | /// 73 | public int FunctionVersion { get; set; } 74 | 75 | /// 76 | /// Gets or sets the name of the log group for the Lambda function being tested. 77 | /// 78 | public string LogGroupName { get; set; } 79 | 80 | /// 81 | /// Gets or sets the name of the log stream for the Lambda function being tested. 82 | /// 83 | public string LogStreamName { get; set; } 84 | } 85 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/MartinCostello.Testing.AwsLambdaTestServer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test server for AWS Lambda 4 | Provides an in-memory test server for testing AWS Lambda functions. 5 | true 6 | true 7 | true 8 | Library 9 | MartinCostello.Testing.AwsLambdaTestServer 10 | MartinCostello.Testing.AwsLambdaTestServer 11 | net8.0;net9.0 12 | AWS Lambda Test Server 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/PublicAPI/PublicAPI.Shipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestContext 3 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestContext.Request.get -> MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest! 4 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestContext.Response.get -> System.Threading.Channels.ChannelReader! 5 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest 6 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest.AwsRequestId.get -> string! 7 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest.ClientContext.get -> string? 8 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest.ClientContext.set -> void 9 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest.CognitoIdentity.get -> string? 10 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest.CognitoIdentity.set -> void 11 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest.Content.get -> byte[]! 12 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest.LambdaTestRequest(byte[]! content, string? awsRequestId = null) -> void 13 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestResponse 14 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestResponse.Content.get -> byte[]! 15 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestResponse.Duration.get -> System.TimeSpan 16 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestResponse.IsSuccessful.get -> bool 17 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestResponseExtensions 18 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer 19 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.Dispose() -> void 20 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.EnqueueAsync(MartinCostello.Testing.AwsLambdaTestServer.LambdaTestRequest! request) -> System.Threading.Tasks.Task! 21 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.IsStarted.get -> bool 22 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.LambdaTestServer() -> void 23 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.LambdaTestServer(MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions! options) -> void 24 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.LambdaTestServer(System.Action! configure) -> void 25 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.Options.get -> MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions! 26 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.~LambdaTestServer() -> void 27 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerExtensions 28 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions 29 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.Configure.get -> System.Action? 30 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.Configure.set -> void 31 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.DisableMemoryLimitCheck.get -> bool 32 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.DisableMemoryLimitCheck.set -> void 33 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionArn.get -> string! 34 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionArn.set -> void 35 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionHandler.get -> string! 36 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionHandler.set -> void 37 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionMemorySize.get -> int 38 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionMemorySize.set -> void 39 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionName.get -> string! 40 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionName.set -> void 41 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionTimeout.get -> System.TimeSpan 42 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionTimeout.set -> void 43 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionVersion.get -> int 44 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.FunctionVersion.set -> void 45 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.LambdaTestServerOptions() -> void 46 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.LogGroupName.get -> string! 47 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.LogGroupName.set -> void 48 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.LogStreamName.get -> string! 49 | MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerOptions.LogStreamName.set -> void 50 | static MartinCostello.Testing.AwsLambdaTestServer.LambdaTestResponseExtensions.ReadAsStringAsync(this MartinCostello.Testing.AwsLambdaTestServer.LambdaTestResponse! response) -> System.Threading.Tasks.Task! 51 | static MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.ClearLambdaEnvironmentVariables() -> void 52 | static MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerExtensions.EnqueueAsync(this MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer! server, byte[]! content) -> System.Threading.Tasks.Task! 53 | static MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServerExtensions.EnqueueAsync(this MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer! server, string! value) -> System.Threading.Tasks.Task! 54 | virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.Configure(Microsoft.AspNetCore.Builder.IApplicationBuilder! app) -> void 55 | virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> void 56 | virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder! builder) -> void 57 | virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.CreateClient() -> System.Net.Http.HttpClient! 58 | virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.CreateServer(Microsoft.AspNetCore.Hosting.WebHostBuilder! builder) -> Microsoft.AspNetCore.Hosting.Server.IServer! 59 | virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.Dispose(bool disposing) -> void 60 | virtual MartinCostello.Testing.AwsLambdaTestServer.LambdaTestServer.StartAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! 61 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/PublicAPI/PublicAPI.Unshipped.txt: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | -------------------------------------------------------------------------------- /src/AwsLambdaTestServer/RuntimeHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Collections.Concurrent; 5 | using System.Diagnostics; 6 | using System.Net.Mime; 7 | using System.Security.Cryptography; 8 | using System.Text; 9 | using System.Threading.Channels; 10 | using Microsoft.AspNetCore.Http; 11 | using Microsoft.Extensions.Logging; 12 | 13 | namespace MartinCostello.Testing.AwsLambdaTestServer; 14 | 15 | /// 16 | /// A class representing a handler for AWS Lambda runtime HTTP requests. This class cannot be inherited. 17 | /// 18 | internal sealed class RuntimeHandler : IDisposable 19 | { 20 | /// 21 | /// The cancellation token that is signalled when request listening should stop. This field is read-only. 22 | /// 23 | private readonly CancellationToken _cancellationToken; 24 | 25 | /// 26 | /// The test server's options. This field is read-only. 27 | /// 28 | private readonly LambdaTestServerOptions _options; 29 | 30 | /// 31 | /// The channel of function requests to process. This field is read-only. 32 | /// 33 | private readonly Channel _requests; 34 | 35 | /// 36 | /// A dictionary containing channels for the responses for enqueued requests. This field is read-only. 37 | /// 38 | private readonly ConcurrentDictionary _responses; 39 | 40 | /// 41 | /// Whether the instance has been disposed. 42 | /// 43 | private bool _disposed; 44 | 45 | /// 46 | /// Initializes a new instance of the class. 47 | /// 48 | /// The test server's options. 49 | /// The cancellation token that is signalled when request listening should stop. 50 | internal RuntimeHandler(LambdaTestServerOptions options, CancellationToken cancellationToken) 51 | { 52 | _cancellationToken = cancellationToken; 53 | _options = options; 54 | 55 | // Support multi-threaded access to the request queue, although the default 56 | // usage scenario would be a single reader and writer from a test method. 57 | var channelOptions = new UnboundedChannelOptions() 58 | { 59 | SingleReader = false, 60 | SingleWriter = false, 61 | }; 62 | 63 | _requests = Channel.CreateUnbounded(channelOptions); 64 | _responses = new ConcurrentDictionary(StringComparer.Ordinal); 65 | } 66 | 67 | /// 68 | /// Finalizes an instance of the class. 69 | /// 70 | ~RuntimeHandler() => Dispose(false); 71 | 72 | /// 73 | /// Gets or sets the logger to use. 74 | /// 75 | internal ILogger? Logger { get; set; } 76 | 77 | /// 78 | public void Dispose() 79 | { 80 | Dispose(true); 81 | GC.SuppressFinalize(this); 82 | } 83 | 84 | /// 85 | /// Enqueues a request for the Lambda function to process as an asynchronous operation. 86 | /// 87 | /// The request to invoke the function with. 88 | /// The cancellation token to use when enqueuing the item. 89 | /// 90 | /// A representing the asynchronous operation to enqueue the request 91 | /// which returns a channel reader which completes once the request is processed by the function. 92 | /// 93 | /// 94 | /// A request with the Id specified by is currently in-flight. 95 | /// 96 | internal async Task> EnqueueAsync( 97 | LambdaTestRequest request, 98 | CancellationToken cancellationToken) 99 | { 100 | // There is only one response per request, so the channel is bounded to one item 101 | var channel = Channel.CreateBounded(1); 102 | var context = new ResponseContext(channel); 103 | 104 | if (!_responses.TryAdd(request.AwsRequestId, context)) 105 | { 106 | throw new InvalidOperationException($"A request with AWS request Id '{request.AwsRequestId}' is currently in-flight."); 107 | } 108 | 109 | // Enqueue the request for the Lambda runtime to process 110 | await _requests.Writer.WriteAsync(request, cancellationToken).ConfigureAwait(false); 111 | 112 | // Return the reader to the caller to await the function being handled 113 | return channel.Reader; 114 | } 115 | 116 | /// 117 | /// Handles a request for the next invocation for the Lambda function. 118 | /// 119 | /// The HTTP context. 120 | /// 121 | /// A representing the asynchronous operation to get the next invocation request. 122 | /// 123 | internal async Task HandleNextAsync(HttpContext httpContext) 124 | { 125 | Logger?.LogInformation( 126 | "Waiting for new request for Lambda function with ARN {FunctionArn}.", 127 | _options.FunctionArn); 128 | 129 | LambdaTestRequest request; 130 | 131 | try 132 | { 133 | // Additionally cancel the listen loop if the processing is stopped 134 | using var cts = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted, _cancellationToken); 135 | 136 | // Wait until there is a request to process 137 | if (!await _requests.Reader.WaitToReadAsync(cts.Token).ConfigureAwait(false)) 138 | { 139 | cts.Token.ThrowIfCancellationRequested(); 140 | } 141 | 142 | request = await _requests.Reader.ReadAsync(cts.Token).ConfigureAwait(false); 143 | } 144 | catch (Exception ex) when (ex is OperationCanceledException or ChannelClosedException) 145 | { 146 | Logger?.LogInformation( 147 | ex, 148 | "Stopped listening for additional requests for Lambda function with ARN {FunctionArn}.", 149 | _options.FunctionArn); 150 | 151 | // Throw back into LambdaBootstrap, which will then stop processing. 152 | // See https://github.com/aws/aws-lambda-dotnet/pull/540 for details of the change. 153 | throw; 154 | } 155 | 156 | // Write the response for the Lambda runtime to pass to the function to invoke 157 | string traceId = GenerateTraceId(); 158 | 159 | Logger?.LogInformation( 160 | "Invoking Lambda function with ARN {FunctionArn} for request Id {AwsRequestId} and trace Id {AwsTraceId}.", 161 | _options.FunctionArn, 162 | request.AwsRequestId, 163 | traceId); 164 | 165 | // These headers are required, as otherwise an exception is thrown 166 | httpContext.Response.Headers["Lambda-Runtime-Aws-Request-Id"] = request.AwsRequestId; 167 | httpContext.Response.Headers["Lambda-Runtime-Invoked-Function-Arn"] = _options.FunctionArn; 168 | 169 | // These headers are optional 170 | httpContext.Response.Headers["Lambda-Runtime-Trace-Id"] = traceId; 171 | 172 | if (request.ClientContext != null) 173 | { 174 | httpContext.Response.Headers["Lambda-Runtime-Client-Context"] = request.ClientContext; 175 | } 176 | 177 | if (request.CognitoIdentity != null) 178 | { 179 | httpContext.Response.Headers["Lambda-Runtime-Cognito-Identity"] = request.CognitoIdentity; 180 | } 181 | 182 | var deadline = DateTimeOffset.UtcNow.Add(_options.FunctionTimeout).ToUnixTimeMilliseconds(); 183 | 184 | // Record the current time for the response to have the duration measured 185 | _responses[request.AwsRequestId].DurationTimer = Stopwatch.StartNew(); 186 | 187 | httpContext.Response.Headers["Lambda-Runtime-Deadline-Ms"] = deadline.ToString("F0", CultureInfo.InvariantCulture); 188 | 189 | httpContext.Response.ContentType = MediaTypeNames.Application.Json; 190 | httpContext.Response.StatusCode = StatusCodes.Status200OK; 191 | 192 | await httpContext.Response.BodyWriter.WriteAsync(request.Content, httpContext.RequestAborted).ConfigureAwait(false); 193 | } 194 | 195 | /// 196 | /// Handles an successful response for an invocation of the Lambda function. 197 | /// 198 | /// The HTTP context. 199 | /// 200 | /// A representing the asynchronous operation to handle the response. 201 | /// 202 | internal async Task HandleResponseAsync(HttpContext httpContext) 203 | { 204 | string? awsRequestId = httpContext.Request.RouteValues["AwsRequestId"] as string; 205 | 206 | byte[] content = await ReadContentAsync(httpContext, httpContext.RequestAborted).ConfigureAwait(false); 207 | 208 | Logger?.LogInformation( 209 | "Invoked Lambda function with ARN {FunctionArn} for request Id {AwsRequestId}: {ResponseContent}.", 210 | _options.FunctionArn, 211 | awsRequestId, 212 | ToString(content)); 213 | 214 | await CompleteRequestChannelAsync( 215 | awsRequestId!, 216 | content, 217 | isSuccessful: true, 218 | httpContext.RequestAborted).ConfigureAwait(false); 219 | 220 | httpContext.Response.StatusCode = StatusCodes.Status204NoContent; 221 | } 222 | 223 | /// 224 | /// Handles an error response for an invocation of the Lambda function. 225 | /// 226 | /// The HTTP context. 227 | /// 228 | /// A representing the asynchronous operation to handle the response. 229 | /// 230 | internal async Task HandleInvocationErrorAsync(HttpContext httpContext) 231 | { 232 | string? awsRequestId = httpContext.Request.RouteValues["AwsRequestId"] as string; 233 | 234 | byte[] content = await ReadContentAsync(httpContext, httpContext.RequestAborted).ConfigureAwait(false); 235 | 236 | Logger?.LogError( 237 | "Error invoking Lambda function with ARN {FunctionArn} for request Id {AwsRequestId}: {ErrorContent}", 238 | _options.FunctionArn, 239 | awsRequestId, 240 | ToString(content)); 241 | 242 | await CompleteRequestChannelAsync( 243 | awsRequestId!, 244 | content, 245 | isSuccessful: false, 246 | httpContext.RequestAborted).ConfigureAwait(false); 247 | 248 | httpContext.Response.StatusCode = StatusCodes.Status204NoContent; 249 | } 250 | 251 | /// 252 | /// Handles an error response for the failed initialization of the Lambda function. 253 | /// 254 | /// The HTTP context. 255 | /// 256 | /// A representing the asynchronous operation to handle the response. 257 | /// 258 | internal async Task HandleInitializationErrorAsync(HttpContext httpContext) 259 | { 260 | byte[] content = await ReadContentAsync(httpContext, httpContext.RequestAborted).ConfigureAwait(false); 261 | 262 | Logger?.LogError( 263 | "Error initializing Lambda function with ARN {FunctionArn}: {ErrorContent}", 264 | _options.FunctionArn, 265 | ToString(content)); 266 | 267 | httpContext.Response.StatusCode = StatusCodes.Status204NoContent; 268 | } 269 | 270 | /// 271 | /// Generates a valid trace identifier for AWS X-Ray. 272 | /// 273 | /// 274 | /// The generated trace identifier. 275 | /// 276 | /// 277 | /// See https://docs.aws.amazon.com/xray/latest/devguide/xray-api-sendingdata.html#xray-api-traceids. 278 | /// 279 | private static string GenerateTraceId() 280 | { 281 | var epoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); 282 | var buffer = RandomNumberGenerator.GetBytes(96 / 8); 283 | #if NET9_0_OR_GREATER 284 | var identifier = Convert.ToHexStringLower(buffer); 285 | #else 286 | #pragma warning disable CA1308 287 | var identifier = Convert.ToHexString(buffer).ToLowerInvariant(); 288 | #pragma warning restore CA1308 289 | #endif 290 | 291 | return FormattableString.Invariant($"Root=1-{epoch:x8}-{identifier}"); 292 | } 293 | 294 | /// 295 | /// Reads the HTTP request body as an asynchronous operation. 296 | /// 297 | /// The HTTP request to read the body from. 298 | /// The cancellation token to use. 299 | /// 300 | /// A representing the asynchronous operation to read the 301 | /// request body that returns a byte array containing the request content. 302 | /// 303 | private static async Task ReadContentAsync(HttpContext httpContext, CancellationToken cancellationToken) 304 | { 305 | using var stream = new MemoryStream(); 306 | 307 | await httpContext.Request.BodyReader.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); 308 | 309 | return stream.ToArray(); 310 | } 311 | 312 | /// 313 | /// Converts the specified byte array to a string. 314 | /// 315 | /// The array to convert to a string. 316 | /// 317 | /// The UTF-8 representation of . 318 | /// 319 | private static string ToString(byte[] content) => Encoding.UTF8.GetString(content); 320 | 321 | /// 322 | /// Completes the request channel for the specified request. 323 | /// 324 | /// The AWS request Id to complete the response for. 325 | /// The raw content associated with the request's response. 326 | /// Whether the response indicates the request was successfully handled. 327 | /// The cancellation token to use. 328 | /// 329 | /// A representing the asynchronous operation. 330 | /// 331 | private async Task CompleteRequestChannelAsync( 332 | string awsRequestId, 333 | byte[] content, 334 | bool isSuccessful, 335 | CancellationToken cancellationToken) 336 | { 337 | if (!_responses.TryRemove(awsRequestId, out var context)) 338 | { 339 | throw new InvalidOperationException($"Failed to complete channel for AWS request Id {awsRequestId}."); 340 | } 341 | 342 | context.DurationTimer!.Stop(); 343 | 344 | Logger?.LogInformation( 345 | "Completed processing AWS request Id {AwsRequestId} for Lambda function with ARN {FunctionArn} in {FunctionDuration} milliseconds.", 346 | awsRequestId, 347 | _options.FunctionArn, 348 | context.DurationTimer.ElapsedMilliseconds); 349 | 350 | // Make the response available to read by the enqueuer 351 | var response = new LambdaTestResponse(content, isSuccessful, context.DurationTimer.Elapsed); 352 | await context.Channel.Writer.WriteAsync(response, cancellationToken).ConfigureAwait(false); 353 | 354 | // Mark the channel as complete as there will be no more responses written 355 | context.Channel.Writer.Complete(); 356 | } 357 | 358 | private void Dispose(bool disposing) 359 | { 360 | if (!_disposed) 361 | { 362 | if (disposing) 363 | { 364 | // Complete the channels so that any callers who do not know 365 | // the server has been disposed do not wait indefinitely for a 366 | // channel to complete that will now never be written to again. 367 | _requests.Writer.TryComplete(); 368 | 369 | var contexts = _responses.Values; 370 | _responses.Clear(); 371 | 372 | foreach (var context in contexts) 373 | { 374 | context.Channel.Writer.TryComplete(); 375 | } 376 | } 377 | 378 | _disposed = true; 379 | } 380 | } 381 | 382 | private sealed class ResponseContext 383 | { 384 | internal ResponseContext(Channel channel) 385 | { 386 | Channel = channel; 387 | } 388 | 389 | internal Channel Channel { get; } 390 | 391 | internal Stopwatch? DurationTimer { get; set; } 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /stylecop.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", 3 | "settings": { 4 | "documentationRules": { 5 | "companyName": "https://github.com/martincostello/lambda-test-server", 6 | "copyrightText": "Copyright (c) {ownerName}, {year}. All rights reserved.\nLicensed under the {licenseName} license. See the {licenseFile} file in the project root for full license information.", 7 | "documentExposedElements": true, 8 | "documentInterfaces": true, 9 | "documentInternalElements": true, 10 | "documentPrivateElements": false, 11 | "documentPrivateFields": false, 12 | "fileNamingConvention": "metadata", 13 | "xmlHeader": false, 14 | "variables": { 15 | "licenseFile": "LICENSE", 16 | "licenseName": "Apache 2.0", 17 | "ownerName": "Martin Costello", 18 | "year": "2019" 19 | } 20 | }, 21 | "layoutRules": { 22 | "newlineAtEndOfFile": "require" 23 | }, 24 | "maintainabilityRules": {}, 25 | "namingRules": { 26 | "allowCommonHungarianPrefixes": true, 27 | "allowedHungarianPrefixes": [] 28 | }, 29 | "orderingRules": { 30 | "elementOrder": [ 31 | "kind", 32 | "accessibility", 33 | "constant", 34 | "static", 35 | "readonly" 36 | ], 37 | "systemUsingDirectivesFirst": true, 38 | "usingDirectivesPlacement": "outsideNamespace" 39 | }, 40 | "readabilityRules": {}, 41 | "spacingRules": {} 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/AssemblyFixture.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Diagnostics; 5 | using MartinCostello.Testing.AwsLambdaTestServer; 6 | 7 | [assembly: AssemblyFixture(typeof(AssemblyFixture))] 8 | 9 | namespace MartinCostello.Testing.AwsLambdaTestServer; 10 | 11 | public sealed class AssemblyFixture 12 | { 13 | // Read the default memory limits before any of the tests execute any code that may change it. 14 | // The cast to ulong is required for the setting to be respected by the runtime. 15 | // See https://github.com/aws/aws-lambda-dotnet/pull/1595#issuecomment-1771747410. 16 | private static readonly ulong DefaultMemory = (ulong)GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; 17 | 18 | public AssemblyFixture() 19 | { 20 | ResetMemoryLimits(); 21 | } 22 | 23 | public static void ResetMemoryLimits() 24 | { 25 | Debug.Assert(DefaultMemory != 134217728, "The default value of TotalAvailableMemoryBytes should not be 128MB."); 26 | 27 | // Undo any changes that Amazon.Lambda.RuntimeSupport makes internally 28 | AppContext.SetData("GCHeapHardLimit", DefaultMemory); 29 | GC.RefreshMemoryLimit(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/AwsIntegrationTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Amazon; 5 | using Amazon.Lambda.Core; 6 | using Amazon.Lambda.RuntimeSupport; 7 | using Amazon.Runtime; 8 | using Amazon.SQS; 9 | using Amazon.SQS.Model; 10 | 11 | namespace MartinCostello.Testing.AwsLambdaTestServer; 12 | 13 | public static class AwsIntegrationTests 14 | { 15 | [Fact] 16 | public static async Task Runtime_Generates_Valid_Aws_Trace_Id() 17 | { 18 | // Arrange 19 | Assert.SkipWhen(GetAwsCredentials() is null, "No AWS credentials are configured."); 20 | 21 | using var server = new LambdaTestServer(); 22 | using var cancellationTokenSource = new CancellationTokenSource(); 23 | 24 | await server.StartAsync(cancellationTokenSource.Token); 25 | 26 | cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(5)); 27 | 28 | var request = new QueueExistsRequest() 29 | { 30 | QueueName = Guid.NewGuid().ToString(), 31 | }; 32 | 33 | var json = System.Text.Json.JsonSerializer.Serialize(request); 34 | var context = await server.EnqueueAsync(json); 35 | 36 | _ = Task.Run( 37 | async () => 38 | { 39 | await context.Response.WaitToReadAsync(cancellationTokenSource.Token); 40 | 41 | if (!cancellationTokenSource.IsCancellationRequested) 42 | { 43 | await cancellationTokenSource.CancelAsync(); 44 | } 45 | }, 46 | cancellationTokenSource.Token); 47 | 48 | using var httpClient = server.CreateClient(); 49 | 50 | // Act 51 | await RunAsync(httpClient, cancellationTokenSource.Token); 52 | 53 | // Assert 54 | context.Response.TryRead(out var response).ShouldBeTrue(); 55 | response.ShouldNotBeNull(); 56 | response.IsSuccessful.ShouldBeTrue(); 57 | } 58 | 59 | private static async Task RunAsync(HttpClient? httpClient, CancellationToken cancellationToken) 60 | { 61 | var serializer = new Amazon.Lambda.Serialization.Json.JsonSerializer(); 62 | 63 | using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(SqsFunction.QueueExistsAsync, serializer); 64 | using var bootstrap = new LambdaBootstrap(httpClient, handlerWrapper, SqsFunction.InitializeAsync); 65 | 66 | await bootstrap.RunAsync(cancellationToken); 67 | } 68 | 69 | private static AWSCredentials? GetAwsCredentials() 70 | { 71 | try 72 | { 73 | return new EnvironmentVariablesAWSCredentials(); 74 | } 75 | catch (InvalidOperationException) 76 | { 77 | // Not configured 78 | } 79 | 80 | try 81 | { 82 | return AssumeRoleWithWebIdentityCredentials.FromEnvironmentVariables(); 83 | } 84 | catch (ArgumentException) 85 | { 86 | return null; 87 | } 88 | } 89 | 90 | private static class SqsFunction 91 | { 92 | public static Task InitializeAsync() => Task.FromResult(true); 93 | 94 | public static async Task QueueExistsAsync(QueueExistsRequest request, ILambdaContext context) 95 | { 96 | context.Logger.LogLine($"Handling AWS request Id {context.AwsRequestId} to check if SQS queue ${request.QueueName} exists."); 97 | 98 | var credentials = GetAwsCredentials(); 99 | var region = RegionEndpoint.EUWest2; 100 | 101 | bool exists; 102 | 103 | try 104 | { 105 | using var client = new AmazonSQSClient(credentials, region); 106 | _ = await client.GetQueueUrlAsync(request.QueueName); 107 | 108 | exists = true; 109 | } 110 | catch (QueueDoesNotExistException) 111 | { 112 | exists = false; 113 | } 114 | 115 | context.Logger.LogLine($"SQS queue ${request.QueueName} {(exists ? "exists" : "does not exist")}."); 116 | 117 | return exists; 118 | } 119 | } 120 | 121 | private sealed class QueueExistsRequest 122 | { 123 | public string QueueName { get; set; } = string.Empty; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/Examples.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | 6 | namespace MartinCostello.Testing.AwsLambdaTestServer; 7 | 8 | /// 9 | /// Examples for using MartinCostello.Testing.AwsLambdaTestServer. 10 | /// 11 | public static class Examples 12 | { 13 | [Fact] 14 | public static async Task Function_Can_Process_Request() 15 | { 16 | // Arrange - Create a test server for the Lambda runtime to use 17 | using var server = new LambdaTestServer(); 18 | 19 | // Create a cancellation token that stops the server listening for new requests. 20 | // Auto-cancel the server after 2 seconds in case something goes wrong and the request is not handled. 21 | using var cancellationTokenSource = new CancellationTokenSource(); 22 | 23 | // Start the test server so it is ready to listen for requests from the Lambda runtime 24 | await server.StartAsync(cancellationTokenSource.Token); 25 | 26 | // Now that the server has started, cancel it after 2 seconds if no requests are processed 27 | cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(2)); 28 | 29 | // Create a test request for the Lambda function being tested 30 | var value = new MyRequest() 31 | { 32 | Values = [1, 2, 3], // The function returns the sum of the specified numbers 33 | }; 34 | 35 | string requestJson = JsonSerializer.Serialize(value); 36 | 37 | // Queue the request with the server to invoke the Lambda function and 38 | // store the ChannelReader into a variable to use to read the response. 39 | LambdaTestContext context = await server.EnqueueAsync(requestJson); 40 | 41 | // Queue a task to stop the test server from listening as soon as the response is available 42 | _ = Task.Run( 43 | async () => 44 | { 45 | await context.Response.WaitToReadAsync(cancellationTokenSource.Token); 46 | 47 | if (!cancellationTokenSource.IsCancellationRequested) 48 | { 49 | await cancellationTokenSource.CancelAsync(); 50 | } 51 | }, 52 | cancellationTokenSource.Token); 53 | 54 | // Create an HttpClient for the Lambda to use with LambdaBootstrap 55 | using var httpClient = server.CreateClient(); 56 | 57 | // Act - Start the Lambda runtime and run until the cancellation token is signalled 58 | await MyFunctionEntrypoint.RunAsync(httpClient, cancellationTokenSource.Token); 59 | 60 | // Assert - The channel reader should have the response available 61 | context.Response.TryRead(out LambdaTestResponse? response).ShouldBeTrue("No Lambda response is available."); 62 | 63 | response!.IsSuccessful.ShouldBeTrue("The Lambda function failed to handle the request."); 64 | response.Content.ShouldNotBeEmpty("The Lambda function did not return any content."); 65 | 66 | string responseJson = await response.ReadAsStringAsync(); 67 | var actual = JsonSerializer.Deserialize(responseJson); 68 | 69 | actual.ShouldNotBeNull(); 70 | actual.Sum.ShouldBe(6, "The Lambda function returned an incorrect response."); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/FunctionRunner.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Amazon.Lambda.RuntimeSupport; 5 | using Amazon.Lambda.Serialization.Json; 6 | 7 | namespace MartinCostello.Testing.AwsLambdaTestServer; 8 | 9 | internal static class FunctionRunner 10 | { 11 | internal static async Task RunAsync(HttpClient? httpClient, CancellationToken cancellationToken) 12 | where T : MyHandler, new() 13 | { 14 | var handler = new T(); 15 | var serializer = new JsonSerializer(); 16 | 17 | using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler.SumAsync, serializer); 18 | using var bootstrap = new LambdaBootstrap(httpClient, handlerWrapper, handler.InitializeAsync); 19 | 20 | await bootstrap.RunAsync(cancellationToken); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/HttpLambdaTestServer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Logging.XUnit; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.Hosting.Server; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace MartinCostello.Testing.AwsLambdaTestServer; 11 | 12 | internal sealed class HttpLambdaTestServer : LambdaTestServer, IAsyncLifetime, ITestOutputHelperAccessor 13 | { 14 | private readonly CancellationTokenSource _cts = new(); 15 | private bool _disposed; 16 | private IWebHost? _webHost; 17 | 18 | public HttpLambdaTestServer() 19 | : base() 20 | { 21 | } 22 | 23 | public HttpLambdaTestServer(Action configure) 24 | : base(configure) 25 | { 26 | } 27 | 28 | public ITestOutputHelper? OutputHelper { get; set; } 29 | 30 | public async ValueTask DisposeAsync() 31 | { 32 | if (_webHost is not null) 33 | { 34 | await _webHost.StopAsync(); 35 | } 36 | 37 | Dispose(); 38 | } 39 | 40 | public async ValueTask InitializeAsync() 41 | { 42 | Options.Configure = (services) => 43 | services.AddLogging((builder) => builder.AddXUnit(this)); 44 | 45 | await StartAsync(_cts.Token); 46 | } 47 | 48 | protected override IServer CreateServer(WebHostBuilder builder) 49 | { 50 | _webHost = builder 51 | .UseKestrel() 52 | .ConfigureServices((services) => services.AddLogging((builder) => builder.AddXUnit(this))) 53 | .UseUrls("http://127.0.0.1:0") 54 | .Build(); 55 | 56 | _webHost.Start(); 57 | 58 | return _webHost.Services.GetRequiredService(); 59 | } 60 | 61 | protected override void Dispose(bool disposing) 62 | { 63 | if (!_disposed) 64 | { 65 | if (disposing) 66 | { 67 | _webHost?.Dispose(); 68 | 69 | _cts.Cancel(); 70 | _cts.Dispose(); 71 | } 72 | 73 | _disposed = true; 74 | } 75 | 76 | base.Dispose(disposing); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/HttpLambdaTestServerTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text; 5 | using MartinCostello.Logging.XUnit; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace MartinCostello.Testing.AwsLambdaTestServer; 10 | 11 | #pragma warning disable JSON002 12 | 13 | [Collection] 14 | public class HttpLambdaTestServerTests(ITestOutputHelper outputHelper) : ITestOutputHelperAccessor 15 | { 16 | public ITestOutputHelper? OutputHelper { get; set; } = outputHelper; 17 | 18 | [Fact] 19 | public async Task Function_Can_Process_Request() 20 | { 21 | // Arrange 22 | void Configure(IServiceCollection services) 23 | => services.AddLogging((builder) => builder.AddXUnit(this)); 24 | 25 | using var server = new HttpLambdaTestServer(Configure); 26 | using var cts = new CancellationTokenSource(); 27 | 28 | await server.StartAsync(cts.Token); 29 | 30 | cts.CancelAfter(TimeSpan.FromSeconds(2)); 31 | 32 | var context = await server.EnqueueAsync("""{"Values": [ 1, 2, 3 ]}"""); 33 | 34 | _ = Task.Run( 35 | async () => 36 | { 37 | await context.Response.WaitToReadAsync(cts.Token); 38 | 39 | if (!cts.IsCancellationRequested) 40 | { 41 | await cts.CancelAsync(); 42 | } 43 | }, 44 | cts.Token); 45 | 46 | using var httpClient = server.CreateClient(); 47 | 48 | // Act 49 | await MyFunctionEntrypoint.RunAsync(httpClient, cts.Token); 50 | 51 | // Assert 52 | context.Response.TryRead(out var response).ShouldBeTrue(); 53 | 54 | response.ShouldNotBeNull(); 55 | response!.IsSuccessful.ShouldBeTrue(); 56 | response.Content.ShouldNotBeNull(); 57 | response.Duration.ShouldBeGreaterThan(TimeSpan.Zero); 58 | Encoding.UTF8.GetString(response.Content).ShouldBe("""{"Sum":6}"""); 59 | } 60 | 61 | [Fact] 62 | public async Task Function_Can_Handle_Failed_Request() 63 | { 64 | // Arrange 65 | void Configure(IServiceCollection services) 66 | => services.AddLogging((builder) => builder.AddXUnit(this)); 67 | 68 | using var server = new LambdaTestServer(Configure); 69 | using var cts = new CancellationTokenSource(); 70 | 71 | await server.StartAsync(cts.Token); 72 | 73 | cts.CancelAfter(TimeSpan.FromSeconds(2)); 74 | 75 | var context = await server.EnqueueAsync("""{"Values": null}"""); 76 | 77 | _ = Task.Run( 78 | async () => 79 | { 80 | await context.Response.WaitToReadAsync(cts.Token); 81 | 82 | if (!cts.IsCancellationRequested) 83 | { 84 | await cts.CancelAsync(); 85 | } 86 | }, 87 | cts.Token); 88 | 89 | using var httpClient = server.CreateClient(); 90 | 91 | // Act 92 | await MyFunctionEntrypoint.RunAsync(httpClient, cts.Token); 93 | 94 | // Assert 95 | context.Response.TryRead(out var response).ShouldBeTrue(); 96 | 97 | response.ShouldNotBeNull(); 98 | response!.IsSuccessful.ShouldBeFalse(); 99 | response.Content.ShouldNotBeNull(); 100 | } 101 | 102 | [Fact] 103 | public async Task Function_Can_Process_Multiple_Requests() 104 | { 105 | // Arrange 106 | void Configure(IServiceCollection services) 107 | => services.AddLogging((builder) => builder.AddXUnit(this)); 108 | 109 | using var server = new LambdaTestServer(Configure); 110 | using var cts = new CancellationTokenSource(); 111 | 112 | await server.StartAsync(cts.Token); 113 | 114 | cts.CancelAfter(TimeSpan.FromSeconds(2)); 115 | 116 | var channels = new List<(int Expected, LambdaTestContext Context)>(); 117 | 118 | for (int i = 0; i < 10; i++) 119 | { 120 | var request = new MyRequest() 121 | { 122 | Values = [.. Enumerable.Range(1, i + 1)], 123 | }; 124 | 125 | channels.Add((request.Values.Sum(), await server.EnqueueAsync(request))); 126 | } 127 | 128 | _ = Task.Run( 129 | async () => 130 | { 131 | foreach ((var _, var context) in channels) 132 | { 133 | await context.Response.WaitToReadAsync(cts.Token); 134 | } 135 | 136 | if (!cts.IsCancellationRequested) 137 | { 138 | await cts.CancelAsync(); 139 | } 140 | }, 141 | cts.Token); 142 | 143 | using var httpClient = server.CreateClient(); 144 | 145 | // Act 146 | await MyFunctionEntrypoint.RunAsync(httpClient, cts.Token); 147 | 148 | // Assert 149 | foreach ((int expected, var context) in channels) 150 | { 151 | context.Response.TryRead(out var response).ShouldBeTrue(); 152 | 153 | response.ShouldNotBeNull(); 154 | response!.IsSuccessful.ShouldBeTrue(); 155 | response.Content.ShouldNotBeNull(); 156 | 157 | var deserialized = response.ReadAs(); 158 | deserialized.Sum.ShouldBe(expected); 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/LambdaTestRequestTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Testing.AwsLambdaTestServer; 5 | 6 | public static class LambdaTestRequestTests 7 | { 8 | [Fact] 9 | public static void Constructor_Throws_If_Content_Null() 10 | { 11 | // Arrange 12 | byte[] content = null!; 13 | 14 | // Act and Assert 15 | Assert.Throws("content", () => new LambdaTestRequest(content)); 16 | } 17 | 18 | [Fact] 19 | public static void Constructor_Sets_Properties() 20 | { 21 | // Arrange 22 | byte[] content = [1]; 23 | 24 | // Act 25 | var actual = new LambdaTestRequest(content); 26 | 27 | // Assert 28 | actual.Content.ShouldBeSameAs(content); 29 | actual.AwsRequestId.ShouldNotBeNullOrEmpty(); 30 | Guid.TryParse(actual.AwsRequestId, out var requestId).ShouldBeTrue(); 31 | requestId.ShouldNotBe(Guid.Empty); 32 | 33 | // Arrange 34 | string awsRequestId = "my-request-id"; 35 | 36 | // Act 37 | actual = new LambdaTestRequest(content, awsRequestId); 38 | 39 | // Assert 40 | actual.Content.ShouldBeSameAs(content); 41 | actual.AwsRequestId.ShouldBe(awsRequestId); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/LambdaTestResponseExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Testing.AwsLambdaTestServer; 5 | 6 | public static class LambdaTestResponseExtensionsTests 7 | { 8 | [Fact] 9 | public static async Task EnqueueAsync_Validates_Parameters() 10 | { 11 | // Arrange 12 | LambdaTestResponse response = null!; 13 | 14 | // Act 15 | await Assert.ThrowsAsync("response", response.ReadAsStringAsync); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/LambdaTestServerCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Testing.AwsLambdaTestServer; 5 | 6 | [CollectionDefinition(nameof(LambdaTestServerCollection), DisableParallelization = true)] 7 | public class LambdaTestServerCollection; 8 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/LambdaTestServerExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Testing.AwsLambdaTestServer; 5 | 6 | public static class LambdaTestServerExtensionsTests 7 | { 8 | [Fact] 9 | public static async Task EnqueueAsync_Validates_Parameters() 10 | { 11 | // Arrange 12 | using var server = new LambdaTestServer(); 13 | using LambdaTestServer nullServer = null!; 14 | byte[] content = null!; 15 | string value = null!; 16 | 17 | byte[] emptyBytes = []; 18 | 19 | // Act 20 | await Assert.ThrowsAsync("content", () => server.EnqueueAsync(content)); 21 | await Assert.ThrowsAsync("value", () => server.EnqueueAsync(value)); 22 | await Assert.ThrowsAsync("server", () => nullServer.EnqueueAsync(emptyBytes)); 23 | await Assert.ThrowsAsync("server", () => nullServer.EnqueueAsync(string.Empty)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/LambdaTestServerOptionsTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Testing.AwsLambdaTestServer; 5 | 6 | public static class LambdaTestServerOptionsTests 7 | { 8 | [Fact] 9 | public static void Constructor_Initializes_Defaults() 10 | { 11 | // Act 12 | var actual = new LambdaTestServerOptions(); 13 | 14 | // Assert 15 | actual.Configure.ShouldBeNull(); 16 | actual.FunctionArn.ShouldNotBeNullOrEmpty(); 17 | actual.FunctionHandler.ShouldBe(string.Empty); 18 | actual.FunctionMemorySize.ShouldBe(128); 19 | actual.FunctionTimeout.ShouldBe(TimeSpan.FromSeconds(3)); 20 | actual.FunctionVersion.ShouldBe(1); 21 | actual.LogGroupName.ShouldNotBeNullOrEmpty(); 22 | actual.LogStreamName.ShouldNotBeNullOrEmpty(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/MartinCostello.Testing.AwsLambdaTestServer.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Tests for MartinCostello.Testing.AwsLambdaTestServer. 4 | Exe 5 | MartinCostello.Testing.AwsLambdaTestServer 6 | net9.0 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | true 29 | 84 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/MyFunctionEntrypoint.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Testing.AwsLambdaTestServer; 5 | 6 | internal static class MyFunctionEntrypoint 7 | { 8 | internal static async Task RunAsync( 9 | HttpClient? httpClient = null, 10 | CancellationToken cancellationToken = default) 11 | { 12 | await FunctionRunner.RunAsync(httpClient, cancellationToken); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/MyHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Amazon.Lambda.Core; 5 | 6 | namespace MartinCostello.Testing.AwsLambdaTestServer; 7 | 8 | internal class MyHandler 9 | { 10 | public virtual Task InitializeAsync() 11 | => Task.FromResult(true); 12 | 13 | public virtual Task SumAsync(MyRequest request, ILambdaContext context) 14 | { 15 | context.Logger.LogLine($"Handling AWS request Id {context.AwsRequestId}."); 16 | 17 | var response = new MyResponse() 18 | { 19 | Sum = request.Values!.Sum(), 20 | }; 21 | 22 | context.Logger.LogLine($"The sum of the {request.Values?.Count} values is {response.Sum}."); 23 | 24 | return Task.FromResult(response); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/MyRequest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Testing.AwsLambdaTestServer; 5 | 6 | internal sealed class MyRequest 7 | { 8 | public ICollection? Values { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/MyResponse.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Testing.AwsLambdaTestServer; 5 | 6 | internal sealed class MyResponse 7 | { 8 | public int Sum { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/ParallelismTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Collections.Concurrent; 5 | using MyFunctions; 6 | 7 | namespace MartinCostello.Testing.AwsLambdaTestServer; 8 | 9 | public static class ParallelismTests 10 | { 11 | [Fact(Timeout = 30_000)] 12 | public static async Task Function_Can_Process_Multiple_Requests_On_Different_Threads() 13 | { 14 | // Arrange 15 | int messageCount = 1_000; 16 | int expected = Enumerable.Range(0, messageCount).Sum(); 17 | 18 | using var server = new LambdaTestServer(); 19 | using var cts = new CancellationTokenSource(); 20 | 21 | await server.StartAsync(cts.Token); 22 | 23 | using var httpClient = server.CreateClient(); 24 | 25 | // Enqueue the requests to process in the background 26 | var addTask = EnqueueInParallel(messageCount, server); 27 | 28 | // Start a task to consume the responses in the background 29 | var processTask = Assert(addTask, messageCount, cts); 30 | 31 | // Act - Start the function processing 32 | await ReverseFunction.RunAsync(httpClient, cts.Token); 33 | 34 | // Assert 35 | int actual = await processTask.Task; 36 | actual.ShouldBe(expected); 37 | } 38 | 39 | private static async Task> EnqueueInParallel( 40 | int count, 41 | LambdaTestServer server) 42 | { 43 | var collection = new ConcurrentBag(); 44 | 45 | await Task.Yield(); 46 | await Parallel.ForAsync(0, count, async (i, _) => collection.Add(await server.EnqueueAsync(new[] { i }))); 47 | 48 | return collection; 49 | } 50 | 51 | private static TaskCompletionSource Assert( 52 | Task> addTask, 53 | int messages, 54 | CancellationTokenSource cts) 55 | { 56 | var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); 57 | 58 | _ = Task.Run(async () => 59 | { 60 | var collection = await addTask; 61 | collection.Count.ShouldBe(messages); 62 | 63 | int actual = 0; 64 | 65 | foreach (var context in collection) 66 | { 67 | await context.Response.WaitToReadAsync(); 68 | 69 | var result = await context.Response.ReadAsync(); 70 | 71 | var response = result.ReadAs(); 72 | 73 | actual += response[0]; 74 | } 75 | 76 | completionSource.SetResult(actual); 77 | await cts.CancelAsync(); 78 | }); 79 | 80 | return completionSource; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/ReverseFunction.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using Amazon.Lambda.RuntimeSupport; 5 | using Amazon.Lambda.Serialization.Json; 6 | 7 | #pragma warning disable IDE0130 8 | namespace MyFunctions; 9 | 10 | public static class ReverseFunction 11 | { 12 | public static async Task RunAsync( 13 | HttpClient? httpClient = null, 14 | CancellationToken cancellationToken = default) 15 | { 16 | var serializer = new JsonSerializer(); 17 | 18 | #pragma warning disable CA2000 19 | using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(ReverseAsync, serializer); 20 | using var bootstrap = new LambdaBootstrap(httpClient ?? new HttpClient(), handlerWrapper); 21 | #pragma warning restore CA2000 22 | 23 | await bootstrap.RunAsync(cancellationToken); 24 | } 25 | 26 | public static Task ReverseAsync(int[] values) 27 | { 28 | return Task.FromResult(values.Reverse().ToArray()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/ReverseFunctionTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | using MartinCostello.Testing.AwsLambdaTestServer; 6 | 7 | #pragma warning disable IDE0130 8 | namespace MyFunctions; 9 | 10 | public static class ReverseFunctionTests 11 | { 12 | [Fact] 13 | public static async Task Function_Reverses_Numbers() 14 | { 15 | // Arrange 16 | using var server = new LambdaTestServer(); 17 | using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); 18 | 19 | await server.StartAsync(cancellationTokenSource.Token); 20 | 21 | int[] value = [1, 2, 3]; 22 | byte[] json = JsonSerializer.SerializeToUtf8Bytes(value); 23 | 24 | LambdaTestContext context = await server.EnqueueAsync(json); 25 | 26 | using var httpClient = server.CreateClient(); 27 | 28 | // Act 29 | await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token); 30 | 31 | // Assert 32 | Assert.True(context.Response.TryRead(out LambdaTestResponse? response)); 33 | Assert.True(response!.IsSuccessful); 34 | Assert.NotNull(response.Content); 35 | 36 | var actual = JsonSerializer.Deserialize(response.Content); 37 | 38 | Assert.NotNull(actual); 39 | int[] expected = [3, 2, 1]; 40 | Assert.Equal(expected, actual); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/ReverseFunctionWithCustomOptionsTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | using MartinCostello.Testing.AwsLambdaTestServer; 6 | 7 | #pragma warning disable IDE0130 8 | namespace MyFunctions; 9 | 10 | public static class ReverseFunctionWithCustomOptionsTests 11 | { 12 | [Fact] 13 | public static async Task Function_Reverses_Numbers_With_Custom_Options() 14 | { 15 | // Arrange 16 | var options = new LambdaTestServerOptions() 17 | { 18 | FunctionMemorySize = 256, 19 | FunctionTimeout = TimeSpan.FromSeconds(30), 20 | FunctionVersion = 42, 21 | }; 22 | 23 | using var server = new LambdaTestServer(options); 24 | using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); 25 | 26 | await server.StartAsync(cancellationTokenSource.Token); 27 | 28 | int[] value = [1, 2, 3]; 29 | byte[] json = JsonSerializer.SerializeToUtf8Bytes(value); 30 | 31 | LambdaTestContext context = await server.EnqueueAsync(json); 32 | 33 | using var httpClient = server.CreateClient(); 34 | 35 | // Act 36 | await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token); 37 | 38 | // Assert 39 | Assert.True(context.Response.TryRead(out LambdaTestResponse? response)); 40 | Assert.True(response!.IsSuccessful); 41 | 42 | var actual = JsonSerializer.Deserialize(response.Content); 43 | 44 | Assert.NotNull(actual); 45 | int[] expected = [3, 2, 1]; 46 | Assert.Equal(expected, actual); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/ReverseFunctionWithLoggingTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | using MartinCostello.Logging.XUnit; 6 | using MartinCostello.Testing.AwsLambdaTestServer; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Logging; 9 | 10 | #pragma warning disable IDE0130 11 | namespace MyFunctions; 12 | 13 | public class ReverseFunctionWithLoggingTests(ITestOutputHelper outputHelper) : ITestOutputHelperAccessor 14 | { 15 | public ITestOutputHelper? OutputHelper { get; set; } = outputHelper; 16 | 17 | [Fact] 18 | public async Task Function_Reverses_Numbers_With_Logging() 19 | { 20 | // Arrange 21 | using var server = new LambdaTestServer( 22 | (services) => services.AddLogging( 23 | (builder) => builder.AddXUnit(this))); 24 | 25 | using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); 26 | 27 | await server.StartAsync(cancellationTokenSource.Token); 28 | 29 | int[] value = [1, 2, 3]; 30 | byte[] json = JsonSerializer.SerializeToUtf8Bytes(value); 31 | 32 | LambdaTestContext context = await server.EnqueueAsync(json); 33 | 34 | using var httpClient = server.CreateClient(); 35 | 36 | // Act 37 | await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token); 38 | 39 | // Assert 40 | Assert.True(context.Response.TryRead(out LambdaTestResponse? response)); 41 | Assert.NotNull(response); 42 | Assert.True(response!.IsSuccessful); 43 | 44 | var actual = JsonSerializer.Deserialize(response.Content); 45 | 46 | Assert.NotNull(actual); 47 | int[] expected = [3, 2, 1]; 48 | Assert.Equal(expected, actual); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/ReverseFunctionWithMobileSdkTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | using MartinCostello.Testing.AwsLambdaTestServer; 6 | 7 | #pragma warning disable IDE0130 8 | namespace MyFunctions; 9 | 10 | public static class ReverseFunctionWithMobileSdkTests 11 | { 12 | [Fact] 13 | public static async Task Function_Reverses_Numbers_With_Mobile_Sdk() 14 | { 15 | // Arrange 16 | using var server = new LambdaTestServer(); 17 | using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); 18 | 19 | await server.StartAsync(cancellationTokenSource.Token); 20 | 21 | int[] value = [1, 2, 3]; 22 | byte[] content = JsonSerializer.SerializeToUtf8Bytes(value); 23 | 24 | LambdaTestContext context = await server.EnqueueAsync(content); 25 | 26 | using var httpClient = server.CreateClient(); 27 | 28 | // Act 29 | await ReverseFunction.RunAsync(httpClient, cancellationTokenSource.Token); 30 | 31 | // Assert 32 | Assert.True(context.Response.TryRead(out LambdaTestResponse? response)); 33 | Assert.NotNull(response); 34 | Assert.True(response!.IsSuccessful); 35 | 36 | var actual = JsonSerializer.Deserialize(response.Content); 37 | 38 | Assert.NotNull(actual); 39 | int[] expected = [3, 2, 1]; 40 | Assert.Equal(expected, actual); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/AwsLambdaTestServer.Tests/TestExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2019. All rights reserved. 2 | // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json; 5 | 6 | namespace MartinCostello.Testing.AwsLambdaTestServer; 7 | 8 | internal static class TestExtensions 9 | { 10 | internal static async Task EnqueueAsync(this LambdaTestServer server, T value) 11 | where T : class 12 | { 13 | byte[] json = JsonSerializer.SerializeToUtf8Bytes(value); 14 | return await server.EnqueueAsync(json); 15 | } 16 | 17 | internal static T ReadAs(this LambdaTestResponse response) 18 | where T : class 19 | { 20 | return JsonSerializer.Deserialize(response.Content)!; 21 | } 22 | } 23 | --------------------------------------------------------------------------------