From 9d389176dd2b26b72e75a16a94d4917c6a1ece2c Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Sun, 8 Dec 2024 16:12:31 -0500 Subject: [PATCH 1/3] Add request parsing into api gateway --- .../ApiGatewayResponseExtensions.cs | 38 +- .../Extensions/HttpContextExtensions.cs | 196 +++++++ .../Utilities/HttpRequestUtility.cs | 178 ++++++ .../Utilities/RouteTemplateUtility.cs | 66 +++ .../ApiGatewayIntegrationTestFixture.cs | 152 ++++-- ...atewayResponseExtensionsAdditionalTests.cs | 4 +- .../ApiGatewayResponseExtensionsTests.cs | 6 +- .../Helpers/ApiGatewayHelper.cs | 100 +++- .../HttpContextExtensionsTests.cs | 278 ++++++++++ .../cloudformation-template-apigateway.yaml | 482 +++++++++++++---- .../Extensions/HttpContextExtensionsTests.cs | 39 ++ .../Extensions/HttpContextTestCases.cs | 512 ++++++++++++++++++ .../Utilities/HttpRequestUtilityTests.cs | 84 +++ .../Utilities/RouteTemplateUtilityTests.cs | 92 ++++ 14 files changed, 2043 insertions(+), 184 deletions(-) create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/HttpContextExtensions.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/HttpRequestUtility.cs create mode 100644 Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs create mode 100644 Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/HttpContextExtensionsTests.cs create mode 100644 Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextExtensionsTests.cs create mode 100644 Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextTestCases.cs create mode 100644 Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/HttpRequestUtilityTests.cs create mode 100644 Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ApiGatewayResponseExtensions.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ApiGatewayResponseExtensions.cs index bed071a00..b2a41b6ab 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ApiGatewayResponseExtensions.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ApiGatewayResponseExtensions.cs @@ -3,6 +3,7 @@ using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.TestTool.Models; +using Amazon.Lambda.TestTool.Utilities; using Microsoft.Extensions.Primitives; using System.Text; @@ -102,49 +103,18 @@ private static Dictionary GetDefaultApiGatewayHeaders(ApiGateway { 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()); + headers.Add("x-amz-apigw-id", HttpRequestUtility.GenerateRequestId()); + headers.Add("X-Amzn-Trace-Id", HttpRequestUtility.GenerateTraceId()); break; case ApiGatewayEmulatorMode.HttpV1: case ApiGatewayEmulatorMode.HttpV2: - headers.Add("Apigw-Requestid", GenerateRequestId()); + headers.Add("Apigw-Requestid", HttpRequestUtility.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 . /// diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/HttpContextExtensions.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/HttpContextExtensions.cs new file mode 100644 index 000000000..c99d2748f --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/HttpContextExtensions.cs @@ -0,0 +1,196 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.TestTool.Extensions; + +using System.Web; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestTool.Models; +using Amazon.Lambda.TestTool.Utilities; +using static Amazon.Lambda.APIGatewayEvents.APIGatewayHttpApiV2ProxyRequest; + +/// +/// Provides extension methods to translate an to different types of API Gateway requests. +/// +public static class HttpContextExtensions +{ + /// + /// Translates an to an . + /// + /// The to be translated. + /// The configuration of the API Gateway route, including the HTTP method, path, and other metadata. + /// An object representing the translated request. + public static APIGatewayHttpApiV2ProxyRequest ToApiGatewayHttpV2Request( + this HttpContext context, + ApiGatewayRouteConfig apiGatewayRouteConfig) + { + var request = context.Request; + var currentTime = DateTimeOffset.UtcNow; + var body = HttpRequestUtility.ReadRequestBody(request); + var contentLength = HttpRequestUtility.CalculateContentLength(request, body); + + var pathParameters = RouteTemplateUtility.ExtractPathParameters(apiGatewayRouteConfig.Path, request.Path); + + // Format 2.0 doesn't have multiValueHeaders or multiValueQueryStringParameters fields. Duplicate headers are combined with commas and included in the headers field. + var (_, allHeaders) = HttpRequestUtility.ExtractHeaders(request.Headers); + var headers = allHeaders.ToDictionary( + kvp => kvp.Key, + kvp => string.Join(", ", kvp.Value) + ); + + // Duplicate query strings are combined with commas and included in the queryStringParameters field. + var (_, allQueryParams) = HttpRequestUtility.ExtractQueryStringParameters(request.Query); + var queryStringParameters = allQueryParams.ToDictionary( + kvp => kvp.Key, + kvp => string.Join(",", kvp.Value) + ); + + string userAgent = request.Headers.UserAgent.ToString(); + + if (!headers.ContainsKey("content-length")) + { + headers["content-length"] = contentLength.ToString(); + } + + if (!headers.ContainsKey("content-type")) + { + headers["content-type"] = "text/plain; charset=utf-8"; + } + + var httpApiV2ProxyRequest = new APIGatewayHttpApiV2ProxyRequest + { + RouteKey = $"{request.Method} {apiGatewayRouteConfig.Path}", + RawPath = request.Path.Value, // this should be decoded value + Body = body, + IsBase64Encoded = false, + RequestContext = new ProxyRequestContext + { + Http = new HttpDescription + { + Method = request.Method, + Path = request.Path.Value, // this should be decoded value + Protocol = !string.IsNullOrEmpty(request.Protocol) ? request.Protocol : "HTTP/1.1", // defaults to http 1.1 if not provided + UserAgent = userAgent + }, + Time = currentTime.ToString("dd/MMM/yyyy:HH:mm:ss") + " +0000", + TimeEpoch = currentTime.ToUnixTimeMilliseconds(), + RequestId = HttpRequestUtility.GenerateRequestId(), + RouteKey = $"{request.Method} {apiGatewayRouteConfig.Path}", + }, + Version = "2.0" + }; + + if (request.Cookies.Any()) + { + httpApiV2ProxyRequest.Cookies = request.Cookies.Select(c => $"{c.Key}={c.Value}").ToArray(); + } + + if (headers.Any()) + { + httpApiV2ProxyRequest.Headers = headers; + } + + httpApiV2ProxyRequest.RawQueryString = string.Empty; // default is empty string + if (queryStringParameters.Any()) + { + // this should be decoded value + httpApiV2ProxyRequest.QueryStringParameters = queryStringParameters; + + // this should be the url encoded value and not include the "?" + // e.g. key=%2b%2b%2b + httpApiV2ProxyRequest.RawQueryString = HttpUtility.UrlPathEncode(request.QueryString.Value?.Substring(1)); + + } + + if (pathParameters.Any()) + { + // this should be decoded value + httpApiV2ProxyRequest.PathParameters = pathParameters; + } + + if (HttpRequestUtility.IsBinaryContent(request.ContentType)) + { + // we already converted it when we read the body so we dont need to re-convert it + httpApiV2ProxyRequest.IsBase64Encoded = true; + } + + return httpApiV2ProxyRequest; + } + + /// + /// Translates an to an . + /// + /// The to be translated. + /// The configuration of the API Gateway route, including the HTTP method, path, and other metadata. + /// An object representing the translated request. + public static APIGatewayProxyRequest ToApiGatewayRequest( + this HttpContext context, + ApiGatewayRouteConfig apiGatewayRouteConfig) + { + var request = context.Request; + var body = HttpRequestUtility.ReadRequestBody(request); + var contentLength = HttpRequestUtility.CalculateContentLength(request, body); + + var pathParameters = RouteTemplateUtility.ExtractPathParameters(apiGatewayRouteConfig.Path, request.Path); + + var (headers, multiValueHeaders) = HttpRequestUtility.ExtractHeaders(request.Headers); + var (queryStringParameters, multiValueQueryStringParameters) = HttpRequestUtility.ExtractQueryStringParameters(request.Query); + + if (!headers.ContainsKey("content-length")) + { + headers["content-length"] = contentLength.ToString(); + multiValueHeaders["content-length"] = new List { contentLength.ToString() }; + } + + if (!headers.ContainsKey("content-type")) + { + headers["content-type"] = "text/plain; charset=utf-8"; + multiValueHeaders["content-type"] = new List { "text/plain; charset=utf-8" }; + } + + var proxyRequest = new APIGatewayProxyRequest + { + Resource = apiGatewayRouteConfig.Path, + Path = request.Path.Value, + HttpMethod = request.Method, + Body = body, + IsBase64Encoded = false + }; + + if (headers.Any()) + { + proxyRequest.Headers = headers; + } + + if (multiValueHeaders.Any()) + { + proxyRequest.MultiValueHeaders = multiValueHeaders; + } + + if (queryStringParameters.Any()) + { + // this should be decoded value + proxyRequest.QueryStringParameters = queryStringParameters; + } + + if (multiValueQueryStringParameters.Any()) + { + // this should be decoded value + proxyRequest.MultiValueQueryStringParameters = multiValueQueryStringParameters; + } + + if (pathParameters.Any()) + { + // this should be decoded value + proxyRequest.PathParameters = pathParameters; + } + + if (HttpRequestUtility.IsBinaryContent(request.ContentType)) + { + // we already converted it when we read the body so we dont need to re-convert it + proxyRequest.IsBase64Encoded = true; + } + + return proxyRequest; + } +} diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/HttpRequestUtility.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/HttpRequestUtility.cs new file mode 100644 index 000000000..5e2df4b00 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/HttpRequestUtility.cs @@ -0,0 +1,178 @@ +using System.Text; + +namespace Amazon.Lambda.TestTool.Utilities; + +/// +/// Utility class for handling HTTP requests in the context of API Gateway emulation. +/// +public static class HttpRequestUtility +{ + /// + /// Determines whether the specified content type represents binary content. + /// + /// The content type to check. + /// True if the content type represents binary content; otherwise, false. + public static bool IsBinaryContent(string? contentType) + { + if (string.IsNullOrEmpty(contentType)) + return false; + + return contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) || + contentType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase) || + contentType.Equals("application/zip", StringComparison.OrdinalIgnoreCase) || + contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) || + contentType.Equals("application/wasm", StringComparison.OrdinalIgnoreCase) || + contentType.Equals("application/x-protobuf", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Reads the body of the HTTP request as a string. Returns null if the request body is empty. + /// + /// The HTTP request. + /// The body of the request as a string, or null if the body is empty. + public static string? ReadRequestBody(HttpRequest request) + { + if (request.ContentLength == 0 || request.Body == null || !request.Body.CanRead) + { + return null; + } + + // Check if the content is binary + bool isBinary = HttpRequestUtility.IsBinaryContent(request.ContentType); + + request.Body.Position = 0; + + using (var memoryStream = new MemoryStream()) + { + request.Body.CopyTo(memoryStream); + + // If the stream is empty, return null + if (memoryStream.Length == 0) + { + return null; + } + + memoryStream.Position = 0; + + if (isBinary) + { + // For binary data, convert to Base64 string + byte[] bytes = memoryStream.ToArray(); + return Convert.ToBase64String(bytes); + } + else + { + // For text data, read as string + using (var reader = new StreamReader(memoryStream)) + { + string content = reader.ReadToEnd(); + return string.IsNullOrWhiteSpace(content) ? null : content; + } + } + } + } + + + + /// + /// Extracts headers from the request, separating them into single-value and multi-value dictionaries. + /// + /// The request headers. + /// A tuple containing single-value and multi-value header dictionaries. + /// + /// For headers: + /// Accept: text/html + /// Accept: application/xhtml+xml + /// X-Custom-Header: value1 + /// + /// The method will return: + /// singleValueHeaders: { "Accept": "application/xhtml+xml", "X-Custom-Header": "value1" } + /// multiValueHeaders: { "Accept": ["text/html", "application/xhtml+xml"], "X-Custom-Header": ["value1"] } + /// + public static (IDictionary, IDictionary>) ExtractHeaders(IHeaderDictionary headers) + { + var singleValueHeaders = new Dictionary(); + var multiValueHeaders = new Dictionary>(); + + foreach (var header in headers) + { + singleValueHeaders[header.Key.ToLower()] = header.Value.Last() ?? ""; + multiValueHeaders[header.Key.ToLower()] = [.. header.Value]; + } + + return (singleValueHeaders, multiValueHeaders); + } + + /// + /// Extracts query string parameters from the request, separating them into single-value and multi-value dictionaries. + /// + /// The query string collection. + /// A tuple containing single-value and multi-value query parameter dictionaries. + /// + /// For query string: ?param1=value1&param2=value2&param2=value3 + /// + /// The method will return: + /// singleValueParams: { "param1": "value1", "param2": "value3" } + /// multiValueParams: { "param1": ["value1"], "param2": ["value2", "value3"] } + /// + public static (IDictionary, IDictionary>) ExtractQueryStringParameters(IQueryCollection query) + { + var singleValueParams = new Dictionary(); + var multiValueParams = new Dictionary>(); + + foreach (var param in query) + { + singleValueParams[param.Key] = param.Value.Last() ?? ""; + multiValueParams[param.Key] = [.. param.Value]; + } + + return (singleValueParams, multiValueParams); + } + + /// + /// 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 145character string consisting of lowercase letters and numbers, followed by an equals sign. + public static string GenerateRequestId() + { + return Guid.NewGuid().ToString("N").Substring(0, 8) + Guid.NewGuid().ToString("N").Substring(0, 7) + "="; + } + + /// + /// 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 + /// + public 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"; + } + + public static long CalculateContentLength(HttpRequest request, string body) + { + if (!string.IsNullOrEmpty(body)) + { + return Encoding.UTF8.GetByteCount(body); + } + else if (request.ContentLength.HasValue) + { + return request.ContentLength.Value; + } + return 0; + } + + +} diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs new file mode 100644 index 000000000..fa9da4e0d --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs @@ -0,0 +1,66 @@ +namespace Amazon.Lambda.TestTool.Utilities; + +using Microsoft.AspNetCore.Routing.Template; + +/// +/// Provides utility methods for working with route templates and extracting path parameters. +/// +public static class RouteTemplateUtility +{ + /// + /// Extracts path parameters from an actual path based on a route template. + /// + /// The route template to match against. + /// The actual path to extract parameters from. + /// A dictionary of extracted path parameters and their values. + /// + /// Using this method: + /// + /// var routeTemplate = "/users/{id}/orders/{orderId}"; + /// var actualPath = "/users/123/orders/456"; + /// var parameters = RouteTemplateUtility.ExtractPathParameters(routeTemplate, actualPath); + /// // parameters will contain: { {"id", "123"}, {"orderId", "456"} } + /// + /// + public static Dictionary ExtractPathParameters(string routeTemplate, string actualPath) + { + var template = TemplateParser.Parse(routeTemplate); + var matcher = new TemplateMatcher(template, GetDefaults(template)); + var routeValues = new RouteValueDictionary(); + + if (matcher.TryMatch(actualPath, routeValues)) + { + return routeValues.ToDictionary(rv => rv.Key, rv => rv.Value?.ToString() ?? string.Empty); + } + + return new Dictionary(); + } + + /// + /// Gets the default values for parameters in a parsed route template. + /// + /// The parsed route template. + /// A dictionary of default values for the template parameters. + /// + /// Using this method: + /// + /// var template = TemplateParser.Parse("/api/{version=v1}/users/{id}"); + /// var defaults = RouteTemplateUtility.GetDefaults(template); + /// // defaults will contain: { {"version", "v1"} } + /// + /// + public static RouteValueDictionary GetDefaults(RouteTemplate parsedTemplate) + { + var result = new RouteValueDictionary(); + + foreach (var parameter in parsedTemplate.Parameters) + { + if (parameter.DefaultValue != null) + { + if (parameter.Name != null) result.Add(parameter.Name, parameter.DefaultValue); + } + } + + return result; + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayIntegrationTestFixture.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayIntegrationTestFixture.cs index b5b862793..effc80392 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayIntegrationTestFixture.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayIntegrationTestFixture.cs @@ -16,17 +16,39 @@ public class ApiGatewayIntegrationTestFixture : IAsyncLifetime 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; } + // ParseAndReturnBody + public string ParseAndReturnBodyRestApiId { get; private set; } + public string ParseAndReturnBodyHttpApiV1Id { get; private set; } + public string ParseAndReturnBodyHttpApiV2Id { get; private set; } + public string ParseAndReturnBodyRestApiUrl { get; private set; } + public string ParseAndReturnBodyHttpApiV1Url { get; private set; } + public string ParseAndReturnBodyHttpApiV2Url { get; private set; } + + // ReturnRawBody + public string ReturnRawBodyRestApiId { get; private set; } + public string ReturnRawBodyHttpApiV1Id { get; private set; } + public string ReturnRawBodyHttpApiV2Id { get; private set; } + public string ReturnRawBodyRestApiUrl { get; private set; } + public string ReturnRawBodyHttpApiV1Url { get; private set; } + public string ReturnRawBodyHttpApiV2Url { get; private set; } + + // ReturnFullEvent + public string ReturnFullEventRestApiId { get; private set; } + public string ReturnFullEventHttpApiV1Id { get; private set; } + public string ReturnFullEventHttpApiV2Id { get; private set; } + public string ReturnFullEventRestApiUrl { get; private set; } + public string ReturnFullEventHttpApiV1Url { get; private set; } + public string ReturnFullEventHttpApiV2Url { get; private set; } + + // ReturnDecodedParseBin + public string ReturnDecodedParseBinRestApiId { get; private set; } + public string ReturnDecodedParseBinRestApiUrl { get; private set; } + + // Lambda Function ARNs + public string ParseAndReturnBodyLambdaFunctionArn { get; private set; } + public string ReturnRawBodyLambdaFunctionArn { get; private set; } + public string ReturnFullEventLambdaFunctionArn { get; private set; } public ApiGatewayIntegrationTestFixture() { @@ -37,17 +59,41 @@ public ApiGatewayIntegrationTestFixture() 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; + + // ParseAndReturnBody + ParseAndReturnBodyRestApiId = string.Empty; + ParseAndReturnBodyHttpApiV1Id = string.Empty; + ParseAndReturnBodyHttpApiV2Id = string.Empty; + ParseAndReturnBodyRestApiUrl = string.Empty; + ParseAndReturnBodyHttpApiV1Url = string.Empty; + ParseAndReturnBodyHttpApiV2Url = string.Empty; + + // ReturnRawBody + ReturnRawBodyRestApiId = string.Empty; + ReturnRawBodyHttpApiV1Id = string.Empty; + ReturnRawBodyHttpApiV2Id = string.Empty; + ReturnRawBodyRestApiUrl = string.Empty; + ReturnRawBodyHttpApiV1Url = string.Empty; + ReturnRawBodyHttpApiV2Url = string.Empty; + + // ReturnFullEvent + ReturnFullEventRestApiId = string.Empty; + ReturnFullEventHttpApiV1Id = string.Empty; + ReturnFullEventHttpApiV2Id = string.Empty; + ReturnFullEventRestApiUrl = string.Empty; + ReturnFullEventHttpApiV1Url = string.Empty; + ReturnFullEventHttpApiV2Url = string.Empty; + + // ReturnDecodedParseBin + ReturnDecodedParseBinRestApiId = string.Empty; + ReturnDecodedParseBinRestApiUrl = string.Empty; + + // Lambda Function ARNs + ParseAndReturnBodyLambdaFunctionArn = string.Empty; + ReturnRawBodyLambdaFunctionArn = string.Empty; + ReturnFullEventLambdaFunctionArn = string.Empty; } public async Task InitializeAsync() @@ -99,29 +145,59 @@ private async Task WaitForStackCreationComplete() private async Task RetrieveStackOutputs() { - RestApiId = await CloudFormationHelper.GetOutputValueAsync(StackName, "RestApiId"); - RestApiUrl = await CloudFormationHelper.GetOutputValueAsync(StackName, "RestApiUrl"); + // ParseAndReturnBody + ParseAndReturnBodyRestApiId = await CloudFormationHelper.GetOutputValueAsync(StackName, "ParseAndReturnBodyRestApiId"); + ParseAndReturnBodyHttpApiV1Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "ParseAndReturnBodyHttpApiV1Id"); + ParseAndReturnBodyHttpApiV2Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "ParseAndReturnBodyHttpApiV2Id"); + ParseAndReturnBodyRestApiUrl = await CloudFormationHelper.GetOutputValueAsync(StackName, "ParseAndReturnBodyRestApiUrl"); + ParseAndReturnBodyHttpApiV1Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "ParseAndReturnBodyHttpApiV1Url"); + ParseAndReturnBodyHttpApiV2Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "ParseAndReturnBodyHttpApiV2Url"); + + // ReturnRawBody + ReturnRawBodyRestApiId = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawBodyRestApiId"); + ReturnRawBodyHttpApiV1Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawBodyHttpApiV1Id"); + ReturnRawBodyHttpApiV2Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawBodyHttpApiV2Id"); + ReturnRawBodyRestApiUrl = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawBodyRestApiUrl"); + ReturnRawBodyHttpApiV1Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawBodyHttpApiV1Url"); + ReturnRawBodyHttpApiV2Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawBodyHttpApiV2Url"); + + // ReturnFullEvent + ReturnFullEventRestApiId = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnFullEventRestApiId"); + ReturnFullEventHttpApiV1Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnFullEventHttpApiV1Id"); + ReturnFullEventHttpApiV2Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnFullEventHttpApiV2Id"); + ReturnFullEventRestApiUrl = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnFullEventRestApiUrl"); + ReturnFullEventHttpApiV1Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnFullEventHttpApiV1Url"); + ReturnFullEventHttpApiV2Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnFullEventHttpApiV2Url"); + + // ReturnDecodedParseBin + ReturnDecodedParseBinRestApiId = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnDecodedParseBinRestApiId"); + ReturnDecodedParseBinRestApiUrl = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnDecodedParseBinRestApiUrl"); + + // Lambda Function ARNs + ParseAndReturnBodyLambdaFunctionArn = await CloudFormationHelper.GetOutputValueAsync(StackName, "ParseAndReturnBodyLambdaFunctionArn"); + ReturnRawBodyLambdaFunctionArn = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawBodyLambdaFunctionArn"); + ReturnFullEventLambdaFunctionArn = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnFullEventLambdaFunctionArn"); + } - HttpApiV1Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "HttpApiV1Id"); - HttpApiV1Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "HttpApiV1Url"); + private async Task WaitForApisAvailability() + { + // ParseAndReturnBody + await ApiGatewayHelper.WaitForApiAvailability(ParseAndReturnBodyRestApiId, ParseAndReturnBodyRestApiUrl, false); + await ApiGatewayHelper.WaitForApiAvailability(ParseAndReturnBodyHttpApiV1Id, ParseAndReturnBodyHttpApiV1Url, true); + await ApiGatewayHelper.WaitForApiAvailability(ParseAndReturnBodyHttpApiV2Id, ParseAndReturnBodyHttpApiV2Url, true); - HttpApiV2Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "HttpApiV2Id"); - HttpApiV2Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "HttpApiV2Url"); + // ReturnRawBody + await ApiGatewayHelper.WaitForApiAvailability(ReturnRawBodyRestApiId, ReturnRawBodyRestApiUrl, false); + await ApiGatewayHelper.WaitForApiAvailability(ReturnRawBodyHttpApiV1Id, ReturnRawBodyHttpApiV1Url, true); + await ApiGatewayHelper.WaitForApiAvailability(ReturnRawBodyHttpApiV2Id, ReturnRawBodyHttpApiV2Url, true); - ReturnRawRequestBodyV2Id = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawRequestBodyHttpApiId"); - ReturnRawRequestBodyHttpApiV2Url = await CloudFormationHelper.GetOutputValueAsync(StackName, "ReturnRawRequestBodyHttpApiUrl"); + // ReturnFullEvent + await ApiGatewayHelper.WaitForApiAvailability(ReturnFullEventRestApiId, ReturnFullEventRestApiUrl, false); + await ApiGatewayHelper.WaitForApiAvailability(ReturnFullEventHttpApiV1Id, ReturnFullEventHttpApiV1Url, true); + await ApiGatewayHelper.WaitForApiAvailability(ReturnFullEventHttpApiV2Id, ReturnFullEventHttpApiV2Url, true); - BinaryMediaRestApiId = await CloudFormationHelper.GetOutputValueAsync(StackName, "BinaryMediaRestApiId"); - BinaryMediaRestApiUrl = await CloudFormationHelper.GetOutputValueAsync(StackName, "BinaryMediaRestApiUrl"); - } + await ApiGatewayHelper.WaitForApiAvailability(ReturnDecodedParseBinRestApiId, ReturnDecodedParseBinRestApiUrl, false); - 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() diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsAdditionalTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsAdditionalTests.cs index 6bfa15ba6..c692d3d6a 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsAdditionalTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsAdditionalTests.cs @@ -54,7 +54,7 @@ public async Task ToHttpResponse_RestAPIGatewayV1DecodesBase64() var httpContext = new DefaultHttpContext(); testResponse.ToHttpResponse(httpContext, ApiGatewayEmulatorMode.Rest); - var actualResponse = await _httpClient.PostAsync(_fixture.BinaryMediaRestApiUrl, new StringContent(JsonSerializer.Serialize(testResponse))); + var actualResponse = await _httpClient.PostAsync(_fixture.ReturnDecodedParseBinRestApiUrl, new StringContent(JsonSerializer.Serialize(testResponse))); await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpContext.Response); Assert.Equal(200, (int)actualResponse.StatusCode); var content = await actualResponse.Content.ReadAsStringAsync(); @@ -73,7 +73,7 @@ public async Task ToHttpResponse_HttpV1APIGatewayV1DecodesBase64() var httpContext = new DefaultHttpContext(); testResponse.ToHttpResponse(httpContext, ApiGatewayEmulatorMode.HttpV1); - var actualResponse = await _httpClient.PostAsync(_fixture.HttpApiV1Url, new StringContent(JsonSerializer.Serialize(testResponse))); + var actualResponse = await _httpClient.PostAsync(_fixture.ParseAndReturnBodyHttpApiV1Url, new StringContent(JsonSerializer.Serialize(testResponse))); await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpContext.Response); Assert.Equal(200, (int)actualResponse.StatusCode); diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsTests.cs index 6b3892ab7..7877b240b 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/ApiGatewayResponseExtensionsTests.cs @@ -25,7 +25,7 @@ public async Task IntegrationTest_APIGatewayV1_REST(string testName, ApiGatewayR { await RetryHelper.RetryOperation(async () => { - await RunV1Test(testCase, _fixture.RestApiUrl, ApiGatewayEmulatorMode.Rest); + await RunV1Test(testCase, _fixture.ParseAndReturnBodyRestApiUrl, ApiGatewayEmulatorMode.Rest); return true; }); } @@ -37,7 +37,7 @@ public async Task IntegrationTest_APIGatewayV1_HTTP(string testName, ApiGatewayR { await RetryHelper.RetryOperation(async () => { - await RunV1Test(testCase, _fixture.HttpApiV1Url, ApiGatewayEmulatorMode.HttpV1); + await RunV1Test(testCase, _fixture.ParseAndReturnBodyHttpApiV1Url, ApiGatewayEmulatorMode.HttpV1); return true; }); } @@ -51,7 +51,7 @@ await RetryHelper.RetryOperation(async () => { var testResponse = testCase.Response as APIGatewayHttpApiV2ProxyResponse; Assert.NotNull(testResponse); - var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(testResponse, _fixture.HttpApiV2Url); + var (actualResponse, httpTestResponse) = await _fixture.ApiGatewayTestHelper.ExecuteTestRequest(testResponse, _fixture.ParseAndReturnBodyHttpApiV2Url); await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpTestResponse); await testCase.IntegrationAssertions(actualResponse, ApiGatewayEmulatorMode.HttpV2); return true; 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 index 11c6cd735..7ad38e16a 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/ApiGatewayHelper.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/ApiGatewayHelper.cs @@ -6,6 +6,7 @@ using Amazon.APIGateway.Model; using Amazon.ApiGatewayV2.Model; using System.Net; +using Amazon.Runtime.Internal.Endpoints.StandardLibrary; namespace Amazon.Lambda.TestTool.IntegrationTests.Helpers { @@ -47,7 +48,7 @@ public async Task WaitForApiAvailability(string apiId, string apiUrl, bool isHtt 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) + if (response.StatusCode != HttpStatusCode.NotFound && response.StatusCode != HttpStatusCode.Forbidden) { return; // API is available and responding } @@ -70,5 +71,102 @@ public async Task WaitForApiAvailability(string apiId, string apiUrl, bool isHtt } throw new TimeoutException($"API {apiId} did not become available within {maxWaitTimeSeconds} seconds"); } + + public async Task AddRouteToRestApi(string restApiId, string lambdaArn, string route = "/test") + { + var rootResourceId = (await _apiGatewayV1Client.GetResourcesAsync(new GetResourcesRequest { RestApiId = restApiId })).Items[0].Id; + + var pathParts = route.Trim('/').Split('/'); + var currentResourceId = rootResourceId; + foreach (var pathPart in pathParts) + { + var resources = await _apiGatewayV1Client.GetResourcesAsync(new GetResourcesRequest { RestApiId = restApiId }); + var existingResource = resources.Items.FirstOrDefault(r => r.ParentId == currentResourceId && r.PathPart == pathPart); + + if (existingResource == null) + { + var createResourceResponse = await _apiGatewayV1Client.CreateResourceAsync(new CreateResourceRequest + { + RestApiId = restApiId, + ParentId = currentResourceId, + PathPart = pathPart + }); + currentResourceId = createResourceResponse.Id; + } + else + { + currentResourceId = existingResource.Id; + } + } + + await _apiGatewayV1Client.PutMethodAsync(new PutMethodRequest + { + RestApiId = restApiId, + ResourceId = currentResourceId, + HttpMethod = "ANY", + AuthorizationType = "NONE" + }); + + await _apiGatewayV1Client.PutIntegrationAsync(new PutIntegrationRequest + { + RestApiId = restApiId, + ResourceId = currentResourceId, + HttpMethod = "ANY", + Type = APIGateway.IntegrationType.AWS_PROXY, + IntegrationHttpMethod = "POST", + Uri = $"arn:aws:apigateway:{_apiGatewayV1Client.Config.RegionEndpoint.SystemName}:lambda:path/2015-03-31/functions/{lambdaArn}/invocations" + }); + + await _apiGatewayV1Client.CreateDeploymentAsync(new APIGateway.Model.CreateDeploymentRequest + { + RestApiId = restApiId, + StageName = "test" + }); + + var url = $"https://{restApiId}.execute-api.{_apiGatewayV1Client.Config.RegionEndpoint.SystemName}.amazonaws.com/test{route}"; + return url; + } + + public async Task AddRouteToHttpApi(string httpApiId, string lambdaArn, string version, string route = "/test", string routeKey = "ANY") + { + var createIntegrationResponse = await _apiGatewayV2Client.CreateIntegrationAsync(new CreateIntegrationRequest + { + ApiId = httpApiId, + IntegrationType = ApiGatewayV2.IntegrationType.AWS_PROXY, + IntegrationUri = lambdaArn, + PayloadFormatVersion = version + }); + string integrationId = createIntegrationResponse.IntegrationId; + + // Split the route into parts and create each part + var routeParts = route.Trim('/').Split('/'); + var currentPath = ""; + foreach (var part in routeParts) + { + currentPath += "/" + part; + await _apiGatewayV2Client.CreateRouteAsync(new CreateRouteRequest + { + ApiId = httpApiId, + RouteKey = $"{routeKey} {currentPath}", + Target = $"integrations/{integrationId}" + }); + } + + // Create the final route (if it's not already created) + if (currentPath != "/" + route.Trim('/')) + { + await _apiGatewayV2Client.CreateRouteAsync(new CreateRouteRequest + { + ApiId = httpApiId, + RouteKey = $"{routeKey} {route}", + Target = $"integrations/{integrationId}" + }); + } + + var url = $"https://{httpApiId}.execute-api.{_apiGatewayV2Client.Config.RegionEndpoint.SystemName}.amazonaws.com{route}"; + return url ; + } + + } } diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/HttpContextExtensionsTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/HttpContextExtensionsTests.cs new file mode 100644 index 000000000..f78165031 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/HttpContextExtensionsTests.cs @@ -0,0 +1,278 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Net; +using System.Text.Json; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.TestTool.Extensions; +using Amazon.Lambda.TestTool.IntegrationTests.Helpers; +using Amazon.Lambda.TestTool.Models; +using Amazon.Lambda.TestTool.UnitTests.Extensions; +using Microsoft.AspNetCore.Http; +using static Amazon.Lambda.TestTool.UnitTests.Extensions.HttpContextTestCases; + +namespace Amazon.Lambda.TestTool.IntegrationTests +{ + [Collection("ApiGateway Integration Tests")] + public class HttpContextExtensionsTests + { + private readonly ApiGatewayIntegrationTestFixture _fixture; + + public HttpContextExtensionsTests(ApiGatewayIntegrationTestFixture fixture) + { + _fixture = fixture; + } + + [Theory] + [MemberData(nameof(HttpContextTestCases.V1TestCases), MemberType = typeof(HttpContextTestCases))] + public async Task IntegrationTest_APIGatewayV1_REST(string testName, ApiGatewayTestCaseForRequest testCase) + { + var route = testCase.ApiGatewayRouteConfig?.Path ?? "/test"; + await _fixture.ApiGatewayHelper.AddRouteToRestApi(_fixture.ReturnFullEventRestApiId, _fixture.ReturnFullEventLambdaFunctionArn, route); + await RunApiGatewayTest(testCase, _fixture.ReturnFullEventRestApiUrl, + (context, config) => context.ToApiGatewayRequest(config), true); + } + + [Theory] + [MemberData(nameof(HttpContextTestCases.V1TestCases), MemberType = typeof(HttpContextTestCases))] + public async Task IntegrationTest_APIGatewayV1_HTTP(string testName, ApiGatewayTestCaseForRequest testCase) + { + var route = testCase.ApiGatewayRouteConfig?.Path ?? "/test"; + var routeKey = testCase.ApiGatewayRouteConfig?.HttpMethod ?? "POST"; + await _fixture.ApiGatewayHelper.AddRouteToHttpApi(_fixture.ReturnFullEventHttpApiV1Id, _fixture.ReturnFullEventLambdaFunctionArn, "1.0", route, routeKey); + await RunApiGatewayTest(testCase, _fixture.ReturnFullEventHttpApiV1Url, + (context, config) => context.ToApiGatewayRequest(config), false); + } + + [Theory] + [MemberData(nameof(HttpContextTestCases.V2TestCases), MemberType = typeof(HttpContextTestCases))] + public async Task IntegrationTest_APIGatewayV2(string testName, ApiGatewayTestCaseForRequest testCase) + { + var route = testCase.ApiGatewayRouteConfig?.Path ?? "/test"; + var routeKey = testCase.ApiGatewayRouteConfig?.HttpMethod ?? "POST"; + await _fixture.ApiGatewayHelper.AddRouteToHttpApi(_fixture.ReturnFullEventHttpApiV2Id, _fixture.ReturnFullEventLambdaFunctionArn, "2.0", route, routeKey); + await RunApiGatewayTest(testCase, _fixture.ReturnFullEventHttpApiV2Url, + (context, config) => context.ToApiGatewayHttpV2Request(config), false); + } + + private async Task RunApiGatewayTest(ApiGatewayTestCaseForRequest testCase, string apiUrl, Func toApiGatewayRequest, bool isRestAPI) + where T : class + { + var httpClient = new HttpClient(); + + var uri = new Uri(apiUrl); + var baseUrl = $"{uri.Scheme}://{uri.Authority}"; + var stageName = isRestAPI ? "/test" : ""; // matching hardcoded test stage name for rest api. TODO update this logic later to not be hardcoded + var actualPath = ResolveActualPath(testCase.ApiGatewayRouteConfig.Path, testCase.HttpContext.Request.Path); + var fullUrl = baseUrl + stageName + actualPath + testCase.HttpContext.Request.QueryString; + + + var httpRequest = CreateHttpRequestMessage(testCase.HttpContext, fullUrl); + + var response = await httpClient.SendAsync(httpRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.ToString()); + + var actualApiGatewayRequest = JsonSerializer.Deserialize(responseContent, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + var expectedApiGatewayRequest = toApiGatewayRequest(testCase.HttpContext, testCase.ApiGatewayRouteConfig); + + CompareApiGatewayRequests(expectedApiGatewayRequest, actualApiGatewayRequest); + + testCase.Assertions(actualApiGatewayRequest); + + await Task.Delay(1000); // Small delay between requests + } + + private void CompareApiGatewayRequests(T expected, T actual) where T : class + { + if (expected is APIGatewayProxyRequest v1Expected && actual is APIGatewayProxyRequest v1Actual) + { + CompareApiGatewayV1Requests(v1Expected, v1Actual); + } + else if (expected is APIGatewayHttpApiV2ProxyRequest v2Expected && actual is APIGatewayHttpApiV2ProxyRequest v2Actual) + { + CompareApiGatewayV2Requests(v2Expected, v2Actual); + } + else + { + throw new ArgumentException("Unsupported type for comparison"); + } + } + + private void CompareApiGatewayV1Requests(APIGatewayProxyRequest expected, APIGatewayProxyRequest actual) + { + Assert.Equal(expected.HttpMethod, actual.HttpMethod); + Assert.Equal(expected.Path, actual.Path); + Assert.Equal(expected.Resource, actual.Resource); + Assert.Equal(expected.Body, actual.Body); + Assert.Equal(expected.IsBase64Encoded, actual.IsBase64Encoded); + + CompareHeaders(expected.Headers, actual.Headers); + CompareMultiValueHeaders(expected.MultiValueHeaders, actual.MultiValueHeaders); + CompareDictionaries(expected.QueryStringParameters, actual.QueryStringParameters); + CompareDictionaries(expected.PathParameters, actual.PathParameters); + CompareDictionaries(expected.StageVariables, actual.StageVariables); + CompareDictionaries(expected.MultiValueQueryStringParameters, actual.MultiValueQueryStringParameters); + } + + private void CompareApiGatewayV2Requests(APIGatewayHttpApiV2ProxyRequest expected, APIGatewayHttpApiV2ProxyRequest actual) + { + Assert.Equal(expected.RouteKey, actual.RouteKey); + Assert.Equal(expected.RawPath, actual.RawPath); + Assert.Equal(expected.RawQueryString, actual.RawQueryString); + Assert.Equal(expected.Body, actual.Body); + Assert.Equal(expected.IsBase64Encoded, actual.IsBase64Encoded); + Assert.Equal(expected.Version, actual.Version); + + CompareHeaders(expected.Headers, actual.Headers); + CompareDictionaries(expected.QueryStringParameters, actual.QueryStringParameters); + CompareDictionaries(expected.PathParameters, actual.PathParameters); + CompareStringArrays(expected.Cookies, actual.Cookies); + + CompareRequestContexts(expected.RequestContext, actual.RequestContext); + } + + private void CompareHeaders(IDictionary expected, IDictionary actual) + { + var expectedFiltered = FilterHeaders(expected); + var actualFiltered = FilterHeaders(actual); + + Assert.Equal(expectedFiltered.Count, actualFiltered.Count); + + foreach (var kvp in expectedFiltered) + { + Assert.True(actualFiltered.Keys.Any(k => string.Equals(k, kvp.Key, StringComparison.OrdinalIgnoreCase)), + $"Actual headers do not contain key: {kvp.Key}"); + + var actualValue = actualFiltered.First(pair => string.Equals(pair.Key, kvp.Key, StringComparison.OrdinalIgnoreCase)).Value; + Assert.Equal(kvp.Value, actualValue); + } + } + + private void CompareMultiValueHeaders(IDictionary> expected, IDictionary> actual) + { + var expectedFiltered = FilterHeaders(expected); + var actualFiltered = FilterHeaders(actual); + + Assert.Equal(expectedFiltered.Count, actualFiltered.Count); + + foreach (var kvp in expectedFiltered) + { + Assert.True(actualFiltered.Keys.Any(k => string.Equals(k, kvp.Key, StringComparison.OrdinalIgnoreCase)), + $"Actual headers do not contain key: {kvp.Key}"); + + var actualValue = actualFiltered.First(pair => string.Equals(pair.Key, kvp.Key, StringComparison.OrdinalIgnoreCase)).Value; + Assert.Equal(kvp.Value, actualValue); + } + } + + private IDictionary FilterHeaders(IDictionary headers) + { + return headers.Where(kvp => + !(kvp.Key.ToString().StartsWith("x-forwarded-", StringComparison.OrdinalIgnoreCase) || // ignore these for now + kvp.Key.ToString().StartsWith("cloudfront-", StringComparison.OrdinalIgnoreCase) || // ignore these for now + kvp.Key.ToString().StartsWith("via-", StringComparison.OrdinalIgnoreCase) || // ignore these for now + kvp.Key.ToString().Equals("x-amzn-trace-id", StringComparison.OrdinalIgnoreCase) || // this is dynamic so ignoring for now + kvp.Key.ToString().Equals("cookie", StringComparison.OrdinalIgnoreCase) || // TODO may have to have api gateway v2 not set this in headers + kvp.Key.ToString().Equals("host", StringComparison.OrdinalIgnoreCase))) // TODO we may want to set this + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + + private void CompareDictionaries(IDictionary expected, IDictionary actual) + { + if (expected == null && actual == null) return; + Assert.Equal(expected.Count, actual.Count); + + foreach (var kvp in expected) + { + Assert.True(actual.ContainsKey(kvp.Key), $"Actual does not contain key: {kvp.Key}"); + Assert.Equal(kvp.Value, actual[kvp.Key]); + } + } + + private void CompareStringArrays(string[] expected, string[] actual) + { + Assert.Equal(expected?.Length, actual?.Length); + if (expected != null) + { + Assert.Equal(expected.OrderBy(x => x), actual.OrderBy(x => x)); + } + } + + private void CompareRequestContexts(APIGatewayHttpApiV2ProxyRequest.ProxyRequestContext expected, APIGatewayHttpApiV2ProxyRequest.ProxyRequestContext actual) + { + Assert.Equal(expected.RouteKey, actual.RouteKey); + + Assert.Equal(expected.Http.Method, actual.Http.Method); + Assert.Equal(expected.Http.Path, actual.Http.Path); + Assert.Equal(expected.Http.Protocol, actual.Http.Protocol); + Assert.Equal(expected.Http.UserAgent, actual.Http.UserAgent); + } + + private string ResolveActualPath(string routeWithPlaceholders, string actualPath) + { + var routeParts = routeWithPlaceholders.Split('/'); + var actualParts = actualPath.Split('/'); + + if (routeParts.Length != actualParts.Length) + { + throw new ArgumentException("Route and actual path have different number of segments"); + } + + var resolvedParts = new List(); + for (int i = 0; i < routeParts.Length; i++) + { + if (routeParts[i].StartsWith("{") && routeParts[i].EndsWith("}")) + { + resolvedParts.Add(actualParts[i]); + } + else + { + resolvedParts.Add(routeParts[i]); + } + } + + return string.Join("/", resolvedParts); + } + + private HttpRequestMessage CreateHttpRequestMessage(HttpContext context, string fullUrl) + { + var request = context.Request; + var httpRequest = new HttpRequestMessage(new HttpMethod(request.Method), fullUrl); + + foreach (var header in request.Headers) + { + httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + + if (request.ContentLength > 0) + { + var bodyStream = new MemoryStream(); + request.Body.CopyTo(bodyStream); + bodyStream.Position = 0; + httpRequest.Content = new StreamContent(bodyStream); + + // Set Content-Type if present in the original request + if (request.ContentType != null) + { + httpRequest.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(request.ContentType); + } + } + else + { + httpRequest.Content = new StringContent(string.Empty); + } + + + + httpRequest.Version = HttpVersion.Version11; + + return httpRequest; + } + } +} 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 index 5ae0623ec..abd947b6f 100644 --- 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 @@ -3,10 +3,10 @@ Description: 'CloudFormation template for API Gateway and Lambda integration tes Resources: - TestLambdaFunction: + ParseAndReturnBodyLambdaFunction: Type: 'AWS::Lambda::Function' Properties: - FunctionName: !Sub '${AWS::StackName}-TestFunction' + FunctionName: !Sub '${AWS::StackName}-ParseAndReturnBodyFunction' Handler: index.handler Role: !GetAtt LambdaExecutionRole.Arn Code: @@ -16,10 +16,10 @@ Resources: }; Runtime: nodejs20.x - BinaryLambdaFunction: + ReturnDecodedParseBinLambdaFunction: Type: 'AWS::Lambda::Function' Properties: - FunctionName: !Sub '${AWS::StackName}-BinaryFunction' + FunctionName: !Sub '${AWS::StackName}-ReturnDecodedParseBinFunction' Handler: index.handler Role: !GetAtt LambdaExecutionRole.Arn Code: @@ -31,10 +31,10 @@ Resources: }; Runtime: nodejs20.x - ReturnRawRequestBodyLambdaFunction: + ReturnRawBodyLambdaFunction: Type: 'AWS::Lambda::Function' Properties: - FunctionName: !Sub '${AWS::StackName}-ReturnRawRequestBodyFunction' + FunctionName: !Sub '${AWS::StackName}-ReturnRawBodyFunction' Handler: index.handler Role: !GetAtt LambdaExecutionRole.Arn Code: @@ -45,6 +45,25 @@ Resources: }; Runtime: nodejs20.x + ReturnFullEventLambdaFunction: + Type: 'AWS::Lambda::Function' + Properties: + FunctionName: !Sub '${AWS::StackName}-ReturnFullEventFunction' + Handler: index.handler + Role: !GetAtt LambdaExecutionRole.Arn + Code: + ZipFile: | + exports.handler = async (event) => { + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(event) + }; + }; + Runtime: nodejs20.x + LambdaExecutionRole: Type: 'AWS::IAM::Role' Properties: @@ -58,240 +77,491 @@ Resources: ManagedPolicyArns: - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' - RestApi: + # ParseAndReturnBody APIs + ParseAndReturnBodyRestApi: Type: 'AWS::ApiGateway::RestApi' Properties: - Name: !Sub '${AWS::StackName}-RestAPI' + Name: !Sub '${AWS::StackName}-ParseAndReturnBodyRestAPI' - RestApiResource: + ParseAndReturnBodyRestApiResource: Type: 'AWS::ApiGateway::Resource' Properties: - ParentId: !GetAtt RestApi.RootResourceId + ParentId: !GetAtt ParseAndReturnBodyRestApi.RootResourceId PathPart: 'test' - RestApiId: !Ref RestApi + RestApiId: !Ref ParseAndReturnBodyRestApi - RestApiMethod: + ParseAndReturnBodyRestApiMethod: Type: 'AWS::ApiGateway::Method' Properties: HttpMethod: POST - ResourceId: !Ref RestApiResource - RestApiId: !Ref RestApi + ResourceId: !Ref ParseAndReturnBodyRestApiResource + RestApiId: !Ref ParseAndReturnBodyRestApi AuthorizationType: NONE Integration: Type: AWS_PROXY IntegrationHttpMethod: POST - Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TestLambdaFunction.Arn}/invocations' + Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ParseAndReturnBodyLambdaFunction.Arn}/invocations' - RestApiDeployment: + ParseAndReturnBodyRestApiDeployment: Type: 'AWS::ApiGateway::Deployment' - DependsOn: RestApiMethod + DependsOn: ParseAndReturnBodyRestApiMethod Properties: - RestApiId: !Ref RestApi + RestApiId: !Ref ParseAndReturnBodyRestApi StageName: 'test' - HttpApiV1: + ParseAndReturnBodyHttpApiV1: Type: 'AWS::ApiGatewayV2::Api' Properties: - Name: !Sub '${AWS::StackName}-HttpAPIv1' + Name: !Sub '${AWS::StackName}-ParseAndReturnBodyHttpAPIv1' ProtocolType: HTTP - HttpApiV1Integration: + ParseAndReturnBodyHttpApiV1Integration: Type: 'AWS::ApiGatewayV2::Integration' Properties: - ApiId: !Ref HttpApiV1 + ApiId: !Ref ParseAndReturnBodyHttpApiV1 IntegrationType: AWS_PROXY - IntegrationUri: !GetAtt TestLambdaFunction.Arn + IntegrationUri: !GetAtt ParseAndReturnBodyLambdaFunction.Arn PayloadFormatVersion: '1.0' - HttpApiV1Route: + ParseAndReturnBodyHttpApiV1Route: Type: 'AWS::ApiGatewayV2::Route' Properties: - ApiId: !Ref HttpApiV1 + ApiId: !Ref ParseAndReturnBodyHttpApiV1 RouteKey: 'POST /test' Target: !Join - / - - integrations - - !Ref HttpApiV1Integration + - !Ref ParseAndReturnBodyHttpApiV1Integration - HttpApiV1Stage: + ParseAndReturnBodyHttpApiV1Stage: Type: 'AWS::ApiGatewayV2::Stage' Properties: - ApiId: !Ref HttpApiV1 + ApiId: !Ref ParseAndReturnBodyHttpApiV1 StageName: '$default' AutoDeploy: true - HttpApiV2: + ParseAndReturnBodyHttpApiV2: Type: 'AWS::ApiGatewayV2::Api' Properties: - Name: !Sub '${AWS::StackName}-HttpAPIv2' + Name: !Sub '${AWS::StackName}-ParseAndReturnBodyHttpAPIv2' ProtocolType: HTTP - HttpApiV2Integration: + ParseAndReturnBodyHttpApiV2Integration: Type: 'AWS::ApiGatewayV2::Integration' Properties: - ApiId: !Ref HttpApiV2 + ApiId: !Ref ParseAndReturnBodyHttpApiV2 IntegrationType: AWS_PROXY - IntegrationUri: !GetAtt TestLambdaFunction.Arn + IntegrationUri: !GetAtt ParseAndReturnBodyLambdaFunction.Arn PayloadFormatVersion: '2.0' - HttpApiV2Route: + ParseAndReturnBodyHttpApiV2Route: + Type: 'AWS::ApiGatewayV2::Route' + Properties: + ApiId: !Ref ParseAndReturnBodyHttpApiV2 + RouteKey: 'POST /test' + Target: !Join + - / + - - integrations + - !Ref ParseAndReturnBodyHttpApiV2Integration + + ParseAndReturnBodyHttpApiV2Stage: + Type: 'AWS::ApiGatewayV2::Stage' + Properties: + ApiId: !Ref ParseAndReturnBodyHttpApiV2 + StageName: '$default' + AutoDeploy: true + + # ReturnRawBody APIs + ReturnRawBodyRestApi: + Type: 'AWS::ApiGateway::RestApi' + Properties: + Name: !Sub '${AWS::StackName}-ReturnRawBodyRestAPI' + + ReturnRawBodyRestApiResource: + Type: 'AWS::ApiGateway::Resource' + Properties: + ParentId: !GetAtt ReturnRawBodyRestApi.RootResourceId + PathPart: 'test' + RestApiId: !Ref ReturnRawBodyRestApi + + ReturnRawBodyRestApiMethod: + Type: 'AWS::ApiGateway::Method' + Properties: + HttpMethod: POST + ResourceId: !Ref ReturnRawBodyRestApiResource + RestApiId: !Ref ReturnRawBodyRestApi + AuthorizationType: NONE + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ReturnRawBodyLambdaFunction.Arn}/invocations' + + ReturnRawBodyRestApiDeployment: + Type: 'AWS::ApiGateway::Deployment' + DependsOn: ReturnRawBodyRestApiMethod + Properties: + RestApiId: !Ref ReturnRawBodyRestApi + StageName: 'test' + + ReturnRawBodyHttpApiV1: + Type: 'AWS::ApiGatewayV2::Api' + Properties: + Name: !Sub '${AWS::StackName}-ReturnRawBodyHttpAPIv1' + ProtocolType: HTTP + + ReturnRawBodyHttpApiV1Integration: + Type: 'AWS::ApiGatewayV2::Integration' + Properties: + ApiId: !Ref ReturnRawBodyHttpApiV1 + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt ReturnRawBodyLambdaFunction.Arn + PayloadFormatVersion: '1.0' + + ReturnRawBodyHttpApiV1Route: + Type: 'AWS::ApiGatewayV2::Route' + Properties: + ApiId: !Ref ReturnRawBodyHttpApiV1 + RouteKey: 'POST /test' + Target: !Join + - / + - - integrations + - !Ref ReturnRawBodyHttpApiV1Integration + + ReturnRawBodyHttpApiV1Stage: + Type: 'AWS::ApiGatewayV2::Stage' + Properties: + ApiId: !Ref ReturnRawBodyHttpApiV1 + StageName: '$default' + AutoDeploy: true + + ReturnRawBodyHttpApiV2: + Type: 'AWS::ApiGatewayV2::Api' + Properties: + Name: !Sub '${AWS::StackName}-ReturnRawBodyHttpAPIv2' + ProtocolType: HTTP + + ReturnRawBodyHttpApiV2Integration: + Type: 'AWS::ApiGatewayV2::Integration' + Properties: + ApiId: !Ref ReturnRawBodyHttpApiV2 + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt ReturnRawBodyLambdaFunction.Arn + PayloadFormatVersion: '2.0' + + ReturnRawBodyHttpApiV2Route: + Type: 'AWS::ApiGatewayV2::Route' + Properties: + ApiId: !Ref ReturnRawBodyHttpApiV2 + RouteKey: 'POST /test' + Target: !Join + - / + - - integrations + - !Ref ReturnRawBodyHttpApiV2Integration + + ReturnRawBodyHttpApiV2Stage: + Type: 'AWS::ApiGatewayV2::Stage' + Properties: + ApiId: !Ref ReturnRawBodyHttpApiV2 + StageName: '$default' + AutoDeploy: true + + # ReturnFullEvent APIs + ReturnFullEventRestApi: + Type: 'AWS::ApiGateway::RestApi' + Properties: + Name: !Sub '${AWS::StackName}-ReturnFullEventRestAPI' + + ReturnFullEventRestApiResource: + Type: 'AWS::ApiGateway::Resource' + Properties: + ParentId: !GetAtt ReturnFullEventRestApi.RootResourceId + PathPart: 'test' + RestApiId: !Ref ReturnFullEventRestApi + + ReturnFullEventRestApiMethod: + Type: 'AWS::ApiGateway::Method' + Properties: + HttpMethod: POST + ResourceId: !Ref ReturnFullEventRestApiResource + RestApiId: !Ref ReturnFullEventRestApi + AuthorizationType: NONE + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ReturnFullEventLambdaFunction.Arn}/invocations' + + ReturnFullEventRestApiDeployment: + Type: 'AWS::ApiGateway::Deployment' + DependsOn: ReturnFullEventRestApiMethod + Properties: + RestApiId: !Ref ReturnFullEventRestApi + StageName: 'test' + + ReturnFullEventHttpApiV1: + Type: 'AWS::ApiGatewayV2::Api' + Properties: + Name: !Sub '${AWS::StackName}-ReturnFullEventHttpAPIv1' + ProtocolType: HTTP + + ReturnFullEventHttpApiV1Integration: + Type: 'AWS::ApiGatewayV2::Integration' + Properties: + ApiId: !Ref ReturnFullEventHttpApiV1 + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt ReturnFullEventLambdaFunction.Arn + PayloadFormatVersion: '1.0' + + ReturnFullEventHttpApiV1Route: Type: 'AWS::ApiGatewayV2::Route' Properties: - ApiId: !Ref HttpApiV2 + ApiId: !Ref ReturnFullEventHttpApiV1 RouteKey: 'POST /test' Target: !Join - / - - integrations - - !Ref HttpApiV2Integration + - !Ref ReturnFullEventHttpApiV1Integration - HttpApiV2Stage: + ReturnFullEventHttpApiV1Stage: Type: 'AWS::ApiGatewayV2::Stage' Properties: - ApiId: !Ref HttpApiV2 + ApiId: !Ref ReturnFullEventHttpApiV1 StageName: '$default' AutoDeploy: true - ReturnRawRequestBodyHttpApi: + ReturnFullEventHttpApiV2: Type: 'AWS::ApiGatewayV2::Api' Properties: - Name: !Sub '${AWS::StackName}-ReturnRawRequestBodyHttpAPI' + Name: !Sub '${AWS::StackName}-ReturnFullEventHttpAPIv2' ProtocolType: HTTP - ReturnRawRequestBodyHttpApiIntegration: + ReturnFullEventHttpApiV2Integration: Type: 'AWS::ApiGatewayV2::Integration' Properties: - ApiId: !Ref ReturnRawRequestBodyHttpApi + ApiId: !Ref ReturnFullEventHttpApiV2 IntegrationType: AWS_PROXY - IntegrationUri: !GetAtt ReturnRawRequestBodyLambdaFunction.Arn + IntegrationUri: !GetAtt ReturnFullEventLambdaFunction.Arn PayloadFormatVersion: '2.0' - ReturnRawRequestBodyHttpApiRoute: + ReturnFullEventHttpApiV2Route: Type: 'AWS::ApiGatewayV2::Route' Properties: - ApiId: !Ref ReturnRawRequestBodyHttpApi - RouteKey: 'POST /' + ApiId: !Ref ReturnFullEventHttpApiV2 + RouteKey: 'POST /test' Target: !Join - / - - integrations - - !Ref ReturnRawRequestBodyHttpApiIntegration + - !Ref ReturnFullEventHttpApiV2Integration - ReturnRawRequestBodyHttpApiStage: + ReturnFullEventHttpApiV2Stage: Type: 'AWS::ApiGatewayV2::Stage' Properties: - ApiId: !Ref ReturnRawRequestBodyHttpApi + ApiId: !Ref ReturnFullEventHttpApiV2 StageName: '$default' AutoDeploy: true - LambdaPermissionRestApi: + # Lambda Permissions + LambdaPermissionParseAndReturnBodyRestApi: Type: 'AWS::Lambda::Permission' Properties: Action: 'lambda:InvokeFunction' - FunctionName: !GetAtt TestLambdaFunction.Arn + FunctionName: !GetAtt ParseAndReturnBodyLambdaFunction.Arn Principal: apigateway.amazonaws.com - SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApi}/*' + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ParseAndReturnBodyRestApi}/*' - LambdaPermissionHttpApiV1: + LambdaPermissionParseAndReturnBodyHttpApiV1: Type: 'AWS::Lambda::Permission' Properties: Action: 'lambda:InvokeFunction' - FunctionName: !GetAtt TestLambdaFunction.Arn + FunctionName: !GetAtt ParseAndReturnBodyLambdaFunction.Arn Principal: apigateway.amazonaws.com - SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApiV1}/*' + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ParseAndReturnBodyHttpApiV1}/*' - LambdaPermissionHttpApiV2: + LambdaPermissionParseAndReturnBodyHttpApiV2: Type: 'AWS::Lambda::Permission' Properties: Action: 'lambda:InvokeFunction' - FunctionName: !GetAtt TestLambdaFunction.Arn + FunctionName: !GetAtt ParseAndReturnBodyLambdaFunction.Arn Principal: apigateway.amazonaws.com - SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApiV2}/*' + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ParseAndReturnBodyHttpApiV2}/*' - LambdaPermissionReturnRawRequestBodyHttpApi: + LambdaPermissionReturnRawBodyRestApi: Type: 'AWS::Lambda::Permission' Properties: Action: 'lambda:InvokeFunction' - FunctionName: !GetAtt ReturnRawRequestBodyLambdaFunction.Arn + FunctionName: !GetAtt ReturnRawBodyLambdaFunction.Arn Principal: apigateway.amazonaws.com - SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ReturnRawRequestBodyHttpApi}/*' + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ReturnRawBodyRestApi}/*' - BinaryMediaRestApi: + LambdaPermissionReturnRawBodyHttpApiV1: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt ReturnRawBodyLambdaFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ReturnRawBodyHttpApiV1}/*' + + LambdaPermissionReturnRawBodyHttpApiV2: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt ReturnRawBodyLambdaFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ReturnRawBodyHttpApiV2}/*' + + LambdaPermissionReturnFullEventRestApi: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt ReturnFullEventLambdaFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ReturnFullEventRestApi}/*' + + LambdaPermissionReturnFullEventHttpApiV1: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt ReturnFullEventLambdaFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ReturnFullEventHttpApiV1}/*' + + LambdaPermissionReturnFullEventHttpApiV2: + Type: 'AWS::Lambda::Permission' + Properties: + Action: 'lambda:InvokeFunction' + FunctionName: !GetAtt ReturnFullEventLambdaFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ReturnFullEventHttpApiV2}/*' + + ReturnDecodedParseBinRestApi: Type: 'AWS::ApiGateway::RestApi' Properties: - Name: !Sub '${AWS::StackName}-BinaryMediaRestAPI' + Name: !Sub '${AWS::StackName}-ReturnDecodedParseBinRestAPI' BinaryMediaTypes: - '*/*' - BinaryMediaRestApiResource: + ReturnDecodedParseBinRestApiResource: Type: 'AWS::ApiGateway::Resource' Properties: - ParentId: !GetAtt BinaryMediaRestApi.RootResourceId + ParentId: !GetAtt ReturnDecodedParseBinRestApi.RootResourceId PathPart: 'test' - RestApiId: !Ref BinaryMediaRestApi + RestApiId: !Ref ReturnDecodedParseBinRestApi - BinaryMediaRestApiMethod: + ReturnDecodedParseBinRestApiMethod: Type: 'AWS::ApiGateway::Method' Properties: HttpMethod: POST - ResourceId: !Ref BinaryMediaRestApiResource - RestApiId: !Ref BinaryMediaRestApi + ResourceId: !Ref ReturnDecodedParseBinRestApiResource + RestApiId: !Ref ReturnDecodedParseBinRestApi AuthorizationType: NONE Integration: Type: AWS_PROXY IntegrationHttpMethod: POST - Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${BinaryLambdaFunction.Arn}/invocations' + Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ReturnDecodedParseBinLambdaFunction.Arn}/invocations' - BinaryMediaRestApiDeployment: + ReturnDecodedParseBinRestApiDeployment: Type: 'AWS::ApiGateway::Deployment' - DependsOn: BinaryMediaRestApiMethod + DependsOn: ReturnDecodedParseBinRestApiMethod Properties: - RestApiId: !Ref BinaryMediaRestApi + RestApiId: !Ref ReturnDecodedParseBinRestApi StageName: 'test' - LambdaPermissionBinaryMediaRestApi: + LambdaPermissionReturnDecodedParseBinRestApi: Type: 'AWS::Lambda::Permission' Properties: Action: 'lambda:InvokeFunction' - FunctionName: !GetAtt BinaryLambdaFunction.Arn + FunctionName: !GetAtt ReturnDecodedParseBinLambdaFunction.Arn Principal: apigateway.amazonaws.com - SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${BinaryMediaRestApi}/*' + SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ReturnDecodedParseBinRestApi}/*' Outputs: - RestApiId: - Description: 'ID of the REST API' - Value: !Ref RestApi + ParseAndReturnBodyRestApiId: + Description: 'ID of the Parse and Return Body REST API' + Value: !Ref ParseAndReturnBodyRestApi + + ParseAndReturnBodyRestApiUrl: + Description: 'URL of the Parse and Return Body REST API' + Value: !Sub 'https://${ParseAndReturnBodyRestApi}.execute-api.${AWS::Region}.amazonaws.com/test/test' + + ParseAndReturnBodyHttpApiV1Id: + Description: 'ID of the Parse and Return Body HTTP API v1' + Value: !Ref ParseAndReturnBodyHttpApiV1 + + ParseAndReturnBodyHttpApiV1Url: + Description: 'URL of the Parse and Return Body HTTP API v1' + Value: !Sub 'https://${ParseAndReturnBodyHttpApiV1}.execute-api.${AWS::Region}.amazonaws.com/test' + + ParseAndReturnBodyHttpApiV2Id: + Description: 'ID of the Parse and Return Body HTTP API v2' + Value: !Ref ParseAndReturnBodyHttpApiV2 + + ParseAndReturnBodyHttpApiV2Url: + Description: 'URL of the Parse and Return Body HTTP API v2' + Value: !Sub 'https://${ParseAndReturnBodyHttpApiV2}.execute-api.${AWS::Region}.amazonaws.com/test' + + ReturnRawBodyRestApiId: + Description: 'ID of the Return Raw Body REST API' + Value: !Ref ReturnRawBodyRestApi + + ReturnRawBodyRestApiUrl: + Description: 'URL of the Return Raw Body REST API' + Value: !Sub 'https://${ReturnRawBodyRestApi}.execute-api.${AWS::Region}.amazonaws.com/test/test' + + ReturnRawBodyHttpApiV1Id: + Description: 'ID of the Return Raw Body HTTP API v1' + Value: !Ref ReturnRawBodyHttpApiV1 + + ReturnRawBodyHttpApiV1Url: + Description: 'URL of the Return Raw Body HTTP API v1' + Value: !Sub 'https://${ReturnRawBodyHttpApiV1}.execute-api.${AWS::Region}.amazonaws.com/test' + + ReturnRawBodyHttpApiV2Id: + Description: 'ID of the Return Raw Body HTTP API v2' + Value: !Ref ReturnRawBodyHttpApiV2 + + ReturnRawBodyHttpApiV2Url: + Description: 'URL of the Return Raw Body HTTP API v2' + Value: !Sub 'https://${ReturnRawBodyHttpApiV2}.execute-api.${AWS::Region}.amazonaws.com/test' + + ReturnFullEventRestApiId: + Description: 'ID of the Return Full Event REST API' + Value: !Ref ReturnFullEventRestApi + + ReturnFullEventRestApiUrl: + Description: 'URL of the Return Full Event REST API' + Value: !Sub 'https://${ReturnFullEventRestApi}.execute-api.${AWS::Region}.amazonaws.com/test/test' - RestApiUrl: - Description: 'URL of the REST API' - Value: !Sub 'https://${RestApi}.execute-api.${AWS::Region}.amazonaws.com/test/test' + ReturnFullEventHttpApiV1Id: + Description: 'ID of the Return Full Event HTTP API v1' + Value: !Ref ReturnFullEventHttpApiV1 - HttpApiV1Id: - Description: 'ID of the HTTP API v1' - Value: !Ref HttpApiV1 + ReturnFullEventHttpApiV1Url: + Description: 'URL of the Return Full Event HTTP API v1' + Value: !Sub 'https://${ReturnFullEventHttpApiV1}.execute-api.${AWS::Region}.amazonaws.com/test' - HttpApiV1Url: - Description: 'URL of the HTTP API v1' - Value: !Sub 'https://${HttpApiV1}.execute-api.${AWS::Region}.amazonaws.com/test' + ReturnFullEventHttpApiV2Id: + Description: 'ID of the Return Full Event HTTP API v2' + Value: !Ref ReturnFullEventHttpApiV2 - HttpApiV2Id: - Description: 'ID of the HTTP API v2' - Value: !Ref HttpApiV2 + ReturnFullEventHttpApiV2Url: + Description: 'URL of the Return Full Event HTTP API v2' + Value: !Sub 'https://${ReturnFullEventHttpApiV2}.execute-api.${AWS::Region}.amazonaws.com/test' - HttpApiV2Url: - Description: 'URL of the HTTP API v2' - Value: !Sub 'https://${HttpApiV2}.execute-api.${AWS::Region}.amazonaws.com/test' + ParseAndReturnBodyLambdaFunctionArn: + Description: 'ARN of the Parse and Return Body Lambda Function' + Value: !GetAtt ParseAndReturnBodyLambdaFunction.Arn - ReturnRawRequestBodyHttpApiId: - Description: 'ID of the JSON Inference HTTP API' - Value: !Ref ReturnRawRequestBodyHttpApi + ReturnRawBodyLambdaFunctionArn: + Description: 'ARN of the Return Raw Body Lambda Function' + Value: !GetAtt ReturnRawBodyLambdaFunction.Arn - ReturnRawRequestBodyHttpApiUrl: - Description: 'URL of the JSON Inference HTTP API' - Value: !Sub 'https://${ReturnRawRequestBodyHttpApi}.execute-api.${AWS::Region}.amazonaws.com/' + ReturnFullEventLambdaFunctionArn: + Description: 'ARN of the Return Full Event Lambda Function' + Value: !GetAtt ReturnFullEventLambdaFunction.Arn - BinaryMediaRestApiId: - Description: 'ID of the Binary Media REST API' - Value: !Ref BinaryMediaRestApi + ReturnDecodedParseBinRestApiId: + Description: 'ID of the ReturnDecodedParseBin Media REST API' + Value: !Ref ReturnDecodedParseBinRestApi - BinaryMediaRestApiUrl: - Description: 'URL of the Binary Media REST API' - Value: !Sub 'https://${BinaryMediaRestApi}.execute-api.${AWS::Region}.amazonaws.com/test/test' + ReturnDecodedParseBinRestApiUrl: + Description: 'URL of the ReturnDecodedParseBin Media REST API' + Value: !Sub 'https://${ReturnDecodedParseBinRestApi}.execute-api.${AWS::Region}.amazonaws.com/test/test' diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextExtensionsTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextExtensionsTests.cs new file mode 100644 index 000000000..70cb27ad2 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextExtensionsTests.cs @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Amazon.Lambda.TestTool.Extensions; +using static Amazon.Lambda.TestTool.UnitTests.Extensions.HttpContextTestCases; + +namespace Amazon.Lambda.TestTool.UnitTests.Extensions +{ + public class HttpContextExtensionsTests + { + [Theory] + [MemberData(nameof(HttpContextTestCases.V1TestCases), MemberType = typeof(HttpContextTestCases))] + public void ToApiGatewayRequest_ConvertsCorrectly(string testName, ApiGatewayTestCaseForRequest testCase) + { + // Arrange + var context = testCase.HttpContext; + + // Act + var result = context.ToApiGatewayRequest(testCase.ApiGatewayRouteConfig); + + // Assert + testCase.Assertions(result); + } + + [Theory] + [MemberData(nameof(HttpContextTestCases.V2TestCases), MemberType = typeof(HttpContextTestCases))] + public void ToApiGatewayHttpV2Request_ConvertsCorrectly(string testName, ApiGatewayTestCaseForRequest testCase) + { + // Arrange + var context = testCase.HttpContext; + + // Act + var result = context.ToApiGatewayHttpV2Request(testCase.ApiGatewayRouteConfig); + + // Assert + testCase.Assertions(result); + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextTestCases.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextTestCases.cs new file mode 100644 index 000000000..966a00f55 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextTestCases.cs @@ -0,0 +1,512 @@ +// 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.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Amazon.Lambda.TestTool.UnitTests.Extensions +{ + public static class HttpContextTestCases + { + public static IEnumerable V1TestCases() + { + yield return new object[] + { + "V1_SimpleGetRequest", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test1/api/users/123/orders", + new Dictionary + { + { "User-Agent", "TestAgent" }, + { "Accept", new StringValues(new[] { "text/html", "application/json" }) }, + { "Cookie", "session=abc123; theme=dark" }, + { "X-Custom-Header", "value1" } + }, + queryString: "?status=pending&tag=important&tag=urgent"), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + Endpoint = "/test1/api/users/{userId}/orders", + HttpMethod = "POST", + Path = "/test1/api/users/{userId}/orders" + }, + Assertions = (result) => + { + var v1Result = Assert.IsType(result); + Assert.Equal("/test1/api/users/{userId}/orders", v1Result.Resource); + Assert.Equal("/test1/api/users/123/orders", v1Result.Path); + Assert.Equal("POST", v1Result.HttpMethod); + Assert.Equal("TestAgent", v1Result.Headers["user-agent"]); + Assert.Equal("application/json", v1Result.Headers["accept"]); + Assert.Equal(new List { "text/html", "application/json" }, v1Result.MultiValueHeaders["accept"]); Assert.Equal("session=abc123; theme=dark", v1Result.Headers["cookie"]); + Assert.Equal("value1", v1Result.Headers["x-custom-header"]); + Assert.Equal(new List { "TestAgent" }, v1Result.MultiValueHeaders["user-agent"]); + Assert.Equal(new List { "text/html", "application/json" }, v1Result.MultiValueHeaders["accept"]); + Assert.Equal(new List { "session=abc123; theme=dark" }, v1Result.MultiValueHeaders["cookie"]); + Assert.Equal(new List { "value1" }, v1Result.MultiValueHeaders["x-custom-header"]); + Assert.Equal("pending", v1Result.QueryStringParameters["status"]); + Assert.Equal("urgent", v1Result.QueryStringParameters["tag"]); + Assert.Equal(new List { "pending" }, v1Result.MultiValueQueryStringParameters["status"]); + Assert.Equal(new List { "important", "urgent" }, v1Result.MultiValueQueryStringParameters["tag"]); + Assert.Equal("123", v1Result.PathParameters["userId"]); + Assert.Null(v1Result.Body); + Assert.False(v1Result.IsBase64Encoded); + } + } + }; + + yield return new object[] + { + "V1_EmptyCollections", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test2/api/notmatchingpath/123/orders"), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + Endpoint = "/test2/api/users/{userId}/orders", + HttpMethod = "POST", + Path = "/test2/api/users/{userId}/orders" + }, + Assertions = (result) => + { + var v1Result = Assert.IsType(result); + Assert.Equal(2, v1Result.Headers.Count); + Assert.Equal("0", v1Result.Headers["content-length"]); + Assert.Equal("text/plain; charset=utf-8", v1Result.Headers["content-type"]); + Assert.Equal(new List { "0" }, v1Result.MultiValueHeaders["content-length"]); + Assert.Equal(new List { "text/plain; charset=utf-8" }, v1Result.MultiValueHeaders["content-type"]); + Assert.Null(v1Result.QueryStringParameters); + Assert.Null(v1Result.MultiValueQueryStringParameters); + Assert.Null(v1Result.PathParameters); + } + } + }; + + yield return new object[] + { + "V1_BinaryContent", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test3/api/users/123/avatar", + new Dictionary { { "Content-Type", "application/octet-stream" } }, + body: new byte[] { 1, 2, 3, 4, 5 }), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "UploadAvatarFunction", + Endpoint = "/test3/api/users/{userId}/avatar", + HttpMethod = "POST", + Path = "/test3/api/users/{userId}/avatar" + }, + Assertions = (result) => + { + var v1Result = Assert.IsType(result); + Assert.True(v1Result.IsBase64Encoded); + Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5 }), v1Result.Body); + Assert.Equal("123", v1Result.PathParameters["userId"]); + Assert.Equal("/test3/api/users/{userId}/avatar", v1Result.Resource); + Assert.Equal("POST", v1Result.HttpMethod); + } + } + }; + + yield return new object[] + { + "V1_UrlEncodedQueryString", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test4/api/search", + queryString: "?q=Hello%20World&tag=C%23%20Programming&tag=.NET%20Core"), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "SearchFunction", + Endpoint = "/test4/api/search", + HttpMethod = "POST", + Path = "/test4/api/search" + }, + Assertions = (result) => + { + var v1Result = Assert.IsType(result); + Assert.Equal("Hello World", v1Result.QueryStringParameters["q"]); + Assert.Equal(".NET Core", v1Result.QueryStringParameters["tag"]); + Assert.Equal(new List { "Hello World" }, v1Result.MultiValueQueryStringParameters["q"]); + Assert.Equal(new List { "C# Programming", ".NET Core" }, v1Result.MultiValueQueryStringParameters["tag"]); + } + } + }; + + yield return new object[] + { + "V1_SpecialCharactersInPath", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test5/api/users/****%20Doe/orders/Summer%20Sale%202023"), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "UserOrdersFunction", + Endpoint = "/test5/api/users/{username}/orders/{orderName}", + HttpMethod = "POST", + Path = "/test5/api/users/{username}/orders/{orderName}" + }, + Assertions = (result) => + { + var v1Result = Assert.IsType(result); + Assert.Equal("/test5/api/users/**** Doe/orders/Summer Sale 2023", v1Result.Path); + Assert.Equal("**** Doe", v1Result.PathParameters["username"]); + Assert.Equal("Summer Sale 2023", v1Result.PathParameters["orderName"]); + } + } + }; + + yield return new object[] + { + "V1_UnicodeCharactersInPath", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test6/api/products/%E2%98%95%20Coffee/reviews/%F0%9F%98%8A%20Happy"), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "ProductReviewsFunction", + Endpoint = "/test6/api/products/{productName}/reviews/{reviewTitle}", + HttpMethod = "POST", + Path = "/test6/api/products/{productName}/reviews/{reviewTitle}" + }, + Assertions = (result) => + { + var v1Result = Assert.IsType(result); + Assert.Equal("/test6/api/products/☕ Coffee/reviews/😊 Happy", v1Result.Path); + Assert.Equal("☕ Coffee", v1Result.PathParameters["productName"]); + Assert.Equal("😊 Happy", v1Result.PathParameters["reviewTitle"]); + } + } + }; + + yield return new object[] + { + "V1_EncodedAndUnicodeHeaders", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test7/api/test", + new Dictionary + { + { "X-Encoded-Header", "value%20with%20spaces" }, + { "X-Unicode-Header", "☕ Coffee" }, + { "X-Mixed-Header", "Hello%2C%20World%21%20☕" }, + { "X-Raw-Unicode", "\u2615 Coffee" } + }), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestFunction", + Endpoint = "/test7/api/test", + HttpMethod = "POST", + Path = "/test7/api/test" + }, + Assertions = (result) => + { + var v1Result = Assert.IsType(result); + Assert.Equal("value%20with%20spaces", v1Result.Headers["x-encoded-header"]); + Assert.Equal("☕ Coffee", v1Result.Headers["x-unicode-header"]); + Assert.Equal("Hello%2C%20World%21%20☕", v1Result.Headers["x-mixed-header"]); + Assert.Equal("\u2615 Coffee", v1Result.Headers["x-raw-unicode"]); + Assert.Equal(new List { "value%20with%20spaces" }, v1Result.MultiValueHeaders["x-encoded-header"]); + Assert.Equal(new List { "☕ Coffee" }, v1Result.MultiValueHeaders["x-unicode-header"]); + Assert.Equal(new List { "Hello%2C%20World%21%20☕" }, v1Result.MultiValueHeaders["x-mixed-header"]); + Assert.Equal(new List { "\u2615 Coffee" }, v1Result.MultiValueHeaders["x-raw-unicode"]); + } + } + }; + + yield return new object[] + { + "V1_MultipleHeaderValuesWithUnicode", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test8/api/test", + new Dictionary + { + { "X-Multi-Value", new StringValues(new[] { "value1", "value2%20with%20spaces", "☕ Coffee", "value4%20☕" }) } + }), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestFunction", + Endpoint = "/test8/api/test", + HttpMethod = "POST", + Path = "/test8/api/test" + }, + Assertions = (result) => + { + var v1Result = Assert.IsType(result); + Assert.Equal("value4%20☕", v1Result.Headers["x-multi-value"]); + Assert.Equal( + new List { "value1", "value2%20with%20spaces", "☕ Coffee", "value4%20☕" }, + v1Result.MultiValueHeaders["x-multi-value"] + ); + } + } + }; + } + + public static IEnumerable V2TestCases() + { + yield return new object[] + { + "V2_SimpleGetRequest", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test9/api/users/123/orders", + new Dictionary + { + { "user-agent", "TestAgent" }, + { "accept", new StringValues(new[] { "text/html", "application/json" }) }, + { "cookie", "session=abc123; theme=dark" }, + { "x-custom-Header", "value1" } + }, + queryString: "?status=pending&tag=important&tag=urgent"), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + Endpoint = "/test9/api/users/{userId}/orders", + HttpMethod = "POST", + Path = "/test9/api/users/{userId}/orders" + }, + Assertions = (result) => + { + var v2Result = Assert.IsType(result); + Assert.Equal("POST /test9/api/users/{userId}/orders", v2Result.RouteKey); + Assert.Equal("/test9/api/users/123/orders", v2Result.RawPath); + Assert.Equal("status=pending&tag=important&tag=urgent", v2Result.RawQueryString); + Assert.Equal("TestAgent", v2Result.Headers["user-agent"]); + Assert.Equal("text/html, application/json", v2Result.Headers["accept"]); + //Assert.Equal("session=abc123; theme=dark", v2Result.Headers["cookie"]); + Assert.Equal("value1", v2Result.Headers["x-custom-header"]); + Assert.Equal("pending", v2Result.QueryStringParameters["status"]); + Assert.Equal("important,urgent", v2Result.QueryStringParameters["tag"]); + Assert.Equal("123", v2Result.PathParameters["userId"]); + Assert.Equal(new[] { "session=abc123", "theme=dark" }, v2Result.Cookies); + Assert.Equal("POST", v2Result.RequestContext.Http.Method); + Assert.Equal("/test9/api/users/123/orders", v2Result.RequestContext.Http.Path); + Assert.Equal("HTTP/1.1", v2Result.RequestContext.Http.Protocol); + } + } + }; + + yield return new object[] + { + "V2_EmptyCollections", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test10/api/notmatchingpath/123/orders"), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + Endpoint = "/test10/api/users/{userId}/orders", + HttpMethod = "POST", + Path = "/test10/api/users/{userId}/orders" + }, + Assertions = (result) => + { + var v2Result = Assert.IsType(result); + Assert.Equal(2, v2Result.Headers.Count); + Assert.Equal("0", v2Result.Headers["content-length"]); + Assert.Equal("text/plain; charset=utf-8", v2Result.Headers["content-type"]); + Assert.Null(v2Result.QueryStringParameters); + Assert.Null(v2Result.PathParameters); + Assert.Null(v2Result.Cookies); + } + } + }; + + yield return new object[] + { + "V2_BinaryContent", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test11/api/users/123/avatar", + new Dictionary { { "Content-Type", "application/octet-stream" } }, + body: new byte[] { 1, 2, 3, 4, 5 }), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "UploadAvatarFunction", + Endpoint = "/test11/api/users/{userId}/avatar", + HttpMethod = "POST", + Path = "/test11/api/users/{userId}/avatar" + }, + Assertions = (result) => + { + var v2Result = Assert.IsType(result); + Assert.True(v2Result.IsBase64Encoded); + Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5 }), v2Result.Body); + Assert.Equal("123", v2Result.PathParameters["userId"]); + Assert.Equal("POST /test11/api/users/{userId}/avatar", v2Result.RouteKey); + Assert.Equal("POST", v2Result.RequestContext.Http.Method); + } + } + }; + + yield return new object[] + { + "V2_UrlEncodedQueryString", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test12/api/search", + queryString: "?q=Hello%20World&tag=C%23%20Programming&tag=.NET%20Core"), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "SearchFunction", + Endpoint = "/test12/api/search", + HttpMethod = "POST", + Path = "/test12/api/search" + }, + Assertions = (result) => + { + var v2Result = Assert.IsType(result); + Assert.Equal("q=Hello%20World&tag=C%23%20Programming&tag=.NET%20Core", v2Result.RawQueryString); + Assert.Equal("Hello World", v2Result.QueryStringParameters["q"]); + Assert.Equal("C# Programming,.NET Core", v2Result.QueryStringParameters["tag"]); + } + } + }; + + yield return new object[] + { + "V2_SpecialCharactersInPath", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test13/api/users/****%20Doe/orders/Summer%20Sale%202023"), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "UserOrdersFunction", + Endpoint = "/test13/api/users/{username}/orders/{orderName}", + HttpMethod = "POST", + Path = "/test13/api/users/{username}/orders/{orderName}" + }, + Assertions = (result) => + { + var v2Result = Assert.IsType(result); + Assert.Equal("/test13/api/users/**** Doe/orders/Summer Sale 2023", v2Result.RawPath); + Assert.Equal("/test13/api/users/**** Doe/orders/Summer Sale 2023", v2Result.RequestContext.Http.Path); + Assert.Equal("**** Doe", v2Result.PathParameters["username"]); + Assert.Equal("Summer Sale 2023", v2Result.PathParameters["orderName"]); + } + } + }; + + yield return new object[] + { + "V2_UnicodeCharactersInPath", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test14/api/products/%E2%98%95%20Coffee/reviews/%F0%9F%98%8A%20Happy"), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "ProductReviewsFunction", + Endpoint = "/test14/api/products/{productName}/reviews/{reviewTitle}", + HttpMethod = "POST", + Path = "/test14/api/products/{productName}/reviews/{reviewTitle}" + }, + Assertions = (result) => + { + var v2Result = Assert.IsType(result); + Assert.Equal("/test14/api/products/☕ Coffee/reviews/😊 Happy", v2Result.RawPath); + Assert.Equal("/test14/api/products/☕ Coffee/reviews/😊 Happy", v2Result.RequestContext.Http.Path); + Assert.Equal("☕ Coffee", v2Result.PathParameters["productName"]); + Assert.Equal("😊 Happy", v2Result.PathParameters["reviewTitle"]); + } + } + }; + + yield return new object[] + { + "V2_EncodedAndUnicodeHeaders", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test15/api/test", + new Dictionary + { + { "X-Encoded-Header", "value%20with%20spaces" }, + { "X-Unicode-Header", "☕ Coffee" }, + { "X-Mixed-Header", "Hello%2C%20World%21%20☕" }, + { "X-Raw-Unicode", "\u2615 Coffee" } + }), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestFunction", + Endpoint = "/test15/api/test", + HttpMethod = "POST", + Path = "/test15/api/test" + }, + Assertions = (result) => + { + var v2Result = Assert.IsType(result); + Assert.Equal("value%20with%20spaces", v2Result.Headers["x-encoded-header"]); + Assert.Equal("☕ Coffee", v2Result.Headers["x-unicode-header"]); + Assert.Equal("Hello%2C%20World%21%20☕", v2Result.Headers["x-mixed-header"]); + Assert.Equal("\u2615 Coffee", v2Result.Headers["x-raw-unicode"]); + } + } + }; + + yield return new object[] + { + "V2_MultipleHeaderValuesWithUnicode", + new ApiGatewayTestCaseForRequest + { + HttpContext = CreateHttpContext("POST", "/test16/api/test", + new Dictionary + { + { "X-Multi-Value", new StringValues(new[] { "value1", "value2%20with%20spaces", "☕ Coffee", "value4%20☕" }) } + }), + ApiGatewayRouteConfig = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestFunction", + Endpoint = "/test16/api/test", + HttpMethod = "POST", + Path = "/test16/api/test" + }, + Assertions = (result) => + { + var v2Result = Assert.IsType(result); + Assert.Equal("value1, value2%20with%20spaces, ☕ Coffee, value4%20☕", v2Result.Headers["x-multi-value"]); + } + } + }; + } + + private static DefaultHttpContext CreateHttpContext(string method, string path, + Dictionary? headers = null, string? queryString = null, byte[]? body = null) + { + var context = new DefaultHttpContext(); + var request = context.Request; + request.Method = method; + request.Path = path; + if (headers != null) + { + foreach (var header in headers) + { + request.Headers[header.Key] = new StringValues(header.Value.ToArray()); + } + } + if (queryString != null) + { + request.QueryString = new QueryString(queryString); + } + if (body != null) + { + request.Body = new MemoryStream(body); + request.ContentLength = body.Length; + } + return context; + } + + + public class ApiGatewayTestCaseForRequest + { + public required DefaultHttpContext HttpContext { get; set; } + public required ApiGatewayRouteConfig ApiGatewayRouteConfig { get; set; } + public required Action Assertions { get; set; } + } + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/HttpRequestUtilityTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/HttpRequestUtilityTests.cs new file mode 100644 index 000000000..10646f12a --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/HttpRequestUtilityTests.cs @@ -0,0 +1,84 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.TestTool.UnitTests.Utilities; + +using System.Collections.Generic; +using System.Text; +using Amazon.Lambda.TestTool.Utilities; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Moq; + +public class HttpRequestUtilityTests +{ + [Theory] + [InlineData("image/jpeg", true)] + [InlineData("audio/mpeg", true)] + [InlineData("video/mp4", true)] + [InlineData("application/octet-stream", true)] + [InlineData("application/zip", true)] + [InlineData("application/pdf", true)] + [InlineData("application/x-protobuf", true)] + [InlineData("application/wasm", true)] + [InlineData("text/plain", false)] + [InlineData("application/json", false)] + [InlineData(null, false)] + [InlineData("", false)] + public void IsBinaryContent_ReturnsExpectedResult(string contentType, bool expected) + { + var result = HttpRequestUtility.IsBinaryContent(contentType); + Assert.Equal(expected, result); + } + + [Fact] + public void ReadRequestBody_ReturnsCorrectContent() + { + var content = "Test body content"; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + var request = new Mock(); + request.Setup(r => r.Body).Returns(stream); + + var result = HttpRequestUtility.ReadRequestBody(request.Object); + + Assert.Equal(content, result); + } + + [Fact] + public void ExtractHeaders_ReturnsCorrectDictionaries() + { + var headers = new HeaderDictionary + { + { "Single", new StringValues("Value") }, + { "Multi", new StringValues(new[] { "Value1", "Value2" }) } + }; + + var (singleValueHeaders, multiValueHeaders) = HttpRequestUtility.ExtractHeaders(headers); + + Assert.Equal(2, singleValueHeaders.Count); + Assert.Equal(2, multiValueHeaders.Count); + Assert.Equal("Value", singleValueHeaders["single"]); + Assert.Equal("Value2", singleValueHeaders["multi"]); + Assert.Equal(new List { "Value" }, multiValueHeaders["single"]); + Assert.Equal(new List { "Value1", "Value2" }, multiValueHeaders["multi"]); + } + + [Fact] + public void ExtractQueryStringParameters_ReturnsCorrectDictionaries() + { + var query = new QueryCollection(new Dictionary + { + { "Single", new StringValues("Value") }, + { "Multi", new StringValues(new[] { "Value1", "Value2" }) } + }); + + var (singleValueParams, multiValueParams) = HttpRequestUtility.ExtractQueryStringParameters(query); + + Assert.Equal(2, singleValueParams.Count); + Assert.Equal(2, multiValueParams.Count); + Assert.Equal("Value", singleValueParams["Single"]); + Assert.Equal("Value2", singleValueParams["Multi"]); + Assert.Equal(new List { "Value" }, multiValueParams["Single"]); + Assert.Equal(new List { "Value1", "Value2" }, multiValueParams["Multi"]); + } +} diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs new file mode 100644 index 000000000..cc40fd0cd --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.TestTool.UnitTests.Utilities; + +using Amazon.Lambda.TestTool.Utilities; +using Microsoft.AspNetCore.Routing.Template; +using Xunit; + +public class RouteTemplateUtilityTests +{ + [Theory] + [InlineData("/users/{id}", "/users/123", "id", "123")] + [InlineData("/users/{id}/orders/{orderId}", "/users/123/orders/456", "id", "123", "orderId", "456")] + [InlineData("/products/{category}/{id}", "/products/electronics/laptop-123", "category", "electronics", "id", "laptop-123")] + [InlineData("/api/{version}/users/{userId}", "/api/v1/users/abc-xyz", "version", "v1", "userId", "abc-xyz")] + public void ExtractPathParameters_ShouldExtractCorrectly(string routeTemplate, string actualPath, params string[] expectedKeyValuePairs) + { + // Arrange + var expected = new Dictionary(); + for (int i = 0; i < expectedKeyValuePairs.Length; i += 2) + { + expected[expectedKeyValuePairs[i]] = expectedKeyValuePairs[i + 1]; + } + + // Act + var result = RouteTemplateUtility.ExtractPathParameters(routeTemplate, actualPath); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("/users/{id}", "/products/123")] + [InlineData("/api/{version}/users", "/api/v1/products")] + [InlineData("/products/{category}/{id}", "/products/electronics")] + public void ExtractPathParameters_ShouldReturnEmptyDictionary_WhenNoMatch(string routeTemplate, string actualPath) + { + // Act + var result = RouteTemplateUtility.ExtractPathParameters(routeTemplate, actualPath); + + // Assert + Assert.Empty(result); + } + + [Theory] + [InlineData("/users/{id:int}", "/users/123", "id", "123")] + [InlineData("/users/{id:guid}", "/users/550e8400-e29b-41d4-a716-446655440000", "id", "550e8400-e29b-41d4-a716-446655440000")] + [InlineData("/api/{version:regex(^v[0-9]+$)}/users/{userId}", "/api/v1/users/abc-xyz", "version", "v1", "userId", "abc-xyz")] + public void ExtractPathParameters_ShouldHandleConstraints(string routeTemplate, string actualPath, params string[] expectedKeyValuePairs) + { + // Arrange + var expected = new Dictionary(); + for (int i = 0; i < expectedKeyValuePairs.Length; i += 2) + { + expected[expectedKeyValuePairs[i]] = expectedKeyValuePairs[i + 1]; + } + + // Act + var result = RouteTemplateUtility.ExtractPathParameters(routeTemplate, actualPath); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetDefaults_ShouldReturnCorrectDefaults() + { + // Arrange + var template = TemplateParser.Parse("/api/{version=v1}/users/{id}"); + + // Act + var result = RouteTemplateUtility.GetDefaults(template); + + // Assert + Assert.Single(result); + Assert.Equal("v1", result["version"]); + } + + [Fact] + public void GetDefaults_ShouldReturnEmptyDictionary_WhenNoDefaults() + { + // Arrange + var template = TemplateParser.Parse("/api/{version}/users/{id}"); + + // Act + var result = RouteTemplateUtility.GetDefaults(template); + + // Assert + Assert.Empty(result); + } +} From d96af57dc116a8af63a840f08078588470d542c6 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Thu, 19 Dec 2024 15:18:48 -0500 Subject: [PATCH 2/3] PR comments --- .../Extensions/HttpContextExtensions.cs | 42 ++- .../Utilities/HttpRequestUtility.cs | 27 +- .../Utilities/RouteTemplateUtility.cs | 90 ++++-- .../Helpers/ApiGatewayHelper.cs | 78 +++-- .../HttpContextExtensionsTests.cs | 77 ++++- .../cloudformation-template-apigateway.yaml | 12 + .../Extensions/HttpContextExtensionsTests.cs | 221 +++++++++++++- .../Extensions/HttpContextTestCases.cs | 285 +++--------------- .../Utilities/HttpRequestUtilityTests.cs | 6 +- .../Utilities/RouteTemplateUtilityTests.cs | 66 ++-- 10 files changed, 530 insertions(+), 374 deletions(-) diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/HttpContextExtensions.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/HttpContextExtensions.cs index c99d2748f..8de768cae 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/HttpContextExtensions.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/HttpContextExtensions.cs @@ -3,6 +3,7 @@ namespace Amazon.Lambda.TestTool.Extensions; +using System.Text; using System.Web; using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.TestTool.Models; @@ -20,19 +21,20 @@ public static class HttpContextExtensions /// The to be translated. /// The configuration of the API Gateway route, including the HTTP method, path, and other metadata. /// An object representing the translated request. - public static APIGatewayHttpApiV2ProxyRequest ToApiGatewayHttpV2Request( + public static async Task ToApiGatewayHttpV2Request( this HttpContext context, ApiGatewayRouteConfig apiGatewayRouteConfig) { var request = context.Request; var currentTime = DateTimeOffset.UtcNow; - var body = HttpRequestUtility.ReadRequestBody(request); + var body = await HttpRequestUtility.ReadRequestBody(request); var contentLength = HttpRequestUtility.CalculateContentLength(request, body); var pathParameters = RouteTemplateUtility.ExtractPathParameters(apiGatewayRouteConfig.Path, request.Path); // Format 2.0 doesn't have multiValueHeaders or multiValueQueryStringParameters fields. Duplicate headers are combined with commas and included in the headers field. - var (_, allHeaders) = HttpRequestUtility.ExtractHeaders(request.Headers); + // 2.0 also lowercases all header keys + var (_, allHeaders) = HttpRequestUtility.ExtractHeaders(request.Headers, true); var headers = allHeaders.ToDictionary( kvp => kvp.Key, kvp => string.Join(", ", kvp.Value) @@ -91,6 +93,7 @@ public static APIGatewayHttpApiV2ProxyRequest ToApiGatewayHttpV2Request( } httpApiV2ProxyRequest.RawQueryString = string.Empty; // default is empty string + if (queryStringParameters.Any()) { // this should be decoded value @@ -123,12 +126,13 @@ public static APIGatewayHttpApiV2ProxyRequest ToApiGatewayHttpV2Request( /// The to be translated. /// The configuration of the API Gateway route, including the HTTP method, path, and other metadata. /// An object representing the translated request. - public static APIGatewayProxyRequest ToApiGatewayRequest( + public static async Task ToApiGatewayRequest( this HttpContext context, - ApiGatewayRouteConfig apiGatewayRouteConfig) + ApiGatewayRouteConfig apiGatewayRouteConfig, + ApiGatewayEmulatorMode emulatorMode) { var request = context.Request; - var body = HttpRequestUtility.ReadRequestBody(request); + var body = await HttpRequestUtility.ReadRequestBody(request); var contentLength = HttpRequestUtility.CalculateContentLength(request, body); var pathParameters = RouteTemplateUtility.ExtractPathParameters(apiGatewayRouteConfig.Path, request.Path); @@ -136,22 +140,38 @@ public static APIGatewayProxyRequest ToApiGatewayRequest( var (headers, multiValueHeaders) = HttpRequestUtility.ExtractHeaders(request.Headers); var (queryStringParameters, multiValueQueryStringParameters) = HttpRequestUtility.ExtractQueryStringParameters(request.Query); - if (!headers.ContainsKey("content-length")) + if (!headers.ContainsKey("content-length") && emulatorMode != ApiGatewayEmulatorMode.Rest) // rest doesnt set content-length by default { headers["content-length"] = contentLength.ToString(); - multiValueHeaders["content-length"] = new List { contentLength.ToString() }; + multiValueHeaders["content-length"] = [contentLength.ToString()]; } if (!headers.ContainsKey("content-type")) { headers["content-type"] = "text/plain; charset=utf-8"; - multiValueHeaders["content-type"] = new List { "text/plain; charset=utf-8" }; + multiValueHeaders["content-type"] = ["text/plain; charset=utf-8"]; + } + + // This is the decoded value + var path = request.Path.Value; + + if (emulatorMode == ApiGatewayEmulatorMode.HttpV1 || emulatorMode == ApiGatewayEmulatorMode.Rest) // rest and httpv1 uses the encoded value for path an + { + path = request.Path.ToUriComponent(); + } + + if (emulatorMode == ApiGatewayEmulatorMode.Rest) // rest uses encoded value for the path params + { + var encodedPathParameters = pathParameters.ToDictionary( + kvp => kvp.Key, + kvp => Uri.EscapeUriString(kvp.Value)); // intentionally using EscapeURiString over EscapeDataString since EscapeURiString correctly handles reserved characters :/?#[]@!$&'()*+,;= in this case + pathParameters = encodedPathParameters; } var proxyRequest = new APIGatewayProxyRequest { Resource = apiGatewayRouteConfig.Path, - Path = request.Path.Value, + Path = path, HttpMethod = request.Method, Body = body, IsBase64Encoded = false @@ -181,13 +201,11 @@ public static APIGatewayProxyRequest ToApiGatewayRequest( if (pathParameters.Any()) { - // this should be decoded value proxyRequest.PathParameters = pathParameters; } if (HttpRequestUtility.IsBinaryContent(request.ContentType)) { - // we already converted it when we read the body so we dont need to re-convert it proxyRequest.IsBase64Encoded = true; } diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/HttpRequestUtility.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/HttpRequestUtility.cs index 5e2df4b00..dc1acba6a 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/HttpRequestUtility.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/HttpRequestUtility.cs @@ -1,4 +1,7 @@ -using System.Text; +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; namespace Amazon.Lambda.TestTool.Utilities; @@ -32,7 +35,7 @@ public static bool IsBinaryContent(string? contentType) /// /// The HTTP request. /// The body of the request as a string, or null if the body is empty. - public static string? ReadRequestBody(HttpRequest request) + public static async Task ReadRequestBody(HttpRequest request) { if (request.ContentLength == 0 || request.Body == null || !request.Body.CanRead) { @@ -46,7 +49,7 @@ public static bool IsBinaryContent(string? contentType) using (var memoryStream = new MemoryStream()) { - request.Body.CopyTo(memoryStream); + await request.Body.CopyToAsync(memoryStream); // If the stream is empty, return null if (memoryStream.Length == 0) @@ -67,7 +70,7 @@ public static bool IsBinaryContent(string? contentType) // For text data, read as string using (var reader = new StreamReader(memoryStream)) { - string content = reader.ReadToEnd(); + string content = await reader.ReadToEndAsync(); return string.IsNullOrWhiteSpace(content) ? null : content; } } @@ -80,6 +83,7 @@ public static bool IsBinaryContent(string? contentType) /// Extracts headers from the request, separating them into single-value and multi-value dictionaries. /// /// The request headers. + /// Whether to lowercase the key name or not. /// A tuple containing single-value and multi-value header dictionaries. /// /// For headers: @@ -91,15 +95,16 @@ public static bool IsBinaryContent(string? contentType) /// singleValueHeaders: { "Accept": "application/xhtml+xml", "X-Custom-Header": "value1" } /// multiValueHeaders: { "Accept": ["text/html", "application/xhtml+xml"], "X-Custom-Header": ["value1"] } /// - public static (IDictionary, IDictionary>) ExtractHeaders(IHeaderDictionary headers) + public static (IDictionary, IDictionary>) ExtractHeaders(IHeaderDictionary headers, bool lowerCaseKeyName = false) { - var singleValueHeaders = new Dictionary(); - var multiValueHeaders = new Dictionary>(); + var singleValueHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + var multiValueHeaders = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var header in headers) { - singleValueHeaders[header.Key.ToLower()] = header.Value.Last() ?? ""; - multiValueHeaders[header.Key.ToLower()] = [.. header.Value]; + var key = lowerCaseKeyName ? header.Key.ToLower() : header.Key; + singleValueHeaders[key] = header.Value.Last() ?? ""; + multiValueHeaders[key] = [.. header.Value]; } return (singleValueHeaders, multiValueHeaders); @@ -139,7 +144,7 @@ public static (IDictionary, IDictionary>) /// The generated ID is a 145character string consisting of lowercase letters and numbers, followed by an equals sign. public static string GenerateRequestId() { - return Guid.NewGuid().ToString("N").Substring(0, 8) + Guid.NewGuid().ToString("N").Substring(0, 7) + "="; + return $"{Guid.NewGuid().ToString("N").Substring(0, 8)}{Guid.NewGuid().ToString("N").Substring(0, 7)}="; } /// @@ -161,7 +166,7 @@ public static string GenerateTraceId() 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"; } - public static long CalculateContentLength(HttpRequest request, string body) + public static long CalculateContentLength(HttpRequest request, string? body) { if (!string.IsNullOrEmpty(body)) { diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs index fa9da4e0d..1bfaf90b3 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs @@ -1,5 +1,6 @@ -namespace Amazon.Lambda.TestTool.Utilities; +namespace Amazon.Lambda.TestTool.Utilities; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Routing.Template; /// @@ -7,6 +8,8 @@ /// public static class RouteTemplateUtility { + private const string TemporaryPrefix = "__aws_param__"; + /// /// Extracts path parameters from an actual path based on a route template. /// @@ -24,43 +27,88 @@ public static class RouteTemplateUtility /// public static Dictionary ExtractPathParameters(string routeTemplate, string actualPath) { + // Preprocess the route template to convert from .net style format to aws + routeTemplate = PreprocessRouteTemplate(routeTemplate); + var template = TemplateParser.Parse(routeTemplate); - var matcher = new TemplateMatcher(template, GetDefaults(template)); + var matcher = new TemplateMatcher(template, new RouteValueDictionary()); var routeValues = new RouteValueDictionary(); if (matcher.TryMatch(actualPath, routeValues)) { - return routeValues.ToDictionary(rv => rv.Key, rv => rv.Value?.ToString() ?? string.Empty); + var result = new Dictionary(); + + foreach (var param in template.Parameters) + { + if (routeValues.TryGetValue(param.Name, out var value)) + { + var stringValue = value?.ToString() ?? string.Empty; + + // For catch-all parameters, remove the leading slash if present + if (param.IsCatchAll) + { + stringValue = stringValue.TrimStart('/'); + } + + // Restore original parameter name + var originalParamName = RestoreOriginalParamName(param.Name); + result[originalParamName] = stringValue; + } + } + + return result; } return new Dictionary(); } /// - /// Gets the default values for parameters in a parsed route template. + /// Preprocesses a route template to make it compatible with ASP.NET Core's TemplateMatcher. /// - /// The parsed route template. - /// A dictionary of default values for the template parameters. - /// - /// Using this method: - /// - /// var template = TemplateParser.Parse("/api/{version=v1}/users/{id}"); - /// var defaults = RouteTemplateUtility.GetDefaults(template); - /// // defaults will contain: { {"version", "v1"} } - /// - /// - public static RouteValueDictionary GetDefaults(RouteTemplate parsedTemplate) + /// The original route template, potentially in AWS API Gateway format. + /// A preprocessed route template compatible with ASP.NET Core's TemplateMatcher. + /// + /// This method performs two main transformations: + /// 1. Converts AWS-style {proxy+} to ASP.NET Core style {*proxy} + /// 2. Handles AWS ignoring constraignts by temporarily renaming parameters + /// (e.g., {abc:int} becomes {__aws_param__abc__int}) + /// + private static string PreprocessRouteTemplate(string template) { - var result = new RouteValueDictionary(); + // Convert AWS-style {proxy+} to ASP.NET Core style {*proxy} + template = Regex.Replace(template, @"\{(\w+)\+\}", "{*$1}"); + + // Handle AWS-style "constraints" by replacing them with temporary parameter names + return Regex.Replace(template, @"\{([^}]+):([^}]+)\}", match => + { + var paramName = match.Groups[1].Value; + var constraint = match.Groups[2].Value; + + // There is a low chance that one of the parameters being used actually follows the syntax of {TemporaryPrefix}{paramName}__{constraint}. + // But i dont think its signifigant enough to worry about. + return $"{{{TemporaryPrefix}{paramName}__{constraint}}}"; + }); + } - foreach (var parameter in parsedTemplate.Parameters) + /// + /// Restores the original parameter name after processing by TemplateMatcher. + /// + /// The parameter name after processing and matching. + /// The original parameter name. + /// + /// This method reverses the transformation done in PreprocessRouteTemplate. + /// For example, "__aws_param__abc__int" would be restored to "abc:int". + /// + private static string RestoreOriginalParamName(string processedName) + { + if (processedName.StartsWith(TemporaryPrefix)) { - if (parameter.DefaultValue != null) + var parts = processedName.Substring(TemporaryPrefix.Length).Split("__", 2); + if (parts.Length == 2) { - if (parameter.Name != null) result.Add(parameter.Name, parameter.DefaultValue); + return $"{parts[0]}:{parts[1]}"; } } - - return result; + return processedName; } } 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 index 7ad38e16a..80ae3d330 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/ApiGatewayHelper.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/Helpers/ApiGatewayHelper.cs @@ -23,9 +23,13 @@ public ApiGatewayHelper(IAmazonAPIGateway apiGatewayV1Client, IAmazonApiGatewayV _httpClient = new HttpClient(); } - public async Task WaitForApiAvailability(string apiId, string apiUrl, bool isHttpApi, int maxWaitTimeSeconds = 30) + public async Task WaitForApiAvailability(string apiId, string apiUrl, bool isHttpApi, int maxWaitTimeSeconds = 60) { var startTime = DateTime.UtcNow; + var successStartTime = DateTime.UtcNow; + var requiredSuccessDuration = TimeSpan.FromSeconds(10); + bool hasBeenSuccessful = false; + while ((DateTime.UtcNow - startTime).TotalSeconds < maxWaitTimeSeconds) { try @@ -47,77 +51,107 @@ public async Task WaitForApiAvailability(string apiId, string apiUrl, bool isHtt { 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 && response.StatusCode != HttpStatusCode.Forbidden) + // Check if we get a successful response + if (response.StatusCode != HttpStatusCode.Forbidden && response.StatusCode != HttpStatusCode.NotFound) + { + if (!hasBeenSuccessful) + { + successStartTime = DateTime.UtcNow; + hasBeenSuccessful = true; + } + + if ((DateTime.UtcNow - successStartTime) >= requiredSuccessDuration) + { + return; // API has been responding successfully for at least 10 seconds + } + } + else { - return; // API is available and responding + // Reset the success timer if we get a non-successful response + hasBeenSuccessful = false; + Console.WriteLine($"API responded with status code: {response.StatusCode}"); } } } catch (Amazon.ApiGatewayV2.Model.NotFoundException) when (isHttpApi) { // HTTP API not found yet, continue waiting + hasBeenSuccessful = false; } catch (Amazon.APIGateway.Model.NotFoundException) when (!isHttpApi) { // REST API not found yet, continue waiting + hasBeenSuccessful = false; } catch (Exception ex) { - // Log unexpected exceptions + // Log unexpected exceptions and reset success timer Console.WriteLine($"Unexpected error while checking API availability: {ex.Message}"); + hasBeenSuccessful = false; } await Task.Delay(1000); // Wait for 1 second before checking again } - throw new TimeoutException($"API {apiId} did not become available within {maxWaitTimeSeconds} seconds"); + throw new TimeoutException($"API {apiId} did not become consistently available within {maxWaitTimeSeconds} seconds"); } - public async Task AddRouteToRestApi(string restApiId, string lambdaArn, string route = "/test") + + public async Task AddRouteToRestApi(string restApiId, string lambdaArn, string route = "/test", string httpMethod = "ANY") { - var rootResourceId = (await _apiGatewayV1Client.GetResourcesAsync(new GetResourcesRequest { RestApiId = restApiId })).Items[0].Id; + // Get all resources and find the root resource + var resources = await _apiGatewayV1Client.GetResourcesAsync(new GetResourcesRequest { RestApiId = restApiId }); + var rootResource = resources.Items.First(r => r.Path == "/"); + var rootResourceId = rootResource.Id; - var pathParts = route.Trim('/').Split('/'); - var currentResourceId = rootResourceId; - foreach (var pathPart in pathParts) + // Split the route into parts and create each part + var routeParts = route.Trim('/').Split('/'); + string currentPath = ""; + string parentResourceId = rootResourceId; + + foreach (var part in routeParts) { - var resources = await _apiGatewayV1Client.GetResourcesAsync(new GetResourcesRequest { RestApiId = restApiId }); - var existingResource = resources.Items.FirstOrDefault(r => r.ParentId == currentResourceId && r.PathPart == pathPart); + currentPath += "/" + part; + // Check if the resource already exists + var existingResource = resources.Items.FirstOrDefault(r => r.Path == currentPath); if (existingResource == null) { + // Create the resource if it doesn't exist var createResourceResponse = await _apiGatewayV1Client.CreateResourceAsync(new CreateResourceRequest { RestApiId = restApiId, - ParentId = currentResourceId, - PathPart = pathPart + ParentId = parentResourceId, + PathPart = part }); - currentResourceId = createResourceResponse.Id; + parentResourceId = createResourceResponse.Id; } else { - currentResourceId = existingResource.Id; + parentResourceId = existingResource.Id; } } + // Create the method for the final resource await _apiGatewayV1Client.PutMethodAsync(new PutMethodRequest { RestApiId = restApiId, - ResourceId = currentResourceId, - HttpMethod = "ANY", + ResourceId = parentResourceId, + HttpMethod = httpMethod, AuthorizationType = "NONE" }); + // Create the integration for the method await _apiGatewayV1Client.PutIntegrationAsync(new PutIntegrationRequest { RestApiId = restApiId, - ResourceId = currentResourceId, - HttpMethod = "ANY", + ResourceId = parentResourceId, + HttpMethod = httpMethod, Type = APIGateway.IntegrationType.AWS_PROXY, IntegrationHttpMethod = "POST", Uri = $"arn:aws:apigateway:{_apiGatewayV1Client.Config.RegionEndpoint.SystemName}:lambda:path/2015-03-31/functions/{lambdaArn}/invocations" }); - await _apiGatewayV1Client.CreateDeploymentAsync(new APIGateway.Model.CreateDeploymentRequest + // Deploy the API + var deploymentResponse = await _apiGatewayV1Client.CreateDeploymentAsync(new APIGateway.Model.CreateDeploymentRequest { RestApiId = restApiId, StageName = "test" diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/HttpContextExtensionsTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/HttpContextExtensionsTests.cs index f78165031..da52edcdc 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/HttpContextExtensionsTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/HttpContextExtensionsTests.cs @@ -5,10 +5,10 @@ using System.Text.Json; using Amazon.Lambda.APIGatewayEvents; using Amazon.Lambda.TestTool.Extensions; -using Amazon.Lambda.TestTool.IntegrationTests.Helpers; using Amazon.Lambda.TestTool.Models; using Amazon.Lambda.TestTool.UnitTests.Extensions; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; using static Amazon.Lambda.TestTool.UnitTests.Extensions.HttpContextTestCases; namespace Amazon.Lambda.TestTool.IntegrationTests @@ -25,47 +25,93 @@ public HttpContextExtensionsTests(ApiGatewayIntegrationTestFixture fixture) [Theory] [MemberData(nameof(HttpContextTestCases.V1TestCases), MemberType = typeof(HttpContextTestCases))] - public async Task IntegrationTest_APIGatewayV1_REST(string testName, ApiGatewayTestCaseForRequest testCase) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public async Task IntegrationTest_APIGatewayV1_REST(string testName, HttpContextTestCase testCase) { var route = testCase.ApiGatewayRouteConfig?.Path ?? "/test"; await _fixture.ApiGatewayHelper.AddRouteToRestApi(_fixture.ReturnFullEventRestApiId, _fixture.ReturnFullEventLambdaFunctionArn, route); - await RunApiGatewayTest(testCase, _fixture.ReturnFullEventRestApiUrl, - (context, config) => context.ToApiGatewayRequest(config), true); + await RunApiGatewayTest(testCase, _fixture.ReturnFullEventRestApiUrl, _fixture.ReturnFullEventRestApiId, + async (context, config) => await context.ToApiGatewayRequest(config, ApiGatewayEmulatorMode.Rest), ApiGatewayEmulatorMode.Rest); } [Theory] [MemberData(nameof(HttpContextTestCases.V1TestCases), MemberType = typeof(HttpContextTestCases))] - public async Task IntegrationTest_APIGatewayV1_HTTP(string testName, ApiGatewayTestCaseForRequest testCase) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public async Task IntegrationTest_APIGatewayV1_HTTP(string testName, HttpContextTestCase testCase) { var route = testCase.ApiGatewayRouteConfig?.Path ?? "/test"; var routeKey = testCase.ApiGatewayRouteConfig?.HttpMethod ?? "POST"; await _fixture.ApiGatewayHelper.AddRouteToHttpApi(_fixture.ReturnFullEventHttpApiV1Id, _fixture.ReturnFullEventLambdaFunctionArn, "1.0", route, routeKey); - await RunApiGatewayTest(testCase, _fixture.ReturnFullEventHttpApiV1Url, - (context, config) => context.ToApiGatewayRequest(config), false); + await RunApiGatewayTest(testCase, _fixture.ReturnFullEventHttpApiV1Url, _fixture.ReturnFullEventHttpApiV1Id, + async (context, config) => await context.ToApiGatewayRequest(config, ApiGatewayEmulatorMode.HttpV1), ApiGatewayEmulatorMode.HttpV1); } [Theory] [MemberData(nameof(HttpContextTestCases.V2TestCases), MemberType = typeof(HttpContextTestCases))] - public async Task IntegrationTest_APIGatewayV2(string testName, ApiGatewayTestCaseForRequest testCase) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public async Task IntegrationTest_APIGatewayV2(string testName, HttpContextTestCase testCase) { var route = testCase.ApiGatewayRouteConfig?.Path ?? "/test"; var routeKey = testCase.ApiGatewayRouteConfig?.HttpMethod ?? "POST"; await _fixture.ApiGatewayHelper.AddRouteToHttpApi(_fixture.ReturnFullEventHttpApiV2Id, _fixture.ReturnFullEventLambdaFunctionArn, "2.0", route, routeKey); - await RunApiGatewayTest(testCase, _fixture.ReturnFullEventHttpApiV2Url, - (context, config) => context.ToApiGatewayHttpV2Request(config), false); + await RunApiGatewayTest(testCase, _fixture.ReturnFullEventHttpApiV2Url, _fixture.ReturnFullEventHttpApiV2Id, + async (context, config) => await context.ToApiGatewayHttpV2Request(config), ApiGatewayEmulatorMode.HttpV2); } - private async Task RunApiGatewayTest(ApiGatewayTestCaseForRequest testCase, string apiUrl, Func toApiGatewayRequest, bool isRestAPI) + [Fact] + public async Task BinaryContentHttpV1() + { + var httpContext = CreateHttpContext("POST", "/test3/api/users/123/avatar", + new Dictionary { { "Content-Type", "application/octet-stream" } }, + body: new byte[] { 1, 2, 3, 4, 5 }); + + var config = new ApiGatewayRouteConfig + { + LambdaResourceName = "UploadAvatarFunction", + Endpoint = "/test3/api/users/{userId}/avatar", + HttpMethod = "POST", + Path = "/test3/api/users/{userId}/avatar" + }; + + var testCase = new HttpContextTestCase + { + HttpContext = httpContext, + ApiGatewayRouteConfig = config, + Assertions = (actualRequest, emulatorMode) => + { + var typedRequest = (APIGatewayProxyRequest)actualRequest; + Assert.True(typedRequest.IsBase64Encoded); + Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5 }), typedRequest.Body); + Assert.Equal("123", typedRequest.PathParameters["userId"]); + Assert.Equal("/test3/api/users/{userId}/avatar", typedRequest.Resource); + Assert.Equal("POST", typedRequest.HttpMethod); + } + }; + + var route = testCase.ApiGatewayRouteConfig?.Path ?? "/test"; + var routeKey = testCase.ApiGatewayRouteConfig?.HttpMethod ?? "POST"; + await _fixture.ApiGatewayHelper.AddRouteToHttpApi(_fixture.ReturnFullEventHttpApiV1Id, _fixture.ReturnFullEventLambdaFunctionArn, "1.0", route, routeKey); + + await RunApiGatewayTest( + testCase, + _fixture.ReturnFullEventHttpApiV1Url, + _fixture.ReturnFullEventHttpApiV1Id, + async (context, cfg) => await context.ToApiGatewayRequest(cfg, ApiGatewayEmulatorMode.HttpV1), + ApiGatewayEmulatorMode.HttpV1 + ); + } + + private async Task RunApiGatewayTest(HttpContextTestCase testCase, string apiUrl, string apiId, Func> toApiGatewayRequest, ApiGatewayEmulatorMode emulatorMode) where T : class { var httpClient = new HttpClient(); var uri = new Uri(apiUrl); var baseUrl = $"{uri.Scheme}://{uri.Authority}"; - var stageName = isRestAPI ? "/test" : ""; // matching hardcoded test stage name for rest api. TODO update this logic later to not be hardcoded + var stageName = emulatorMode == ApiGatewayEmulatorMode.Rest ? "/test" : ""; // matching hardcoded test stage name for rest api. TODO update this logic later to not be hardcoded var actualPath = ResolveActualPath(testCase.ApiGatewayRouteConfig.Path, testCase.HttpContext.Request.Path); var fullUrl = baseUrl + stageName + actualPath + testCase.HttpContext.Request.QueryString; - + await _fixture.ApiGatewayHelper.WaitForApiAvailability(apiId, fullUrl, emulatorMode != ApiGatewayEmulatorMode.Rest); var httpRequest = CreateHttpRequestMessage(testCase.HttpContext, fullUrl); @@ -78,15 +124,16 @@ private async Task RunApiGatewayTest(ApiGatewayTestCaseForRequest testCase, s var actualApiGatewayRequest = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - var expectedApiGatewayRequest = toApiGatewayRequest(testCase.HttpContext, testCase.ApiGatewayRouteConfig); + var expectedApiGatewayRequest = await toApiGatewayRequest(testCase.HttpContext, testCase.ApiGatewayRouteConfig); CompareApiGatewayRequests(expectedApiGatewayRequest, actualApiGatewayRequest); - testCase.Assertions(actualApiGatewayRequest); + testCase.Assertions(actualApiGatewayRequest, emulatorMode); await Task.Delay(1000); // Small delay between requests } + private void CompareApiGatewayRequests(T expected, T actual) where T : class { if (expected is APIGatewayProxyRequest v1Expected && actual is APIGatewayProxyRequest v1Actual) 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 index abd947b6f..e0bcc1910 100644 --- 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 @@ -82,6 +82,9 @@ Resources: Type: 'AWS::ApiGateway::RestApi' Properties: Name: !Sub '${AWS::StackName}-ParseAndReturnBodyRestAPI' + EndpointConfiguration: + Types: + - REGIONAL ParseAndReturnBodyRestApiResource: Type: 'AWS::ApiGateway::Resource' @@ -176,6 +179,9 @@ Resources: Type: 'AWS::ApiGateway::RestApi' Properties: Name: !Sub '${AWS::StackName}-ReturnRawBodyRestAPI' + EndpointConfiguration: + Types: + - REGIONAL ReturnRawBodyRestApiResource: Type: 'AWS::ApiGateway::Resource' @@ -270,6 +276,9 @@ Resources: Type: 'AWS::ApiGateway::RestApi' Properties: Name: !Sub '${AWS::StackName}-ReturnFullEventRestAPI' + EndpointConfiguration: + Types: + - REGIONAL ReturnFullEventRestApiResource: Type: 'AWS::ApiGateway::Resource' @@ -436,6 +445,9 @@ Resources: Type: 'AWS::ApiGateway::RestApi' Properties: Name: !Sub '${AWS::StackName}-ReturnDecodedParseBinRestAPI' + EndpointConfiguration: + Types: + - REGIONAL BinaryMediaTypes: - '*/*' diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextExtensionsTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextExtensionsTests.cs index 70cb27ad2..b925ea402 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextExtensionsTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextExtensionsTests.cs @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 using Amazon.Lambda.TestTool.Extensions; +using Amazon.Lambda.TestTool.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; using static Amazon.Lambda.TestTool.UnitTests.Extensions.HttpContextTestCases; namespace Amazon.Lambda.TestTool.UnitTests.Extensions @@ -10,30 +14,235 @@ public class HttpContextExtensionsTests { [Theory] [MemberData(nameof(HttpContextTestCases.V1TestCases), MemberType = typeof(HttpContextTestCases))] - public void ToApiGatewayRequest_ConvertsCorrectly(string testName, ApiGatewayTestCaseForRequest testCase) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public async Task ToApiGatewayRequestRest_ConvertsCorrectly(string testName, HttpContextTestCase testCase) { // Arrange var context = testCase.HttpContext; // Act - var result = context.ToApiGatewayRequest(testCase.ApiGatewayRouteConfig); + var result = await context.ToApiGatewayRequest(testCase.ApiGatewayRouteConfig, ApiGatewayEmulatorMode.Rest); // Assert - testCase.Assertions(result); + testCase.Assertions(result, ApiGatewayEmulatorMode.Rest); + } + + [Theory] + [MemberData(nameof(HttpContextTestCases.V1TestCases), MemberType = typeof(HttpContextTestCases))] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public async Task ToApiGatewayRequestV1_ConvertsCorrectly(string testName, HttpContextTestCase testCase) + { + // Arrange + var context = testCase.HttpContext; + + // Act + var result = await context.ToApiGatewayRequest(testCase.ApiGatewayRouteConfig, ApiGatewayEmulatorMode.HttpV1); + + // Assert + testCase.Assertions(result, ApiGatewayEmulatorMode.HttpV1); } [Theory] [MemberData(nameof(HttpContextTestCases.V2TestCases), MemberType = typeof(HttpContextTestCases))] - public void ToApiGatewayHttpV2Request_ConvertsCorrectly(string testName, ApiGatewayTestCaseForRequest testCase) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1026:Theory methods should use all of their parameters")] + public async Task ToApiGatewayHttpV2Request_ConvertsCorrectly(string testName, HttpContextTestCase testCase) { // Arrange var context = testCase.HttpContext; // Act - var result = context.ToApiGatewayHttpV2Request(testCase.ApiGatewayRouteConfig); + var result = await context.ToApiGatewayHttpV2Request(testCase.ApiGatewayRouteConfig); + + // Assert + testCase.Assertions(result, ApiGatewayEmulatorMode.HttpV2); + } + + [Fact] + public async Task ToApiGatewayHttpV2Request_EmptyCollections() + { + + var httpContext = CreateHttpContext("POST", "/test10/api/notmatchingpath/123/orders"); + var config = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + Endpoint = "/test10/api/users/{userId}/orders", + HttpMethod = "POST", + Path = "/test10/api/users/{userId}/orders" + }; + + // Act + var result = await httpContext.ToApiGatewayHttpV2Request(config); + Assert.Equal(2, result.Headers.Count); + Assert.Equal("0", result.Headers["content-length"]); + Assert.Equal("text/plain; charset=utf-8", result.Headers["content-type"]); + Assert.Null(result.QueryStringParameters); + Assert.Null(result.PathParameters); + Assert.Null(result.Cookies); + } + + [Fact] + public async Task ToApiGatewayHttpV1Request_EmptyCollections() + { + var httpContext = CreateHttpContext("POST", "/test10/api/notmatchingpath/123/orders"); + var config = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + Endpoint = "/test10/api/users/{userId}/orders", + HttpMethod = "POST", + Path = "/test10/api/users/{userId}/orders" + }; + + // Act + var result = await httpContext.ToApiGatewayRequest(config, ApiGatewayEmulatorMode.HttpV1); + Assert.Equal(2, result.Headers.Count); + Assert.Equal("0", result.Headers["content-length"]); + Assert.Equal("text/plain; charset=utf-8", result.Headers["content-type"]); + Assert.Equal(new List { "0" }, result.MultiValueHeaders["content-length"]); + Assert.Equal(new List { "text/plain; charset=utf-8" }, result.MultiValueHeaders["content-type"]); + Assert.Null(result.QueryStringParameters); + Assert.Null(result.MultiValueQueryStringParameters); + Assert.Null(result.PathParameters); + } + + [Theory] + [InlineData(ApiGatewayEmulatorMode.Rest)] + [InlineData(ApiGatewayEmulatorMode.HttpV1)] + public async Task ToApiGateway_MultiValueHeader(ApiGatewayEmulatorMode emulatorMode) + { + var httpContext = CreateHttpContext("POST", "/test1/api/users/123/orders", + new Dictionary + { + { "Accept", new StringValues(new[] { "text/html", "application/json" }) }, + }); + var config = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + Endpoint = "/test1/api/users/{userId}/orders", + HttpMethod = "POST", + Path = "/test1/api/users/{userId}/orders" + }; + + var result = await httpContext.ToApiGatewayRequest(config, emulatorMode); + Assert.Equal(["text/html", "application/json"], result.MultiValueHeaders["accept"]); + } + + + [Fact] + public async Task ToApiGatewayHttpV1_EncodedAndUnicodeHeader() + { + var httpContext = CreateHttpContext("POST", "/test1/api/users/123/orders", + new Dictionary + { + { "X-Encoded-Header", "value%20with%20spaces" }, + { "X-Unicode-Header", "☕ Coffee" }, + { "X-Mixed-Header", "Hello%2C%20World%21%20☕" }, + { "X-Raw-Unicode", "\u2615 Coffee" } + }); + var config = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + Endpoint = "/test1/api/users/{userId}/orders", + HttpMethod = "POST", + Path = "/test1/api/users/{userId}/orders" + }; + + var result = await httpContext.ToApiGatewayRequest(config, ApiGatewayEmulatorMode.HttpV1); + Assert.Equal("value%20with%20spaces", result.Headers["X-Encoded-Header"]); + Assert.Equal("☕ Coffee", result.Headers["X-Unicode-Header"]); + Assert.Equal("Hello%2C%20World%21%20☕", result.Headers["X-Mixed-Header"]); + Assert.Equal("\u2615 Coffee", result.Headers["X-Raw-Unicode"]); + Assert.Equal(new List { "value%20with%20spaces" }, result.MultiValueHeaders["X-Encoded-Header"]); + Assert.Equal(new List { "☕ Coffee" }, result.MultiValueHeaders["X-Unicode-Header"]); + Assert.Equal(new List { "Hello%2C%20World%21%20☕" }, result.MultiValueHeaders["X-Mixed-Header"]); + Assert.Equal(new List { "\u2615 Coffee" }, result.MultiValueHeaders["X-Raw-Unicode"]); + } + + + // Keeping this commented out for now. We have a backlog item DOTNET-7862 for this + //[Fact] + //public void ToApiGatewayRest_EncodedAndUnicodeHeader() + //{ + // var httpContext = CreateHttpContext("POST", "/test1/api/users/123/orders", + // new Dictionary + // { + // { "X-Encoded-Header", "value%20with%20spaces" }, + // { "X-Unicode-Header", "☕ Coffee" }, + // { "X-Mixed-Header", "Hello%2C%20World%21%20☕" }, + // { "X-Raw-Unicode", "\u2615 Coffee" } + // }); + // var config = new ApiGatewayRouteConfig + // { + // LambdaResourceName = "TestLambdaFunction", + // Endpoint = "/test1/api/users/{userId}/orders", + // HttpMethod = "POST", + // Path = "/test1/api/users/{userId}/orders" + // }; + + // var result = httpContext.ToApiGatewayRequest(config, ApiGatewayEmulatorMode.Rest); + // Assert.Equal("value%20with%20spaces", result.Headers["X-Encoded-Header"]); + // Assert.Equal("¬リユ Coffee", result.Headers["X-Unicode-Header"]); + // Assert.Equal("Hello%2C%20World%21%20¬リユ", result.Headers["X-Mixed-Header"]); + // Assert.Equal("\u2615 Coffee", result.Headers["X-Raw-Unicode"]); + // Assert.Equal(new List { "value%20with%20spaces" }, result.MultiValueHeaders["X-Encoded-Header"]); + // Assert.Equal(new List { "¬リユ Coffee" }, result.MultiValueHeaders["X-Unicode-Header"]); // in reality this is what rest api thinks it is + // Assert.Equal(new List { "Hello%2C%20World%21%20☕" }, result.MultiValueHeaders["X-Mixed-Header"]); + // Assert.Equal(new List { "\u2615 Coffee" }, result.MultiValueHeaders["X-Raw-Unicode"]); + //} + + [Fact] + public async Task ToApiGateway_EncodedAndUnicodeHeaderV2() + { + var httpContext = CreateHttpContext("POST", "/test1/api/users/123/orders", + new Dictionary + { + { "X-Encoded-Header", "value%20with%20spaces" }, + { "X-Unicode-Header", "☕ Coffee" }, + { "X-Mixed-Header", "Hello%2C%20World%21%20☕" }, + { "X-Raw-Unicode", "\u2615 Coffee" } + }); + var config = new ApiGatewayRouteConfig + { + LambdaResourceName = "TestLambdaFunction", + Endpoint = "/test1/api/users/{userId}/orders", + HttpMethod = "POST", + Path = "/test1/api/users/{userId}/orders" + }; + + var result = await httpContext.ToApiGatewayHttpV2Request(config); + Assert.Equal("value%20with%20spaces", result.Headers["x-encoded-header"]); + Assert.Equal("☕ Coffee", result.Headers["x-unicode-header"]); + Assert.Equal("Hello%2C%20World%21%20☕", result.Headers["x-mixed-header"]); + Assert.Equal("\u2615 Coffee", result.Headers["x-raw-unicode"]); + } + + [Theory] + [InlineData(ApiGatewayEmulatorMode.Rest)] + [InlineData(ApiGatewayEmulatorMode.HttpV1)] + public async Task BinaryContentHttpV1(ApiGatewayEmulatorMode emulatorMode) + { + // Arrange + var httpContext = CreateHttpContext("POST", "/test3/api/users/123/avatar", + new Dictionary { { "Content-Type", "application/octet-stream" } }, + body: new byte[] { 1, 2, 3, 4, 5 }); + + var config = new ApiGatewayRouteConfig + { + LambdaResourceName = "UploadAvatarFunction", + Endpoint = "/test3/api/users/{userId}/avatar", + HttpMethod = "POST", + Path = "/test3/api/users/{userId}/avatar" + }; + + // Act + var result = await httpContext.ToApiGatewayRequest(config, emulatorMode); // Assert - testCase.Assertions(result); + Assert.True(result.IsBase64Encoded); + Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5 }), result.Body); + Assert.Equal("123", result.PathParameters["userId"]); + Assert.Equal("/test3/api/users/{userId}/avatar", result.Resource); + Assert.Equal("POST", result.HttpMethod); + Assert.Equal("application/octet-stream", result.Headers["Content-Type"]); } } } diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextTestCases.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextTestCases.cs index 966a00f55..0d6442948 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextTestCases.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextTestCases.cs @@ -15,13 +15,12 @@ public static IEnumerable V1TestCases() yield return new object[] { "V1_SimpleGetRequest", - new ApiGatewayTestCaseForRequest + new HttpContextTestCase { HttpContext = CreateHttpContext("POST", "/test1/api/users/123/orders", new Dictionary { { "User-Agent", "TestAgent" }, - { "Accept", new StringValues(new[] { "text/html", "application/json" }) }, { "Cookie", "session=abc123; theme=dark" }, { "X-Custom-Header", "value1" } }, @@ -33,20 +32,18 @@ public static IEnumerable V1TestCases() HttpMethod = "POST", Path = "/test1/api/users/{userId}/orders" }, - Assertions = (result) => + Assertions = (result, emulatorMode) => { var v1Result = Assert.IsType(result); Assert.Equal("/test1/api/users/{userId}/orders", v1Result.Resource); Assert.Equal("/test1/api/users/123/orders", v1Result.Path); Assert.Equal("POST", v1Result.HttpMethod); - Assert.Equal("TestAgent", v1Result.Headers["user-agent"]); - Assert.Equal("application/json", v1Result.Headers["accept"]); - Assert.Equal(new List { "text/html", "application/json" }, v1Result.MultiValueHeaders["accept"]); Assert.Equal("session=abc123; theme=dark", v1Result.Headers["cookie"]); - Assert.Equal("value1", v1Result.Headers["x-custom-header"]); - Assert.Equal(new List { "TestAgent" }, v1Result.MultiValueHeaders["user-agent"]); - Assert.Equal(new List { "text/html", "application/json" }, v1Result.MultiValueHeaders["accept"]); - Assert.Equal(new List { "session=abc123; theme=dark" }, v1Result.MultiValueHeaders["cookie"]); - Assert.Equal(new List { "value1" }, v1Result.MultiValueHeaders["x-custom-header"]); + Assert.Equal("TestAgent", v1Result.Headers["User-Agent"]); + Assert.Equal("session=abc123; theme=dark", v1Result.Headers["Cookie"]); + Assert.Equal("value1", v1Result.Headers["X-Custom-Header"]); + Assert.Equal(new List { "TestAgent" }, v1Result.MultiValueHeaders["User-Agent"]); + Assert.Equal(new List { "session=abc123; theme=dark" }, v1Result.MultiValueHeaders["Cookie"]); + Assert.Equal(new List { "value1" }, v1Result.MultiValueHeaders["X-Custom-Header"]); Assert.Equal("pending", v1Result.QueryStringParameters["status"]); Assert.Equal("urgent", v1Result.QueryStringParameters["tag"]); Assert.Equal(new List { "pending" }, v1Result.MultiValueQueryStringParameters["status"]); @@ -58,65 +55,10 @@ public static IEnumerable V1TestCases() } }; - yield return new object[] - { - "V1_EmptyCollections", - new ApiGatewayTestCaseForRequest - { - HttpContext = CreateHttpContext("POST", "/test2/api/notmatchingpath/123/orders"), - ApiGatewayRouteConfig = new ApiGatewayRouteConfig - { - LambdaResourceName = "TestLambdaFunction", - Endpoint = "/test2/api/users/{userId}/orders", - HttpMethod = "POST", - Path = "/test2/api/users/{userId}/orders" - }, - Assertions = (result) => - { - var v1Result = Assert.IsType(result); - Assert.Equal(2, v1Result.Headers.Count); - Assert.Equal("0", v1Result.Headers["content-length"]); - Assert.Equal("text/plain; charset=utf-8", v1Result.Headers["content-type"]); - Assert.Equal(new List { "0" }, v1Result.MultiValueHeaders["content-length"]); - Assert.Equal(new List { "text/plain; charset=utf-8" }, v1Result.MultiValueHeaders["content-type"]); - Assert.Null(v1Result.QueryStringParameters); - Assert.Null(v1Result.MultiValueQueryStringParameters); - Assert.Null(v1Result.PathParameters); - } - } - }; - - yield return new object[] - { - "V1_BinaryContent", - new ApiGatewayTestCaseForRequest - { - HttpContext = CreateHttpContext("POST", "/test3/api/users/123/avatar", - new Dictionary { { "Content-Type", "application/octet-stream" } }, - body: new byte[] { 1, 2, 3, 4, 5 }), - ApiGatewayRouteConfig = new ApiGatewayRouteConfig - { - LambdaResourceName = "UploadAvatarFunction", - Endpoint = "/test3/api/users/{userId}/avatar", - HttpMethod = "POST", - Path = "/test3/api/users/{userId}/avatar" - }, - Assertions = (result) => - { - var v1Result = Assert.IsType(result); - Assert.True(v1Result.IsBase64Encoded); - Assert.Equal(Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5 }), v1Result.Body); - Assert.Equal("123", v1Result.PathParameters["userId"]); - Assert.Equal("/test3/api/users/{userId}/avatar", v1Result.Resource); - Assert.Equal("POST", v1Result.HttpMethod); - } - } - }; - yield return new object[] { "V1_UrlEncodedQueryString", - new ApiGatewayTestCaseForRequest + new HttpContextTestCase { HttpContext = CreateHttpContext("POST", "/test4/api/search", queryString: "?q=Hello%20World&tag=C%23%20Programming&tag=.NET%20Core"), @@ -127,7 +69,7 @@ public static IEnumerable V1TestCases() HttpMethod = "POST", Path = "/test4/api/search" }, - Assertions = (result) => + Assertions = (result, emulatorMode) => { var v1Result = Assert.IsType(result); Assert.Equal("Hello World", v1Result.QueryStringParameters["q"]); @@ -141,7 +83,7 @@ public static IEnumerable V1TestCases() yield return new object[] { "V1_SpecialCharactersInPath", - new ApiGatewayTestCaseForRequest + new HttpContextTestCase { HttpContext = CreateHttpContext("POST", "/test5/api/users/****%20Doe/orders/Summer%20Sale%202023"), ApiGatewayRouteConfig = new ApiGatewayRouteConfig @@ -151,12 +93,22 @@ public static IEnumerable V1TestCases() HttpMethod = "POST", Path = "/test5/api/users/{username}/orders/{orderName}" }, - Assertions = (result) => + Assertions = (result, emulatorMode) => { var v1Result = Assert.IsType(result); - Assert.Equal("/test5/api/users/**** Doe/orders/Summer Sale 2023", v1Result.Path); - Assert.Equal("**** Doe", v1Result.PathParameters["username"]); - Assert.Equal("Summer Sale 2023", v1Result.PathParameters["orderName"]); + + if (emulatorMode == ApiGatewayEmulatorMode.HttpV1) + { + Assert.Equal("/test5/api/users/****%20Doe/orders/Summer%20Sale%202023", v1Result.Path); + Assert.Equal("**** Doe", v1Result.PathParameters["username"]); + Assert.Equal("Summer Sale 2023", v1Result.PathParameters["orderName"]); + } + else + { + Assert.Equal("/test5/api/users/****%20Doe/orders/Summer%20Sale%202023", v1Result.Path); + Assert.Equal("****%20Doe", v1Result.PathParameters["username"]); + Assert.Equal("Summer%20Sale%202023", v1Result.PathParameters["orderName"]); + } } } }; @@ -164,7 +116,7 @@ public static IEnumerable V1TestCases() yield return new object[] { "V1_UnicodeCharactersInPath", - new ApiGatewayTestCaseForRequest + new HttpContextTestCase { HttpContext = CreateHttpContext("POST", "/test6/api/products/%E2%98%95%20Coffee/reviews/%F0%9F%98%8A%20Happy"), ApiGatewayRouteConfig = new ApiGatewayRouteConfig @@ -174,76 +126,22 @@ public static IEnumerable V1TestCases() HttpMethod = "POST", Path = "/test6/api/products/{productName}/reviews/{reviewTitle}" }, - Assertions = (result) => + Assertions = (result, emulatorMode) => { var v1Result = Assert.IsType(result); - Assert.Equal("/test6/api/products/☕ Coffee/reviews/😊 Happy", v1Result.Path); - Assert.Equal("☕ Coffee", v1Result.PathParameters["productName"]); - Assert.Equal("😊 Happy", v1Result.PathParameters["reviewTitle"]); - } - } - }; - yield return new object[] - { - "V1_EncodedAndUnicodeHeaders", - new ApiGatewayTestCaseForRequest - { - HttpContext = CreateHttpContext("POST", "/test7/api/test", - new Dictionary + if (emulatorMode == ApiGatewayEmulatorMode.HttpV1) { - { "X-Encoded-Header", "value%20with%20spaces" }, - { "X-Unicode-Header", "☕ Coffee" }, - { "X-Mixed-Header", "Hello%2C%20World%21%20☕" }, - { "X-Raw-Unicode", "\u2615 Coffee" } - }), - ApiGatewayRouteConfig = new ApiGatewayRouteConfig - { - LambdaResourceName = "TestFunction", - Endpoint = "/test7/api/test", - HttpMethod = "POST", - Path = "/test7/api/test" - }, - Assertions = (result) => - { - var v1Result = Assert.IsType(result); - Assert.Equal("value%20with%20spaces", v1Result.Headers["x-encoded-header"]); - Assert.Equal("☕ Coffee", v1Result.Headers["x-unicode-header"]); - Assert.Equal("Hello%2C%20World%21%20☕", v1Result.Headers["x-mixed-header"]); - Assert.Equal("\u2615 Coffee", v1Result.Headers["x-raw-unicode"]); - Assert.Equal(new List { "value%20with%20spaces" }, v1Result.MultiValueHeaders["x-encoded-header"]); - Assert.Equal(new List { "☕ Coffee" }, v1Result.MultiValueHeaders["x-unicode-header"]); - Assert.Equal(new List { "Hello%2C%20World%21%20☕" }, v1Result.MultiValueHeaders["x-mixed-header"]); - Assert.Equal(new List { "\u2615 Coffee" }, v1Result.MultiValueHeaders["x-raw-unicode"]); - } - } - }; - - yield return new object[] - { - "V1_MultipleHeaderValuesWithUnicode", - new ApiGatewayTestCaseForRequest - { - HttpContext = CreateHttpContext("POST", "/test8/api/test", - new Dictionary + Assert.Equal("/test6/api/products/%E2%98%95%20Coffee/reviews/%F0%9F%98%8A%20Happy", v1Result.Path); + Assert.Equal("☕ Coffee", v1Result.PathParameters["productName"]); + Assert.Equal("😊 Happy", v1Result.PathParameters["reviewTitle"]); + } + else { - { "X-Multi-Value", new StringValues(new[] { "value1", "value2%20with%20spaces", "☕ Coffee", "value4%20☕" }) } - }), - ApiGatewayRouteConfig = new ApiGatewayRouteConfig - { - LambdaResourceName = "TestFunction", - Endpoint = "/test8/api/test", - HttpMethod = "POST", - Path = "/test8/api/test" - }, - Assertions = (result) => - { - var v1Result = Assert.IsType(result); - Assert.Equal("value4%20☕", v1Result.Headers["x-multi-value"]); - Assert.Equal( - new List { "value1", "value2%20with%20spaces", "☕ Coffee", "value4%20☕" }, - v1Result.MultiValueHeaders["x-multi-value"] - ); + Assert.Equal("/test6/api/products/%E2%98%95%20Coffee/reviews/%F0%9F%98%8A%20Happy", v1Result.Path); + Assert.Equal("%E2%98%95%20Coffee", v1Result.PathParameters["productName"]); + Assert.Equal("%F0%9F%98%8A%20Happy", v1Result.PathParameters["reviewTitle"]); + } } } }; @@ -254,7 +152,7 @@ public static IEnumerable V2TestCases() yield return new object[] { "V2_SimpleGetRequest", - new ApiGatewayTestCaseForRequest + new HttpContextTestCase { HttpContext = CreateHttpContext("POST", "/test9/api/users/123/orders", new Dictionary @@ -272,7 +170,7 @@ public static IEnumerable V2TestCases() HttpMethod = "POST", Path = "/test9/api/users/{userId}/orders" }, - Assertions = (result) => + Assertions = (result, emulatorMode) => { var v2Result = Assert.IsType(result); Assert.Equal("POST /test9/api/users/{userId}/orders", v2Result.RouteKey); @@ -280,7 +178,6 @@ public static IEnumerable V2TestCases() Assert.Equal("status=pending&tag=important&tag=urgent", v2Result.RawQueryString); Assert.Equal("TestAgent", v2Result.Headers["user-agent"]); Assert.Equal("text/html, application/json", v2Result.Headers["accept"]); - //Assert.Equal("session=abc123; theme=dark", v2Result.Headers["cookie"]); Assert.Equal("value1", v2Result.Headers["x-custom-header"]); Assert.Equal("pending", v2Result.QueryStringParameters["status"]); Assert.Equal("important,urgent", v2Result.QueryStringParameters["tag"]); @@ -293,36 +190,10 @@ public static IEnumerable V2TestCases() } }; - yield return new object[] - { - "V2_EmptyCollections", - new ApiGatewayTestCaseForRequest - { - HttpContext = CreateHttpContext("POST", "/test10/api/notmatchingpath/123/orders"), - ApiGatewayRouteConfig = new ApiGatewayRouteConfig - { - LambdaResourceName = "TestLambdaFunction", - Endpoint = "/test10/api/users/{userId}/orders", - HttpMethod = "POST", - Path = "/test10/api/users/{userId}/orders" - }, - Assertions = (result) => - { - var v2Result = Assert.IsType(result); - Assert.Equal(2, v2Result.Headers.Count); - Assert.Equal("0", v2Result.Headers["content-length"]); - Assert.Equal("text/plain; charset=utf-8", v2Result.Headers["content-type"]); - Assert.Null(v2Result.QueryStringParameters); - Assert.Null(v2Result.PathParameters); - Assert.Null(v2Result.Cookies); - } - } - }; - yield return new object[] { "V2_BinaryContent", - new ApiGatewayTestCaseForRequest + new HttpContextTestCase { HttpContext = CreateHttpContext("POST", "/test11/api/users/123/avatar", new Dictionary { { "Content-Type", "application/octet-stream" } }, @@ -334,7 +205,7 @@ public static IEnumerable V2TestCases() HttpMethod = "POST", Path = "/test11/api/users/{userId}/avatar" }, - Assertions = (result) => + Assertions = (result, emulatorMode) => { var v2Result = Assert.IsType(result); Assert.True(v2Result.IsBase64Encoded); @@ -349,7 +220,7 @@ public static IEnumerable V2TestCases() yield return new object[] { "V2_UrlEncodedQueryString", - new ApiGatewayTestCaseForRequest + new HttpContextTestCase { HttpContext = CreateHttpContext("POST", "/test12/api/search", queryString: "?q=Hello%20World&tag=C%23%20Programming&tag=.NET%20Core"), @@ -360,7 +231,7 @@ public static IEnumerable V2TestCases() HttpMethod = "POST", Path = "/test12/api/search" }, - Assertions = (result) => + Assertions = (result, emulatorMode) => { var v2Result = Assert.IsType(result); Assert.Equal("q=Hello%20World&tag=C%23%20Programming&tag=.NET%20Core", v2Result.RawQueryString); @@ -373,7 +244,7 @@ public static IEnumerable V2TestCases() yield return new object[] { "V2_SpecialCharactersInPath", - new ApiGatewayTestCaseForRequest + new HttpContextTestCase { HttpContext = CreateHttpContext("POST", "/test13/api/users/****%20Doe/orders/Summer%20Sale%202023"), ApiGatewayRouteConfig = new ApiGatewayRouteConfig @@ -383,7 +254,7 @@ public static IEnumerable V2TestCases() HttpMethod = "POST", Path = "/test13/api/users/{username}/orders/{orderName}" }, - Assertions = (result) => + Assertions = (result, emulatorMode) => { var v2Result = Assert.IsType(result); Assert.Equal("/test13/api/users/**** Doe/orders/Summer Sale 2023", v2Result.RawPath); @@ -397,7 +268,7 @@ public static IEnumerable V2TestCases() yield return new object[] { "V2_UnicodeCharactersInPath", - new ApiGatewayTestCaseForRequest + new HttpContextTestCase { HttpContext = CreateHttpContext("POST", "/test14/api/products/%E2%98%95%20Coffee/reviews/%F0%9F%98%8A%20Happy"), ApiGatewayRouteConfig = new ApiGatewayRouteConfig @@ -407,7 +278,7 @@ public static IEnumerable V2TestCases() HttpMethod = "POST", Path = "/test14/api/products/{productName}/reviews/{reviewTitle}" }, - Assertions = (result) => + Assertions = (result, emulatorMode) => { var v2Result = Assert.IsType(result); Assert.Equal("/test14/api/products/☕ Coffee/reviews/😊 Happy", v2Result.RawPath); @@ -417,65 +288,9 @@ public static IEnumerable V2TestCases() } } }; - - yield return new object[] - { - "V2_EncodedAndUnicodeHeaders", - new ApiGatewayTestCaseForRequest - { - HttpContext = CreateHttpContext("POST", "/test15/api/test", - new Dictionary - { - { "X-Encoded-Header", "value%20with%20spaces" }, - { "X-Unicode-Header", "☕ Coffee" }, - { "X-Mixed-Header", "Hello%2C%20World%21%20☕" }, - { "X-Raw-Unicode", "\u2615 Coffee" } - }), - ApiGatewayRouteConfig = new ApiGatewayRouteConfig - { - LambdaResourceName = "TestFunction", - Endpoint = "/test15/api/test", - HttpMethod = "POST", - Path = "/test15/api/test" - }, - Assertions = (result) => - { - var v2Result = Assert.IsType(result); - Assert.Equal("value%20with%20spaces", v2Result.Headers["x-encoded-header"]); - Assert.Equal("☕ Coffee", v2Result.Headers["x-unicode-header"]); - Assert.Equal("Hello%2C%20World%21%20☕", v2Result.Headers["x-mixed-header"]); - Assert.Equal("\u2615 Coffee", v2Result.Headers["x-raw-unicode"]); - } - } - }; - - yield return new object[] - { - "V2_MultipleHeaderValuesWithUnicode", - new ApiGatewayTestCaseForRequest - { - HttpContext = CreateHttpContext("POST", "/test16/api/test", - new Dictionary - { - { "X-Multi-Value", new StringValues(new[] { "value1", "value2%20with%20spaces", "☕ Coffee", "value4%20☕" }) } - }), - ApiGatewayRouteConfig = new ApiGatewayRouteConfig - { - LambdaResourceName = "TestFunction", - Endpoint = "/test16/api/test", - HttpMethod = "POST", - Path = "/test16/api/test" - }, - Assertions = (result) => - { - var v2Result = Assert.IsType(result); - Assert.Equal("value1, value2%20with%20spaces, ☕ Coffee, value4%20☕", v2Result.Headers["x-multi-value"]); - } - } - }; } - private static DefaultHttpContext CreateHttpContext(string method, string path, + public static DefaultHttpContext CreateHttpContext(string method, string path, Dictionary? headers = null, string? queryString = null, byte[]? body = null) { var context = new DefaultHttpContext(); @@ -502,11 +317,11 @@ private static DefaultHttpContext CreateHttpContext(string method, string path, } - public class ApiGatewayTestCaseForRequest + public class HttpContextTestCase { public required DefaultHttpContext HttpContext { get; set; } public required ApiGatewayRouteConfig ApiGatewayRouteConfig { get; set; } - public required Action Assertions { get; set; } + public required Action Assertions { get; set; } } } } diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/HttpRequestUtilityTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/HttpRequestUtilityTests.cs index 10646f12a..192475e77 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/HttpRequestUtilityTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/HttpRequestUtilityTests.cs @@ -25,21 +25,21 @@ public class HttpRequestUtilityTests [InlineData("application/json", false)] [InlineData(null, false)] [InlineData("", false)] - public void IsBinaryContent_ReturnsExpectedResult(string contentType, bool expected) + public void IsBinaryContent_ReturnsExpectedResult(string? contentType, bool expected) { var result = HttpRequestUtility.IsBinaryContent(contentType); Assert.Equal(expected, result); } [Fact] - public void ReadRequestBody_ReturnsCorrectContent() + public async Task ReadRequestBody_ReturnsCorrectContent() { var content = "Test body content"; var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); var request = new Mock(); request.Setup(r => r.Body).Returns(stream); - var result = HttpRequestUtility.ReadRequestBody(request.Object); + var result = await HttpRequestUtility.ReadRequestBody(request.Object); Assert.Equal(content, result); } diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs index cc40fd0cd..ff130cd7f 100644 --- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs @@ -4,7 +4,6 @@ namespace Amazon.Lambda.TestTool.UnitTests.Utilities; using Amazon.Lambda.TestTool.Utilities; -using Microsoft.AspNetCore.Routing.Template; using Xunit; public class RouteTemplateUtilityTests @@ -14,11 +13,27 @@ public class RouteTemplateUtilityTests [InlineData("/users/{id}/orders/{orderId}", "/users/123/orders/456", "id", "123", "orderId", "456")] [InlineData("/products/{category}/{id}", "/products/electronics/laptop-123", "category", "electronics", "id", "laptop-123")] [InlineData("/api/{version}/users/{userId}", "/api/v1/users/abc-xyz", "version", "v1", "userId", "abc-xyz")] + [InlineData("/api/{proxy+}", "/api/v1/users/abc-xyz", "proxy", "v1/users/abc-xyz")] + [InlineData("/{param}", "/value", "param", "value")] + [InlineData("/{param}/", "/value/", "param", "value")] + [InlineData("/static/{param}/static", "/static/value/static", "param", "value")] + [InlineData("/{param1}/{param2}", "/123/456", "param1", "123", "param2", "456")] + [InlineData("/{param1}/{param2+}", "/123/456/789/000", "param1", "123", "param2", "456/789/000")] + [InlineData("/api/{version}/{proxy+}", "/api/v2/users/123/orders", "version", "v2", "proxy", "users/123/orders")] + [InlineData("/{param}", "/value with spaces", "param", "value with spaces")] + [InlineData("/{param+}", "/a/very/long/path/with/many/segments", "param", "a/very/long/path/with/many/segments")] + [InlineData("/api/{proxy+}", "/api/", "proxy", "")] + [InlineData("/api/{proxy+}", "/api", "proxy", "")] + [InlineData("/{param1}/static/{param2+}", "/value1/static/rest/of/the/path", "param1", "value1", "param2", "rest/of/the/path")] + [InlineData("/users/{id}/posts/{postId?}", "/users/123/posts", "id", "123")] + [InlineData("/{param:int}", "/123", "param:int", "123")] + [InlineData("/users/{id}", "/users/", new string[] { })] + [InlineData("/", "/", new string[] { })] public void ExtractPathParameters_ShouldExtractCorrectly(string routeTemplate, string actualPath, params string[] expectedKeyValuePairs) { // Arrange var expected = new Dictionary(); - for (int i = 0; i < expectedKeyValuePairs.Length; i += 2) + for (var i = 0; i < expectedKeyValuePairs.Length; i += 2) { expected[expectedKeyValuePairs[i]] = expectedKeyValuePairs[i + 1]; } @@ -42,51 +57,4 @@ public void ExtractPathParameters_ShouldReturnEmptyDictionary_WhenNoMatch(string // Assert Assert.Empty(result); } - - [Theory] - [InlineData("/users/{id:int}", "/users/123", "id", "123")] - [InlineData("/users/{id:guid}", "/users/550e8400-e29b-41d4-a716-446655440000", "id", "550e8400-e29b-41d4-a716-446655440000")] - [InlineData("/api/{version:regex(^v[0-9]+$)}/users/{userId}", "/api/v1/users/abc-xyz", "version", "v1", "userId", "abc-xyz")] - public void ExtractPathParameters_ShouldHandleConstraints(string routeTemplate, string actualPath, params string[] expectedKeyValuePairs) - { - // Arrange - var expected = new Dictionary(); - for (int i = 0; i < expectedKeyValuePairs.Length; i += 2) - { - expected[expectedKeyValuePairs[i]] = expectedKeyValuePairs[i + 1]; - } - - // Act - var result = RouteTemplateUtility.ExtractPathParameters(routeTemplate, actualPath); - - // Assert - Assert.Equal(expected, result); - } - - [Fact] - public void GetDefaults_ShouldReturnCorrectDefaults() - { - // Arrange - var template = TemplateParser.Parse("/api/{version=v1}/users/{id}"); - - // Act - var result = RouteTemplateUtility.GetDefaults(template); - - // Assert - Assert.Single(result); - Assert.Equal("v1", result["version"]); - } - - [Fact] - public void GetDefaults_ShouldReturnEmptyDictionary_WhenNoDefaults() - { - // Arrange - var template = TemplateParser.Parse("/api/{version}/users/{id}"); - - // Act - var result = RouteTemplateUtility.GetDefaults(template); - - // Assert - Assert.Empty(result); - } } From c3bf7cd652afe98a3761e720604495924e033e6e Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Mon, 23 Dec 2024 16:50:27 -0500 Subject: [PATCH 3/3] Update comment --- .../Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs index 1bfaf90b3..29693e210 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs @@ -78,7 +78,10 @@ private static string PreprocessRouteTemplate(string template) // Convert AWS-style {proxy+} to ASP.NET Core style {*proxy} template = Regex.Replace(template, @"\{(\w+)\+\}", "{*$1}"); - // Handle AWS-style "constraints" by replacing them with temporary parameter names + // AWS allows you to name a route as {abc:int}, which gets parsed by AWS as the path + // variable abc:int. However, .NET template matcher, thinks {abc:int} means + // abc variable with the int constraint. So we are converting variables that have + // contstraints to a different name temporarily, so template matcher doesn't think they are constraints. return Regex.Replace(template, @"\{([^}]+):([^}]+)\}", match => { var paramName = match.Groups[1].Value;