diff --git a/Tools/LambdaTestTool-v2/Amazon.Lambda.TestTool.sln b/Tools/LambdaTestTool-v2/Amazon.Lambda.TestTool.sln index 9a6282320..d47ab47e6 100644 --- a/Tools/LambdaTestTool-v2/Amazon.Lambda.TestTool.sln +++ b/Tools/LambdaTestTool-v2/Amazon.Lambda.TestTool.sln @@ -1,5 +1,8 @@  Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35527.113 +MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.TestTool", "src\Amazon.Lambda.TestTool\Amazon.Lambda.TestTool.csproj", "{97EE2E8A-D1F4-CB11-B664-B99B036E9F7B}" @@ -8,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.TestTool.UnitTests", "tests\Amazon.Lambda.TestTool.UnitTests\Amazon.Lambda.TestTool.UnitTests.csproj", "{80A4F809-28B7-61EC-6539-DF3C7A0733FD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.TestTool.IntegrationTests", "tests\Amazon.Lambda.TestTool.IntegrationTests\Amazon.Lambda.TestTool.IntegrationTests.csproj", "{5C1B3E1C-DFEA-425B-8ED2-BB43BAECC3CB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -22,6 +27,13 @@ Global {80A4F809-28B7-61EC-6539-DF3C7A0733FD}.Debug|Any CPU.Build.0 = Debug|Any CPU {80A4F809-28B7-61EC-6539-DF3C7A0733FD}.Release|Any CPU.ActiveCfg = Release|Any CPU {80A4F809-28B7-61EC-6539-DF3C7A0733FD}.Release|Any CPU.Build.0 = Release|Any CPU + {5C1B3E1C-DFEA-425B-8ED2-BB43BAECC3CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C1B3E1C-DFEA-425B-8ED2-BB43BAECC3CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C1B3E1C-DFEA-425B-8ED2-BB43BAECC3CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C1B3E1C-DFEA-425B-8ED2-BB43BAECC3CB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {97EE2E8A-D1F4-CB11-B664-B99B036E9F7B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} diff --git a/Tools/LambdaTestTool-v2/Amazon.Lambda.TestTool.slnx b/Tools/LambdaTestTool-v2/Amazon.Lambda.TestTool.slnx index 67bc9abc2..a213ed515 100644 --- a/Tools/LambdaTestTool-v2/Amazon.Lambda.TestTool.slnx +++ b/Tools/LambdaTestTool-v2/Amazon.Lambda.TestTool.slnx @@ -1,8 +1,9 @@ - + - + - + + \ No newline at end of file diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Amazon.Lambda.TestTool.csproj b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Amazon.Lambda.TestTool.csproj index 4b26ad9fd..6bc2b1452 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Amazon.Lambda.TestTool.csproj +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Amazon.Lambda.TestTool.csproj @@ -17,6 +17,7 @@ + diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ApiGatewayResponseExtensions.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ApiGatewayResponseExtensions.cs new file mode 100644 index 000000000..bed071a00 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ApiGatewayResponseExtensions.cs @@ -0,0 +1,270 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestTool.Models; +using Microsoft.Extensions.Primitives; +using System.Text; + +namespace Amazon.Lambda.TestTool.Extensions; + +/// +/// Provides extension methods for converting API Gateway responses to objects. +/// +public static class ApiGatewayResponseExtensions +{ + /// + /// Converts an to an . + /// + /// The API Gateway proxy response to convert. + /// The to use for the conversion. + /// The to use for the conversion. + /// An representing the API Gateway response. + public static void ToHttpResponse(this APIGatewayProxyResponse apiResponse, HttpContext httpContext, ApiGatewayEmulatorMode emulatorMode) + { + var response = httpContext.Response; + response.Clear(); + + SetResponseHeaders(response, apiResponse.Headers, emulatorMode, apiResponse.MultiValueHeaders); + SetResponseBody(response, apiResponse.Body, apiResponse.IsBase64Encoded); + SetContentTypeAndStatusCodeV1(response, apiResponse.Headers, apiResponse.MultiValueHeaders, apiResponse.StatusCode, emulatorMode); + } + + /// + /// Converts an to an . + /// + /// The API Gateway HTTP API v2 proxy response to convert. + /// The to use for the conversion. + public static void ToHttpResponse(this APIGatewayHttpApiV2ProxyResponse apiResponse, HttpContext httpContext) + { + var response = httpContext.Response; + response.Clear(); + + SetResponseHeaders(response, apiResponse.Headers, ApiGatewayEmulatorMode.HttpV2); + SetResponseBody(response, apiResponse.Body, apiResponse.IsBase64Encoded); + SetContentTypeAndStatusCodeV2(response, apiResponse.Headers, apiResponse.StatusCode); + } + + /// + /// Sets the response headers on the , including default API Gateway headers based on the emulator mode. + /// + /// The to set headers on. + /// The single-value headers to set. + /// The determining which default headers to include. + /// The multi-value headers to set. + private static void SetResponseHeaders(HttpResponse response, IDictionary? headers, ApiGatewayEmulatorMode emulatorMode, IDictionary>? multiValueHeaders = null) + { + // Add default API Gateway headers + var defaultHeaders = GetDefaultApiGatewayHeaders(emulatorMode); + foreach (var header in defaultHeaders) + { + response.Headers[header.Key] = header.Value; + } + + if (multiValueHeaders != null) + { + foreach (var header in multiValueHeaders) + { + response.Headers[header.Key] = new StringValues(header.Value.ToArray()); + } + } + + if (headers != null) + { + foreach (var header in headers) + { + if (!response.Headers.ContainsKey(header.Key)) + { + response.Headers[header.Key] = header.Value; + } + else + { + response.Headers.Append(header.Key, header.Value); + } + } + } + } + + /// + /// Generates default API Gateway headers based on the specified emulator mode. + /// + /// The determining which headers to generate. + /// A dictionary of default headers appropriate for the specified emulator mode. + private static Dictionary GetDefaultApiGatewayHeaders(ApiGatewayEmulatorMode emulatorMode) + { + var headers = new Dictionary + { + { "Date", DateTime.UtcNow.ToString("r") }, + { "Connection", "keep-alive" } + }; + + switch (emulatorMode) + { + case ApiGatewayEmulatorMode.Rest: + headers.Add("x-amzn-RequestId", Guid.NewGuid().ToString("D")); + headers.Add("x-amz-apigw-id", GenerateRequestId()); + headers.Add("X-Amzn-Trace-Id", GenerateTraceId()); + break; + case ApiGatewayEmulatorMode.HttpV1: + case ApiGatewayEmulatorMode.HttpV2: + headers.Add("Apigw-Requestid", GenerateRequestId()); + break; + } + + return headers; + } + + /// + /// Generates a random X-Amzn-Trace-Id for REST API mode. + /// + /// A string representing a random X-Amzn-Trace-Id in the format used by API Gateway for REST APIs. + /// + /// The generated trace ID includes: + /// - A root ID with a timestamp and two partial GUIDs + /// - A parent ID + /// - A sampling decision (always set to 0 in this implementation) + /// - A lineage identifier + /// + private static string GenerateTraceId() + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString("x"); + var guid1 = Guid.NewGuid().ToString("N"); + var guid2 = Guid.NewGuid().ToString("N"); + return $"Root=1-{timestamp}-{guid1.Substring(0, 12)}{guid2.Substring(0, 12)};Parent={Guid.NewGuid().ToString("N").Substring(0, 16)};Sampled=0;Lineage=1:{Guid.NewGuid().ToString("N").Substring(0, 8)}:0"; + } + + /// + /// Generates a random API Gateway request ID for HTTP API v1 and v2. + /// + /// A string representing a random request ID in the format used by API Gateway for HTTP APIs. + /// + /// The generated ID is a 15-character string consisting of lowercase letters and numbers, followed by an equals sign. + /// + private static string GenerateRequestId() + { + return Guid.NewGuid().ToString("N").Substring(0, 8) + Guid.NewGuid().ToString("N").Substring(0, 7) + "="; + } + + /// + /// Sets the response body on the . + /// + /// The to set the body on. + /// The body content. + /// Whether the body is Base64 encoded. + private static void SetResponseBody(HttpResponse response, string? body, bool isBase64Encoded) + { + if (!string.IsNullOrEmpty(body)) + { + byte[] bodyBytes; + if (isBase64Encoded) + { + bodyBytes = Convert.FromBase64String(body); + } + else + { + bodyBytes = Encoding.UTF8.GetBytes(body); + } + + response.Body = new MemoryStream(bodyBytes); + response.ContentLength = bodyBytes.Length; + } + } + + /// + /// Sets the content type and status code for API Gateway v1 responses. + /// + /// The to set the content type and status code on. + /// The single-value headers. + /// The multi-value headers. + /// The status code to set. + /// The being used. + private static void SetContentTypeAndStatusCodeV1(HttpResponse response, IDictionary? headers, IDictionary>? multiValueHeaders, int statusCode, ApiGatewayEmulatorMode emulatorMode) + { + string? contentType = null; + + if (headers != null && headers.TryGetValue("Content-Type", out var headerContentType)) + { + contentType = headerContentType; + } + else if (multiValueHeaders != null && multiValueHeaders.TryGetValue("Content-Type", out var multiValueContentType)) + { + contentType = multiValueContentType.FirstOrDefault(); + } + + if (contentType != null) + { + response.ContentType = contentType; + } + else + { + if (emulatorMode == ApiGatewayEmulatorMode.HttpV1) + { + response.ContentType = "text/plain; charset=utf-8"; + } + else if (emulatorMode == ApiGatewayEmulatorMode.Rest) + { + response.ContentType = "application/json"; + } + else + { + throw new ArgumentException("This function should only be called for ApiGatewayEmulatorMode.HttpV1 or ApiGatewayEmulatorMode.Rest"); + } + } + + if (statusCode != 0) + { + response.StatusCode = statusCode; + } + else + { + if (emulatorMode == ApiGatewayEmulatorMode.Rest) // rest api text for this message/error code is slightly different + { + response.StatusCode = 502; + response.ContentType = "application/json"; + var errorBytes = Encoding.UTF8.GetBytes("{\"message\": \"Internal server error\"}"); + response.Body = new MemoryStream(errorBytes); + response.ContentLength = errorBytes.Length; + response.Headers["x-amzn-ErrorType"] = "InternalServerErrorException"; + } + else + { + response.StatusCode = 500; + response.ContentType = "application/json"; + var errorBytes = Encoding.UTF8.GetBytes("{\"message\":\"Internal Server Error\"}"); + response.Body = new MemoryStream(errorBytes); + response.ContentLength = errorBytes.Length; + } + } + } + + /// + /// Sets the content type and status code for API Gateway v2 responses. + /// + /// The to set the content type and status code on. + /// The headers. + /// The status code to set. + private static void SetContentTypeAndStatusCodeV2(HttpResponse response, IDictionary? headers, int statusCode) + { + if (headers != null && headers.TryGetValue("Content-Type", out var contentType)) + { + response.ContentType = contentType; + } + else + { + response.ContentType = "text/plain; charset=utf-8"; // api gateway v2 defaults to this content type if none is provided + } + + if (statusCode != 0) + { + response.StatusCode = statusCode; + } + else + { + response.StatusCode = 500; + response.ContentType = "application/json"; + var errorBytes = Encoding.UTF8.GetBytes("{\"message\":\"Internal Server Error\"}"); + response.Body = new MemoryStream(errorBytes); + response.ContentLength = errorBytes.Length; + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Amazon.Lambda.TestTool.IntegrationTests.csproj b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Amazon.Lambda.TestTool.IntegrationTests.csproj new file mode 100644 index 000000000..6ff5e1821 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Amazon.Lambda.TestTool.IntegrationTests.csproj @@ -0,0 +1,41 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Amazon.Lambda.TestTool.IntegrationTests.sln b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Amazon.Lambda.TestTool.IntegrationTests.sln new file mode 100644 index 000000000..e9ae52b95 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Amazon.Lambda.TestTool.IntegrationTests.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Amazon.Lambda.TestTool.IntegrationTests", "Amazon.Lambda.TestTool.IntegrationTests.csproj", "{94C7903E-A21A-43EC-BB04-C9DA404F1C02}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {94C7903E-A21A-43EC-BB04-C9DA404F1C02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94C7903E-A21A-43EC-BB04-C9DA404F1C02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94C7903E-A21A-43EC-BB04-C9DA404F1C02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94C7903E-A21A-43EC-BB04-C9DA404F1C02}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {429CE21F-1692-4C50-A9E6-299AB413D027} + EndGlobalSection +EndGlobal diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayIntegrationTestCollection.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayIntegrationTestCollection.cs new file mode 100644 index 000000000..c7fd41d64 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayIntegrationTestCollection.cs @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.TestTool.IntegrationTests +{ + [CollectionDefinition("ApiGateway Integration Tests")] + public class ApiGatewayIntegrationTestCollection : ICollectionFixture + { + + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayIntegrationTestFixture.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayIntegrationTestFixture.cs new file mode 100644 index 000000000..b5b862793 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayIntegrationTestFixture.cs @@ -0,0 +1,132 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.CloudFormation; +using Amazon.APIGateway; +using Amazon.ApiGatewayV2; +using Amazon.Lambda.TestTool.IntegrationTests.Helpers; +using System.Reflection; + +namespace Amazon.Lambda.TestTool.IntegrationTests +{ + public class ApiGatewayIntegrationTestFixture : IAsyncLifetime + { + public CloudFormationHelper CloudFormationHelper { get; private set; } + public ApiGatewayHelper ApiGatewayHelper { get; private set; } + public ApiGatewayTestHelper ApiGatewayTestHelper { get; private set; } + + public string StackName { get; private set; } + public string RestApiId { get; private set; } + public string HttpApiV1Id { get; private set; } + public string HttpApiV2Id { get; private set; } + public string ReturnRawRequestBodyV2Id { get; private set; } + public string RestApiUrl { get; private set; } + public string HttpApiV1Url { get; private set; } + public string HttpApiV2Url { get; private set; } + public string ReturnRawRequestBodyHttpApiV2Url { get; private set; } + public string BinaryMediaRestApiId { get; private set; } + public string BinaryMediaRestApiUrl { get; private set; } + + + public ApiGatewayIntegrationTestFixture() + { + var regionEndpoint = RegionEndpoint.USWest2; + CloudFormationHelper = new CloudFormationHelper(new AmazonCloudFormationClient(regionEndpoint)); + ApiGatewayHelper = new ApiGatewayHelper( + new AmazonAPIGatewayClient(regionEndpoint), + new AmazonApiGatewayV2Client(regionEndpoint) + ); + ApiGatewayTestHelper = new ApiGatewayTestHelper(); + StackName = string.Empty; + RestApiId = string.Empty; + HttpApiV1Id = string.Empty; + HttpApiV2Id = string.Empty; + ReturnRawRequestBodyV2Id = string.Empty; + RestApiUrl = string.Empty; + HttpApiV1Url = string.Empty; + HttpApiV2Url = string.Empty; + ReturnRawRequestBodyHttpApiV2Url = string.Empty; + BinaryMediaRestApiId = string.Empty; + BinaryMediaRestApiUrl = string.Empty; + } + + public async Task InitializeAsync() + { + StackName = $"Test-{Guid.NewGuid().ToString("N").Substring(0, 5)}"; + + string templateBody = ReadCloudFormationTemplate("cloudformation-template-apigateway.yaml"); + await CloudFormationHelper.CreateStackAsync(StackName, templateBody); + + await WaitForStackCreationComplete(); + await RetrieveStackOutputs(); + await WaitForApisAvailability(); + } + + private string ReadCloudFormationTemplate(string fileName) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = $"{assembly.GetName().Name}.{fileName}"; + using (var stream = assembly.GetManifestResourceStream(resourceName)) + { + if (stream == null) + { + throw new FileNotFoundException($"CloudFormation template file '{fileName}' not found in assembly resources."); + } + + using (StreamReader reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } + } + + private async Task WaitForStackCreationComplete() + { + while (true) + { + var status = await CloudFormationHelper.GetStackStatusAsync(StackName); + if (status == StackStatus.CREATE_COMPLETE) + { + break; + } + if (status.ToString().EndsWith("FAILED") || status == StackStatus.DELETE_COMPLETE) + { + throw new Exception($"Stack creation failed. Status: {status}"); + } + await Task.Delay(10000); + } + } + + private async Task RetrieveStackOutputs() + { + RestApiId = await CloudFormationHelper.GetOutputValueAsync(StackName, "RestApiId"); + RestApiUrl = await CloudFormationHelper.GetOutputValueAsync(StackName, "RestApiUrl"); + + HttpApiV1Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "HttpApiV1Id"); + HttpApiV1Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "HttpApiV1Url"); + + HttpApiV2Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "HttpApiV2Id"); + HttpApiV2Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "HttpApiV2Url"); + + ReturnRawRequestBodyV2Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawRequestBodyHttpApiId"); + ReturnRawRequestBodyHttpApiV2Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawRequestBodyHttpApiUrl"); + + BinaryMediaRestApiId = await CloudFormationHelper.GetOutputValueAsync(StackName, "BinaryMediaRestApiId"); + BinaryMediaRestApiUrl = await CloudFormationHelper.GetOutputValueAsync(StackName, "BinaryMediaRestApiUrl"); + } + + private async Task WaitForApisAvailability() + { + await ApiGatewayHelper.WaitForApiAvailability(RestApiId, RestApiUrl, false); + await ApiGatewayHelper.WaitForApiAvailability(HttpApiV1Id, HttpApiV1Url, true); + await ApiGatewayHelper.WaitForApiAvailability(HttpApiV2Id, HttpApiV2Url, true); + await ApiGatewayHelper.WaitForApiAvailability(ReturnRawRequestBodyV2Id, ReturnRawRequestBodyHttpApiV2Url, true); + await ApiGatewayHelper.WaitForApiAvailability(BinaryMediaRestApiId, BinaryMediaRestApiUrl, false); + } + + public async Task DisposeAsync() + { + await CloudFormationHelper.DeleteStackAsync(StackName); + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsAdditionalTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsAdditionalTests.cs new file mode 100644 index 000000000..6bfa15ba6 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsAdditionalTests.cs @@ -0,0 +1,84 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.APIGatewayEvents; +using Microsoft.AspNetCore.Http; +using System.Text.Json; +using Amazon.Lambda.TestTool.Extensions; +using Amazon.Lambda.TestTool.Models; +using System.Text; + +namespace Amazon.Lambda.TestTool.IntegrationTests +{ + [Collection("ApiGateway Integration Tests")] + public class ApiGatewayResponseExtensionsAdditionalTests + { + private readonly ApiGatewayIntegrationTestFixture _fixture; + private readonly HttpClient _httpClient; + + public ApiGatewayResponseExtensionsAdditionalTests(ApiGatewayIntegrationTestFixture fixture) + { + _fixture = fixture; + _httpClient = new HttpClient(); + } + + //[Fact] + //public async Task V2_SetsContentTypeApplicationJsonWhenNoStatusProvided() + //{ + // var testResponse = new APIGatewayHttpApiV2ProxyResponse + // { + // Body = "Hello from lambda" + // }; + + // var httpContext = new DefaultHttpContext(); + // testResponse.ToHttpResponse(httpContext); + // var actualResponse = await _httpClient.PostAsync(_fixture.ReturnRawRequestBodyHttpApiV2Url, new StringContent("Hello from lambda")); + + // await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpContext.Response); + // Assert.Equal(200, (int)actualResponse.StatusCode); + // Assert.Equal("application/json", actualResponse.Content.Headers.ContentType?.ToString()); + // var content = await actualResponse.Content.ReadAsStringAsync(); + // Assert.Equal("Hello from lambda", content); + //} + + + [Fact] + public async Task ToHttpResponse_RestAPIGatewayV1DecodesBase64() + { + var testResponse = new APIGatewayProxyResponse + { + StatusCode = 200, + Body = Convert.ToBase64String(Encoding.UTF8.GetBytes("test")), + IsBase64Encoded = true + }; + + var httpContext = new DefaultHttpContext(); + testResponse.ToHttpResponse(httpContext, ApiGatewayEmulatorMode.Rest); + var actualResponse = await _httpClient.PostAsync(_fixture.BinaryMediaRestApiUrl, new StringContent(JsonSerializer.Serialize(testResponse))); + await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpContext.Response); + Assert.Equal(200, (int)actualResponse.StatusCode); + var content = await actualResponse.Content.ReadAsStringAsync(); + Assert.Equal("test", content); + } + + [Fact] + public async Task ToHttpResponse_HttpV1APIGatewayV1DecodesBase64() + { + var testResponse = new APIGatewayProxyResponse + { + StatusCode = 200, + Body = Convert.ToBase64String(Encoding.UTF8.GetBytes("test")), + IsBase64Encoded = true + }; + + var httpContext = new DefaultHttpContext(); + testResponse.ToHttpResponse(httpContext, ApiGatewayEmulatorMode.HttpV1); + var actualResponse = await _httpClient.PostAsync(_fixture.HttpApiV1Url, new StringContent(JsonSerializer.Serialize(testResponse))); + + await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpContext.Response); + Assert.Equal(200, (int)actualResponse.StatusCode); + var content = await actualResponse.Content.ReadAsStringAsync(); + Assert.Equal("test", content); + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsTests.cs new file mode 100644 index 000000000..6b3892ab7 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsTests.cs @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestTool.IntegrationTests.Helpers; +using Amazon.Lambda.TestTool.Models; +using static ApiGatewayResponseTestCases; + +namespace Amazon.Lambda.TestTool.IntegrationTests +{ + [Collection("ApiGateway Integration Tests")] + public class ApiGatewayResponseExtensionsTests + { + private readonly ApiGatewayIntegrationTestFixture _fixture; + + public ApiGatewayResponseExtensionsTests(ApiGatewayIntegrationTestFixture fixture) + { + _fixture = fixture; + } + + [Theory] + [MemberData(nameof(ApiGatewayResponseTestCases.V1TestCases), MemberType = typeof(ApiGatewayResponseTestCases))] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public async Task IntegrationTest_APIGatewayV1_REST(string testName, ApiGatewayResponseTestCase testCase) + { + await RetryHelper.RetryOperation(async () => + { + await RunV1Test(testCase, _fixture.RestApiUrl, ApiGatewayEmulatorMode.Rest); + return true; + }); + } + + [Theory] + [MemberData(nameof(ApiGatewayResponseTestCases.V1TestCases), MemberType = typeof(ApiGatewayResponseTestCases))] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public async Task IntegrationTest_APIGatewayV1_HTTP(string testName, ApiGatewayResponseTestCase testCase) + { + await RetryHelper.RetryOperation(async () => + { + await RunV1Test(testCase, _fixture.HttpApiV1Url, ApiGatewayEmulatorMode.HttpV1); + return true; + }); + } + + [Theory] + [MemberData(nameof(ApiGatewayResponseTestCases.V2TestCases), MemberType = typeof(ApiGatewayResponseTestCases))] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public async Task IntegrationTest_APIGatewayV2(string testName, ApiGatewayResponseTestCase testCase) + { + await RetryHelper.RetryOperation(async () => + { + var testResponse = testCase.Response as APIGatewayHttpApiV2ProxyResponse; + Assert.NotNull(testResponse); + var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(testResponse, _fixture.HttpApiV2Url); + await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse); + await testCase.IntegrationAssertions(actualResponse, ApiGatewayEmulatorMode.HttpV2); + return true; + }); + } + + private async Task RunV1Test(ApiGatewayResponseTestCase testCase, string apiUrl, ApiGatewayEmulatorMode emulatorMode) + { + var testResponse = testCase.Response as APIGatewayProxyResponse; + Assert.NotNull(testResponse); + var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(testResponse, apiUrl, emulatorMode); + await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse); + await testCase.IntegrationAssertions(actualResponse, emulatorMode); + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/ApiGatewayHelper.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/ApiGatewayHelper.cs new file mode 100644 index 000000000..11c6cd735 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/ApiGatewayHelper.cs @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.APIGateway; +using Amazon.ApiGatewayV2; +using Amazon.APIGateway.Model; +using Amazon.ApiGatewayV2.Model; +using System.Net; + +namespace Amazon.Lambda.TestTool.IntegrationTests.Helpers +{ + public class ApiGatewayHelper + { + private readonly IAmazonAPIGateway _apiGatewayV1Client; + private readonly IAmazonApiGatewayV2 _apiGatewayV2Client; + private readonly HttpClient _httpClient; + + public ApiGatewayHelper(IAmazonAPIGateway apiGatewayV1Client, IAmazonApiGatewayV2 apiGatewayV2Client) + { + _apiGatewayV1Client = apiGatewayV1Client; + _apiGatewayV2Client = apiGatewayV2Client; + _httpClient = new HttpClient(); + } + + public async Task WaitForApiAvailability(string apiId, string apiUrl, bool isHttpApi, int maxWaitTimeSeconds = 30) + { + var startTime = DateTime.UtcNow; + while ((DateTime.UtcNow - startTime).TotalSeconds < maxWaitTimeSeconds) + { + try + { + // Check if the API exists + if (isHttpApi) + { + var response = await _apiGatewayV2Client.GetApiAsync(new GetApiRequest { ApiId = apiId }); + if (response.ApiEndpoint == null) continue; + } + else + { + var response = await _apiGatewayV1Client.GetRestApiAsync(new GetRestApiRequest { RestApiId = apiId }); + if (response.Id == null) continue; + } + + // Try to make a request to the API + using (var httpClient = new HttpClient()) + { + var response = await httpClient.PostAsync(apiUrl, new StringContent("{}")); + + // Check if we get a response, even if it's an error + if (response.StatusCode != HttpStatusCode.NotFound) + { + return; // API is available and responding + } + } + } + catch (Amazon.ApiGatewayV2.Model.NotFoundException) when (isHttpApi) + { + // HTTP API not found yet, continue waiting + } + catch (Amazon.APIGateway.Model.NotFoundException) when (!isHttpApi) + { + // REST API not found yet, continue waiting + } + catch (Exception ex) + { + // Log unexpected exceptions + Console.WriteLine($"Unexpected error while checking API availability: {ex.Message}"); + } + await Task.Delay(1000); // Wait for 1 second before checking again + } + throw new TimeoutException($"API {apiId} did not become available within {maxWaitTimeSeconds} seconds"); + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/ApiGatewayTestHelper.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/ApiGatewayTestHelper.cs new file mode 100644 index 000000000..24f736961 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/ApiGatewayTestHelper.cs @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestTool.Extensions; +using Amazon.Lambda.TestTool.Models; +using Microsoft.AspNetCore.Http; + +namespace Amazon.Lambda.TestTool.IntegrationTests.Helpers +{ + public class ApiGatewayTestHelper + { + private readonly HttpClient _httpClient; + + public ApiGatewayTestHelper() + { + _httpClient = new HttpClient(); + } + public async Task<(HttpResponseMessage actualResponse, HttpResponse httpTestResponse)> ExecuteTestRequest(APIGatewayProxyResponse testResponse, string apiUrl, ApiGatewayEmulatorMode emulatorMode) + { + var httpContext = new DefaultHttpContext(); + testResponse.ToHttpResponse(httpContext, emulatorMode); + var serialized = JsonSerializer.Serialize(testResponse); + var actualResponse = await _httpClient.PostAsync(apiUrl, new StringContent(serialized)); + return (actualResponse, httpContext.Response); + } + + public async Task<(HttpResponseMessage actualResponse, HttpResponse httpTestResponse)> ExecuteTestRequest(APIGatewayHttpApiV2ProxyResponse testResponse, string apiUrl) + { + var httpContext = new DefaultHttpContext(); + testResponse.ToHttpResponse(httpContext); + var serialized = JsonSerializer.Serialize(testResponse); + var actualResponse = await _httpClient.PostAsync(apiUrl, new StringContent(serialized)); + return (actualResponse, httpContext.Response); + } + + public async Task AssertResponsesEqual(HttpResponseMessage actualResponse, HttpResponse httpTestResponse) + { + + var expectedContent = await new StreamReader(httpTestResponse.Body).ReadToEndAsync(); + httpTestResponse.Body.Seek(0, SeekOrigin.Begin); + var actualContent = await actualResponse.Content.ReadAsStringAsync(); + + Assert.Equal(expectedContent, actualContent); + + Assert.Equal(httpTestResponse.StatusCode, (int)actualResponse.StatusCode); + + // ignore these because they will vary in the real world. we will check manually in other test cases that these are set + var headersToIgnore = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Date", + "Apigw-Requestid", + "X-Amzn-Trace-Id", + "x-amzn-RequestId", + "x-amz-apigw-id", + "X-Cache", + "Via", + "X-Amz-Cf-Pop", + "X-Amz-Cf-Id" + }; + + foreach (var header in httpTestResponse.Headers) + { + if (headersToIgnore.Contains(header.Key)) continue; + Assert.True(actualResponse.Headers.TryGetValues(header.Key, out var actualValues) || + actualResponse.Content.Headers.TryGetValues(header.Key, out actualValues), + $"Header '{header.Key}={string.Join(", ", header.Value.ToArray())}' not found in actual response"); + + var sortedExpectedValues = header.Value.OrderBy(v => v).ToArray(); + var sortedActualValues = actualValues.OrderBy(v => v).ToArray(); + Assert.Equal(sortedExpectedValues, sortedActualValues); + } + + foreach (var header in actualResponse.Headers.Concat(actualResponse.Content.Headers)) + { + if (headersToIgnore.Contains(header.Key)) continue; + + Assert.True(httpTestResponse.Headers.ContainsKey(header.Key), + $"Header '{header.Key}={string.Join(", ", header.Value)}' not found in test response"); + + var sortedExpectedValues = httpTestResponse.Headers[header.Key].OrderBy(v => v).ToArray(); + var sortedActualValues = header.Value.OrderBy(v => v).ToArray(); + Assert.Equal(sortedExpectedValues, sortedActualValues); + } + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/CloudFormationHelper.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/CloudFormationHelper.cs new file mode 100644 index 000000000..cf440cf35 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/CloudFormationHelper.cs @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.CloudFormation; +using Amazon.CloudFormation.Model; + +namespace Amazon.Lambda.TestTool.IntegrationTests.Helpers +{ + public class CloudFormationHelper + { + private readonly IAmazonCloudFormation _cloudFormationClient; + + public CloudFormationHelper(IAmazonCloudFormation cloudFormationClient) + { + _cloudFormationClient = cloudFormationClient; + } + + public async Task CreateStackAsync(string stackName, string templateBody) + { + var response = await _cloudFormationClient.CreateStackAsync(new CreateStackRequest + { + StackName = stackName, + TemplateBody = templateBody, + Capabilities = new List { "CAPABILITY_IAM" } + }); + return response.StackId; + } + + public async Task GetStackStatusAsync(string stackName) + { + var response = await _cloudFormationClient.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }); + return response.Stacks[0].StackStatus; + } + + public async Task DeleteStackAsync(string stackName) + { + await _cloudFormationClient.DeleteStackAsync(new DeleteStackRequest { StackName = stackName }); + } + + public async Task GetOutputValueAsync(string stackName, string outputKey) + { + var response = await _cloudFormationClient.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }); + return response.Stacks[0].Outputs.FirstOrDefault(o => o.OutputKey == outputKey)?.OutputValue ?? string.Empty; + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/RetryHelper.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/RetryHelper.cs new file mode 100644 index 000000000..f6e5e18e4 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/RetryHelper.cs @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.TestTool.IntegrationTests.Helpers +{ + public class RetryHelper + { + public static async Task RetryOperation(Func> operation, int maxRetries = 3, int delayMilliseconds = 20000) + { + for (int i = 0; i < maxRetries; i++) + { + try + { + return await operation(); + } + catch (Exception ex) when (i < maxRetries - 1) + { + Console.WriteLine($"Attempt {i + 1} failed: {ex.Message}. Retrying in {delayMilliseconds}ms..."); + await Task.Delay(delayMilliseconds); + } + } + + // If we've exhausted all retries, run one last time and let any exception propagate + return await operation(); + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/cloudformation-template-apigateway.yaml b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/cloudformation-template-apigateway.yaml new file mode 100644 index 000000000..5ae0623ec --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/cloudformation-template-apigateway.yaml @@ -0,0 +1,297 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'CloudFormation template for API Gateway and Lambda integration tests' + +Resources: + + TestLambdaFunction: + Type: 'AWS::Lambda::Function' + Properties: + FunctionName: !Sub '${AWS::StackName}-TestFunction' + Handler: index.handler + Role: !GetAtt LambdaExecutionRole.Arn + Code: + ZipFile: | + exports.handler = async (event) => { + return JSON.parse(event.body); + }; + Runtime: nodejs20.x + + BinaryLambdaFunction: + Type: 'AWS::Lambda::Function' + Properties: + FunctionName: !Sub '${AWS::StackName}-BinaryFunction' + Handler: index.handler + Role: !GetAtt LambdaExecutionRole.Arn + Code: + ZipFile: | + exports.handler = async (event) => { + const decodedBody = atob(event.body); + const parsedBody = JSON.parse(decodedBody.toString('utf8')); + return parsedBody; + }; + Runtime: nodejs20.x + + ReturnRawRequestBodyLambdaFunction: + Type: 'AWS::Lambda::Function' + Properties: + FunctionName: !Sub '${AWS::StackName}-ReturnRawRequestBodyFunction' + Handler: index.handler + Role: !GetAtt LambdaExecutionRole.Arn + Code: + ZipFile: | + exports.handler = async (event, context, callback) => { + console.log(event); + callback(null, event.body); + }; + Runtime: nodejs20.x + + LambdaExecutionRole: + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: 'sts:AssumeRole' + ManagedPolicyArns: + - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + + RestApi: + Type: 'AWS::ApiGateway::RestApi' + Properties: + Name: !Sub '${AWS::StackName}-RestAPI' + + RestApiResource: + Type: 'AWS::ApiGateway::Resource' + Properties: + ParentId: !GetAtt RestApi.RootResourceId + PathPart: 'test' + RestApiId: !Ref RestApi + + RestApiMethod: + Type: 'AWS::ApiGateway::Method' + Properties: + HttpMethod: POST + ResourceId: !Ref RestApiResource + RestApiId: !Ref RestApi + AuthorizationType: NONE + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TestLambdaFunction.Arn}/invocations' + + RestApiDeployment: + Type: 'AWS::ApiGateway::Deployment' + DependsOn: RestApiMethod + Properties: + RestApiId: !Ref RestApi + StageName: 'test' + + HttpApiV1: + Type: 'AWS::ApiGatewayV2::Api' + Properties: + Name: !Sub '${AWS::StackName}-HttpAPIv1' + ProtocolType: HTTP + + HttpApiV1Integration: + Type: 'AWS::ApiGatewayV2::Integration' + Properties: + ApiId: !Ref HttpApiV1 + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt TestLambdaFunction.Arn + PayloadFormatVersion: '1.0' + + HttpApiV1Route: + Type: 'AWS::ApiGatewayV2::Route' + Properties: + ApiId: !Ref HttpApiV1 + RouteKey: 'POST /test' + Target: !Join + - / + - - integrations + - !Ref HttpApiV1Integration + + HttpApiV1Stage: + Type: 'AWS::ApiGatewayV2::Stage' + Properties: + ApiId: !Ref HttpApiV1 + StageName: '$default' + AutoDeploy: true + + HttpApiV2: + Type: 'AWS::ApiGatewayV2::Api' + Properties: + Name: !Sub '${AWS::StackName}-HttpAPIv2' + ProtocolType: HTTP + + HttpApiV2Integration: + Type: 'AWS::ApiGatewayV2::Integration' + Properties: + ApiId: !Ref HttpApiV2 + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt TestLambdaFunction.Arn + PayloadFormatVersion: '2.0' + + HttpApiV2Route: + Type: 'AWS::ApiGatewayV2::Route' + Properties: + ApiId: !Ref HttpApiV2 + RouteKey: 'POST /test' + Target: !Join + - / + - - integrations + - !Ref HttpApiV2Integration + + HttpApiV2Stage: + Type: 'AWS::ApiGatewayV2::Stage' + Properties: + ApiId: !Ref HttpApiV2 + StageName: '$default' + AutoDeploy: true + + ReturnRawRequestBodyHttpApi: + Type: 'AWS::ApiGatewayV2::Api' + Properties: + Name: !Sub '${AWS::StackName}-ReturnRawRequestBodyHttpAPI' + ProtocolType: HTTP + + ReturnRawRequestBodyHttpApiIntegration: + Type: 'AWS::ApiGatewayV2::Integration' + Properties: + ApiId: !Ref ReturnRawRequestBodyHttpApi + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt ReturnRawRequestBodyLambdaFunction.Arn + PayloadFormatVersion: '2.0' + + ReturnRawRequestBodyHttpApiRoute: + Type: 'AWS::ApiGatewayV2::Route' + Properties: + ApiId: !Ref ReturnRawRequestBodyHttpApi + RouteKey: 'POST /' + Target: !Join + - / + - - integrations + - !Ref ReturnRawRequestBodyHttpApiIntegration + + ReturnRawRequestBodyHttpApiStage: + Type: 'AWS::ApiGatewayV2::Stage' + Properties: + ApiId: !Ref ReturnRawRequestBodyHttpApi + StageName: '$default' + AutoDeploy: true + + LambdaPermissionRestApi: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt TestLambdaFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/*' + + LambdaPermissionHttpApiV1: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt TestLambdaFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApiV1}/*' + + LambdaPermissionHttpApiV2: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt TestLambdaFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApiV2}/*' + + LambdaPermissionReturnRawRequestBodyHttpApi: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt ReturnRawRequestBodyLambdaFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ReturnRawRequestBodyHttpApi}/*' + + BinaryMediaRestApi: + Type: 'AWS::ApiGateway::RestApi' + Properties: + Name: !Sub '${AWS::StackName}-BinaryMediaRestAPI' + BinaryMediaTypes: + - '*/*' + + BinaryMediaRestApiResource: + Type: 'AWS::ApiGateway::Resource' + Properties: + ParentId: !GetAtt BinaryMediaRestApi.RootResourceId + PathPart: 'test' + RestApiId: !Ref BinaryMediaRestApi + + BinaryMediaRestApiMethod: + Type: 'AWS::ApiGateway::Method' + Properties: + HttpMethod: POST + ResourceId: !Ref BinaryMediaRestApiResource + RestApiId: !Ref BinaryMediaRestApi + AuthorizationType: NONE + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${BinaryLambdaFunction.Arn}/invocations' + + BinaryMediaRestApiDeployment: + Type: 'AWS::ApiGateway::Deployment' + DependsOn: BinaryMediaRestApiMethod + Properties: + RestApiId: !Ref BinaryMediaRestApi + StageName: 'test' + + LambdaPermissionBinaryMediaRestApi: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt BinaryLambdaFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${BinaryMediaRestApi}/*' + +Outputs: + RestApiId: + Description: 'ID of the REST API' + Value: !Ref RestApi + + RestApiUrl: + Description: 'URL of the REST API' + Value: !Sub 'https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/test/test' + + HttpApiV1Id: + Description: 'ID of the HTTP API v1' + Value: !Ref HttpApiV1 + + HttpApiV1Url: + Description: 'URL of the HTTP API v1' + Value: !Sub 'https://${HttpApiV1}.execute-api.${AWS::Region}.amazonaws.com/test' + + HttpApiV2Id: + Description: 'ID of the HTTP API v2' + Value: !Ref HttpApiV2 + + HttpApiV2Url: + Description: 'URL of the HTTP API v2' + Value: !Sub 'https://${HttpApiV2}.execute-api.${AWS::Region}.amazonaws.com/test' + + ReturnRawRequestBodyHttpApiId: + Description: 'ID of the JSON Inference HTTP API' + Value: !Ref ReturnRawRequestBodyHttpApi + + ReturnRawRequestBodyHttpApiUrl: + Description: 'URL of the JSON Inference HTTP API' + Value: !Sub 'https://${ReturnRawRequestBodyHttpApi}.execute-api.${AWS::Region}.amazonaws.com/' + + BinaryMediaRestApiId: + Description: 'ID of the Binary Media REST API' + Value: !Ref BinaryMediaRestApi + + BinaryMediaRestApiUrl: + Description: 'URL of the Binary Media REST API' + Value: !Sub 'https://${BinaryMediaRestApi}.execute-api.${AWS::Region}.amazonaws.com/test/test' diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/ApiGatewayResponseExtensionsTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/ApiGatewayResponseExtensionsTests.cs new file mode 100644 index 000000000..2a78394e1 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/ApiGatewayResponseExtensionsTests.cs @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestTool.Extensions; +using Amazon.Lambda.TestTool.Models; +using Microsoft.AspNetCore.Http; +using static ApiGatewayResponseTestCases; + +namespace Amazon.Lambda.TestTool.UnitTests.Extensions +{ + public class ApiGatewayResponseExtensionsUnitTests + { + [Theory] + [MemberData(nameof(ApiGatewayResponseTestCases.V1TestCases), MemberType = typeof(ApiGatewayResponseTestCases))] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public void ToHttpResponse_ConvertsCorrectlyV1(string testName, ApiGatewayResponseTestCase testCase) + { + // Arrange + var httpContext = new DefaultHttpContext(); + ((APIGatewayProxyResponse)testCase.Response).ToHttpResponse(httpContext, ApiGatewayEmulatorMode.HttpV1); + + // Assert + testCase.Assertions(httpContext.Response, ApiGatewayEmulatorMode.HttpV1); + } + + [Theory] + [MemberData(nameof(ApiGatewayResponseTestCases.V1TestCases), MemberType = typeof(ApiGatewayResponseTestCases))] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public void ToHttpResponse_ConvertsCorrectlyV1Rest(string testName, ApiGatewayResponseTestCase testCase) + { + // Arrange + var httpContext = new DefaultHttpContext(); + ((APIGatewayProxyResponse)testCase.Response).ToHttpResponse(httpContext, ApiGatewayEmulatorMode.Rest); + + // Assert + testCase.Assertions(httpContext.Response, ApiGatewayEmulatorMode.Rest); + } + + [Theory] + [MemberData(nameof(ApiGatewayResponseTestCases.V2TestCases), MemberType = typeof(ApiGatewayResponseTestCases))] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public void ToHttpResponse_ConvertsCorrectlyV2(string testName, ApiGatewayResponseTestCase testCase) + { + // Arrange + var httpContext = new DefaultHttpContext(); + ((APIGatewayHttpApiV2ProxyResponse)testCase.Response).ToHttpResponse(httpContext); + + // Assert + testCase.Assertions(httpContext.Response, ApiGatewayEmulatorMode.HttpV2); + } + + [Theory] + [InlineData(ApiGatewayEmulatorMode.HttpV1)] + [InlineData(ApiGatewayEmulatorMode.Rest)] + public void ToHttpResponse_APIGatewayV1DecodesBase64(ApiGatewayEmulatorMode emulatorMode) + { + var apiResponse = new APIGatewayProxyResponse + { + StatusCode = 200, + Body = Convert.ToBase64String(Encoding.UTF8.GetBytes("test")), + IsBase64Encoded = true + }; + + var httpContext = new DefaultHttpContext(); + apiResponse.ToHttpResponse(httpContext, emulatorMode); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(httpContext.Response.Body); + var bodyContent = reader.ReadToEnd(); + Assert.Equal("test", bodyContent); + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/ApiGatewayResponseTestCases.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/ApiGatewayResponseTestCases.cs new file mode 100644 index 000000000..1769d5c93 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/ApiGatewayResponseTestCases.cs @@ -0,0 +1,731 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; +using System.Text.Json; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestTool.Models; +using Microsoft.AspNetCore.Http; + +public static class ApiGatewayResponseTestCases +{ + public static IEnumerable V1TestCases() + { + // V1 (APIGatewayProxyResponse) test cases + yield return new object[] + { + "V1_SimpleJsonResponse", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + StatusCode = 200, + Body = JsonSerializer.Serialize(new { message = "Hello, World!" }), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }, + Assertions = (response, emulatormode) => + { + Assert.Equal(200, response.StatusCode); + Assert.Equal("application/json", response.ContentType); + Assert.Equal("{\"message\":\"Hello, World!\"}", ReadResponseBody(response)); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("{\"message\":\"Hello, World!\"}", content); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V1_SetsCorrectStatusCode", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + StatusCode = 201, + Body = "{\"message\":\"Created\"}" + }, + Assertions = (response, emulatormode) => + { + Assert.Equal(201, response.StatusCode); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal(201, (int)response.StatusCode); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V1_SetsHeaders", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + StatusCode = 200, + Headers = new Dictionary + { + { "Content-Type", "application/json" }, + { "X-Custom-Header", "CustomValue" } + }, + Body = "{\"message\":\"With Headers\"}" + }, + Assertions = (response, emulatormode) => + { + Assert.Equal("application/json", response.Headers["Content-Type"]); + Assert.Equal("CustomValue", response.Headers["X-Custom-Header"]); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + Assert.True(response.Headers.Contains("X-Custom-Header")); + Assert.Equal("CustomValue", response.Headers.GetValues("X-Custom-Header").First()); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V1_SetsMultiValueHeaders", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + StatusCode = 200, + MultiValueHeaders = new Dictionary> + { + { "X-Multi-Header", new List { "Value1", "Value2" } } + }, + Body = "{\"message\":\"With MultiValueHeaders\"}" + }, + Assertions = (response, emulatormode) => + { + Assert.Equal(new[] { "Value1", "Value2" }, response.Headers["X-Multi-Header"]); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.True(response.Headers.Contains("X-Multi-Header")); + var multiHeaderValues = response.Headers.GetValues("X-Multi-Header").ToList(); + Assert.Contains("Value1", multiHeaderValues); + Assert.Contains("Value2", multiHeaderValues); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V1_SetsBodyNonBase64", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + StatusCode = 200, + Body = "{\"message\":\"Hello, World!\"}", + IsBase64Encoded = false + }, + Assertions = (response, emulatormode) => + { + Assert.Equal("{\"message\":\"Hello, World!\"}", ReadResponseBody(response)); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("{\"message\":\"Hello, World!\"}", content); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V1_DefaultsToCorrectContentTYpe", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + StatusCode = 200, + Body = "Hello, World!" + }, + Assertions = (response, emulatorMode) => + { + if (emulatorMode == ApiGatewayEmulatorMode.HttpV1) + { + Assert.Equal("text/plain; charset=utf-8", response.ContentType); + } else + { + Assert.Equal("application/json", response.ContentType); + } + }, + IntegrationAssertions = async (response, emulatorMode) => + { + if (emulatorMode == ApiGatewayEmulatorMode.HttpV1) + { + Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType?.ToString()); + } + else + { + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + } + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V1_HandlesHeadersCorrectly", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + Headers = new Dictionary + { + { "Content-Type", "application/json" }, + { "myheader", "test,other" }, + { "anotherheader", "secondvalue" } + }, + MultiValueHeaders = new Dictionary> + { + { "headername", new List { "headervalue", "headervalue2" } } + }, + Body = "{\"message\":\"With Multiple Headers\"}", + StatusCode = 200 + + }, + Assertions = (response, emulatormode) => + { + Assert.Equal("application/json", response.Headers["Content-Type"]); + Assert.Equal("test,other", response.Headers["myheader"]); + Assert.Equal("secondvalue", response.Headers["anotherheader"]); + Assert.Equal(new[] { "headervalue", "headervalue2" }, response.Headers["headername"]); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + Assert.Equal("test,other", response.Headers.GetValues("myheader").First()); + Assert.Equal("secondvalue", response.Headers.GetValues("anotherheader").First()); + var headernameValues = response.Headers.GetValues("headername").ToList(); + Assert.Contains("headervalue", headernameValues); + Assert.Contains("headervalue2", headernameValues); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V1_CombinesSingleAndMultiValueHeaders", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + Headers = new Dictionary + { + { "Content-Type", "application/json" }, + { "X-Custom-Header", "single-value" }, + { "Combined-Header", "single-value" } + }, + MultiValueHeaders = new Dictionary> + { + { "X-Multi-Header", new List { "multi-value1", "multi-value2" } }, + { "Combined-Header", new List { "multi-value1", "multi-value2" } } + }, + Body = "{\"message\":\"With Combined Headers\"}", + StatusCode = 200 + }, + Assertions = (response, emulatormode) => + { + Assert.Equal("application/json", response.Headers["Content-Type"]); + Assert.Equal("single-value", response.Headers["X-Custom-Header"]); + Assert.Equal(new[] { "multi-value1", "multi-value2" }, response.Headers["X-Multi-Header"]); + Assert.Equal(new[] { "multi-value1", "multi-value2", "single-value" }, response.Headers["Combined-Header"]); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + Assert.Equal("single-value", response.Headers.GetValues("X-Custom-Header").First()); + var multiHeaderValues = response.Headers.GetValues("X-Multi-Header").ToList(); + Assert.Contains("multi-value1", multiHeaderValues); + Assert.Contains("multi-value2", multiHeaderValues); + var combinedHeaderValues = response.Headers.GetValues("Combined-Header").ToList(); + Assert.Contains("multi-value1", combinedHeaderValues); + Assert.Contains("multi-value2", combinedHeaderValues); + Assert.Contains("single-value", combinedHeaderValues); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V1_SetsContentLength", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + Body = "{\"message\":\"Hello, World!\"}", + IsBase64Encoded = false, + StatusCode = 200 + }, + Assertions = (response, emulatorMode) => + { + Assert.Equal("{\"message\":\"Hello, World!\"}".Length, response.ContentLength); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal("{\"message\":\"Hello, World!\"}".Length, response.Content.Headers.ContentLength); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V1_HandlesZeroStatusCode", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + StatusCode = 0, + Body = "{\"key\":\"This body should be replaced\"}" + }, + Assertions = (response, emulatorMode) => + { + string error; + int contentLength; + int statusCode; + if (emulatorMode == ApiGatewayEmulatorMode.Rest) + { + error = " \"Internal server error\"}"; + contentLength = 36; + statusCode = 502; + } + else + { + error = "\"Internal Server Error\"}"; + contentLength = 35; + statusCode = 500; + } + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal("application/json", response.ContentType); + Assert.Equal("{\"message\":"+error, ReadResponseBody(response)); + Assert.Equal(contentLength, response.ContentLength); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + string error; + int contentLength; + int statusCode; + + if (emulatorMode == ApiGatewayEmulatorMode.Rest) + { + error = " \"Internal server error\"}"; + contentLength = 36; + statusCode = 502; + } + else + { + error = "\"Internal Server Error\"}"; + contentLength = 35; + statusCode = 500; + } + Assert.Equal(statusCode, (int)response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("{\"message\":"+error, content); + Assert.Equal(contentLength, response.Content.Headers.ContentLength); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V1_UsesProvidedContentType", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + StatusCode = 200, + Body = "Hello, World!", + Headers = new Dictionary + { + { "Content-Type", "application/json" } + } + }, + Assertions = (response, emulatormode) => + { + Assert.Equal("application/json", response.ContentType); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + await Task.CompletedTask; + } + } + }; + yield return new object[] + { + "V1_APIHeaders", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayProxyResponse + { + StatusCode = 200, + Body = "Test body" + }, + Assertions = (response, emulatorMode) => + { + Assert.True(response.Headers.ContainsKey("Date")); + + if (emulatorMode == ApiGatewayEmulatorMode.Rest) + { + Assert.True(response.Headers.ContainsKey("x-amzn-RequestId")); + Assert.True(response.Headers.ContainsKey("x-amz-apigw-id")); + Assert.True(response.Headers.ContainsKey("X-Amzn-Trace-Id")); + + Assert.Matches(@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", response.Headers["x-amzn-RequestId"]); + Assert.Matches(@"^[A-Za-z0-9_\-]{15}=$", response.Headers["x-amz-apigw-id"]); + Assert.Matches(@"^Root=1-[0-9a-f]{8}-[0-9a-f]{24};Parent=[0-9a-f]{16};Sampled=0;Lineage=1:[0-9a-f]{8}:0$", response.Headers["X-Amzn-Trace-Id"]); + } + else // HttpV1 or HttpV2 + { + Assert.True(response.Headers.ContainsKey("Apigw-Requestid")); + Assert.Matches(@"^[A-Za-z0-9_\-]{15}=$", response.Headers["Apigw-Requestid"]); + } + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.True(response.Headers.Contains("Date")); + + if (emulatorMode == ApiGatewayEmulatorMode.Rest) + { + Assert.True(response.Headers.Contains("x-amzn-RequestId")); + Assert.True(response.Headers.Contains("x-amz-apigw-id")); + Assert.True(response.Headers.Contains("X-Amzn-Trace-Id")); + + Assert.Matches(@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", response.Headers.GetValues("x-amzn-RequestId").First()); + Assert.Matches(@"^[A-Za-z0-9_\-]{15}=$", response.Headers.GetValues("x-amz-apigw-id").First()); + Assert.Matches(@"^Root=1-[0-9a-f]{8}-[0-9a-f]{24};Parent=[0-9a-f]{16};Sampled=0;Lineage=1:[0-9a-f]{8}:0$", response.Headers.GetValues("X-Amzn-Trace-Id").First()); + } + else // HttpV1 or HttpV2 + { + Assert.True(response.Headers.Contains("Apigw-Requestid")); + Assert.Matches(@"^[A-Za-z0-9_\-]{15}=$", response.Headers.GetValues("Apigw-Requestid").First()); + } + + await Task.CompletedTask; + } + } + }; + + } + + public static IEnumerable V2TestCases() + { + // V2 (APIGatewayHttpApiV2ProxyResponse) test cases + yield return new object[] + { + "V2_SimpleJsonResponse", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayHttpApiV2ProxyResponse + { + StatusCode = 200, + Body = JsonSerializer.Serialize(new { message = "Hello, World!" }), + Headers = new Dictionary { { "Content-Type", "application/json" } } + }, + Assertions = (response, emulatorMode) => + { + Assert.Equal(200, response.StatusCode); + Assert.Equal("application/json", response.ContentType); + Assert.Equal("{\"message\":\"Hello, World!\"}", ReadResponseBody(response)); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("{\"message\":\"Hello, World!\"}", content); + } + } + }; + + yield return new object[] + { + "V2_SetsCorrectStatusCode", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayHttpApiV2ProxyResponse + { + StatusCode = 201, + Body = "{\"message\":\"Created\"}" + }, + Assertions = (response, emulatorMode) => + { + Assert.Equal(201, response.StatusCode); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal(201, (int)response.StatusCode); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V2_HandlesZeroStatusCode", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayHttpApiV2ProxyResponse + { + StatusCode = 0, + Body = "{\"key\":\"This body should be replaced\"}" + }, + Assertions = (response, emulatorMode) => + { + string error; + int contentLength; + int statusCode; + error = "\"Internal Server Error\"}"; + contentLength = 35; + statusCode = 500; + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal("application/json", response.ContentType); + Assert.Equal("{\"message\":"+error, ReadResponseBody(response)); + Assert.Equal(contentLength, response.ContentLength); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + string error; + int contentLength; + int statusCode; + + error = "\"Internal Server Error\"}"; + contentLength = 35; + statusCode = 500; + Assert.Equal(statusCode, (int)response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("{\"message\":"+error, content); + Assert.Equal(contentLength, response.Content.Headers.ContentLength); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V2_SetsHeaders", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayHttpApiV2ProxyResponse + { + StatusCode = 200, + Headers = new Dictionary + { + { "Content-Type", "application/json" }, + { "X-Custom-Header", "CustomValue" } + }, + Body = "{\"message\":\"With Headers\"}" + }, + Assertions = (response, emulatorMode) => + { + Assert.Equal("application/json", response.Headers["Content-Type"]); + Assert.Equal("CustomValue", response.Headers["X-Custom-Header"]); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + Assert.True(response.Headers.Contains("X-Custom-Header")); + Assert.Equal("CustomValue", response.Headers.GetValues("X-Custom-Header").First()); + await Task.CompletedTask; + } + } + }; + + yield return new object[] +{ + "V2_SetsBodyNonBase64", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayHttpApiV2ProxyResponse + { + StatusCode = 200, + Body = "{\"message\":\"Hello, API Gateway v2!\"}", + IsBase64Encoded = false + }, + Assertions = (response, emulatorMode) => + { + Assert.Equal("{\"message\":\"Hello, API Gateway v2!\"}", ReadResponseBody(response)); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("{\"message\":\"Hello, API Gateway v2!\"}", content); + } + } +}; + + yield return new object[] + { + "V2_SetsBodyBase64", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayHttpApiV2ProxyResponse + { + StatusCode = 200, + Body = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"message\":\"Hello, API Gateway v2!\"}")), + IsBase64Encoded = true + }, + Assertions = (response, emulatormode) => + { + Assert.Equal("{\"message\":\"Hello, API Gateway v2!\"}", ReadResponseBody(response)); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("{\"message\":\"Hello, API Gateway v2!\"}", content); + } + } + }; + + yield return new object[] + { + "V2_DefaultsToTextPlainContentType", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayHttpApiV2ProxyResponse + { + StatusCode = 200, + Body = "Hello, World!" + }, + Assertions = (response, emulatorMode) => + { + Assert.Equal("text/plain; charset=utf-8", response.ContentType); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType?.ToString()); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V2_HandlesHeadersCorrectly", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayHttpApiV2ProxyResponse + { + StatusCode = 200, + Headers = new Dictionary + { + { "Content-Type", "application/json" }, + { "myheader", "test,shouldhavesecondvalue" }, + { "anotherheader", "secondvalue" } + }, + Body = "{\"message\":\"With Headers\"}" + }, + Assertions = (response, emulatorMode) => + { + Assert.Equal("application/json", response.Headers["Content-Type"]); + Assert.Equal("test,shouldhavesecondvalue", response.Headers["myheader"]); + Assert.Equal("secondvalue", response.Headers["anotherheader"]); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + Assert.Equal("test,shouldhavesecondvalue", response.Headers.GetValues("myheader").First()); + Assert.Equal("secondvalue", response.Headers.GetValues("anotherheader").First()); + await Task.CompletedTask; + } + } + }; + + yield return new object[] + { + "V2_DoesNotOverrideExplicitValues", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayHttpApiV2ProxyResponse + { + StatusCode = 201, + Body = "{\"key\":\"value\"}", + Headers = new Dictionary + { + { "Content-Type", "application/xml" } + } + }, + Assertions = (response, emulatorMode) => + { + Assert.Equal(201, response.StatusCode); + Assert.Equal("application/xml", response.ContentType); + Assert.Equal("{\"key\":\"value\"}", ReadResponseBody(response)); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.Equal(201, (int)response.StatusCode); + Assert.Equal("application/xml", response.Content.Headers.ContentType?.ToString()); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("{\"key\":\"value\"}", content); + } + } + }; + + yield return new object[] + { + "V2_HttpAPIHeaders", + new ApiGatewayResponseTestCase + { + Response = new APIGatewayHttpApiV2ProxyResponse + { + StatusCode = 200, + Body = "Test body" + }, + Assertions = (response, emulatorMode) => + { + Assert.True(response.Headers.ContainsKey("Date")); + Assert.True(response.Headers.ContainsKey("Apigw-Requestid")); + + Assert.Matches(@"^[A-Za-z0-9_\-]{15}=$", response.Headers["Apigw-Requestid"]); + }, + IntegrationAssertions = async (response, emulatorMode) => + { + Assert.True(response.Headers.Contains("Date")); + Assert.True(response.Headers.Contains("Apigw-Requestid")); + + Assert.Matches(@"^[A-Za-z0-9_\-]{15}=$", response.Headers.GetValues("Apigw-Requestid").First()); + await Task.CompletedTask; + } + } + }; + + } + + private static string ReadResponseBody(HttpResponse response) + { + response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(response.Body); + return reader.ReadToEnd(); + } + + public class ApiGatewayResponseTestCase + { + public required object Response { get; set; } + public required Action Assertions { get; set; } + public required Func IntegrationAssertions { get; set; } + } + +}