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..8de768cae --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/HttpContextExtensions.cs @@ -0,0 +1,214 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.TestTool.Extensions; + +using System.Text; +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 async Task ToApiGatewayHttpV2Request( + this HttpContext context, + ApiGatewayRouteConfig apiGatewayRouteConfig) + { + var request = context.Request; + var currentTime = DateTimeOffset.UtcNow; + 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. + // 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) + ); + + // 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 async Task ToApiGatewayRequest( + this HttpContext context, + ApiGatewayRouteConfig apiGatewayRouteConfig, + ApiGatewayEmulatorMode emulatorMode) + { + var request = context.Request; + var body = await 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") && emulatorMode != ApiGatewayEmulatorMode.Rest) // rest doesnt set content-length by default + { + headers["content-length"] = contentLength.ToString(); + multiValueHeaders["content-length"] = [contentLength.ToString()]; + } + + if (!headers.ContainsKey("content-type")) + { + headers["content-type"] = "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 = path, + 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()) + { + proxyRequest.PathParameters = pathParameters; + } + + if (HttpRequestUtility.IsBinaryContent(request.ContentType)) + { + 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..dc1acba6a --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/HttpRequestUtility.cs @@ -0,0 +1,183 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +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 async Task 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()) + { + await request.Body.CopyToAsync(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 = await reader.ReadToEndAsync(); + return string.IsNullOrWhiteSpace(content) ? null : content; + } + } + } + } + + + + /// + /// 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: + /// 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, bool lowerCaseKeyName = false) + { + var singleValueHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + var multiValueHeaders = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var header in headers) + { + var key = lowerCaseKeyName ? header.Key.ToLower() : header.Key; + singleValueHeaders[key] = header.Value.Last() ?? ""; + multiValueHeaders[key] = [.. 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..29693e210 --- /dev/null +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs @@ -0,0 +1,117 @@ +namespace Amazon.Lambda.TestTool.Utilities; + +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Routing.Template; + +/// +/// Provides utility methods for working with route templates and extracting path parameters. +/// +public static class RouteTemplateUtility +{ + private const string TemporaryPrefix = "__aws_param__"; + + /// + /// 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) + { + // 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, new RouteValueDictionary()); + var routeValues = new RouteValueDictionary(); + + if (matcher.TryMatch(actualPath, routeValues)) + { + 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(); + } + + /// + /// Preprocesses a route template to make it compatible with ASP.NET Core's TemplateMatcher. + /// + /// 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) + { + // Convert AWS-style {proxy+} to ASP.NET Core style {*proxy} + template = Regex.Replace(template, @"\{(\w+)\+\}", "{*$1}"); + + // 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; + 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}}}"; + }); + } + + /// + /// 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)) + { + var parts = processedName.Substring(TemporaryPrefix.Length).Split("__", 2); + if (parts.Length == 2) + { + return $"{parts[0]}:{parts[1]}"; + } + } + return processedName; + } +} 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..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 @@ -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 { @@ -22,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 @@ -46,29 +51,156 @@ 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) + // Check if we get a successful response + if (response.StatusCode != HttpStatusCode.Forbidden && response.StatusCode != HttpStatusCode.NotFound) { - return; // API is available and responding + if (!hasBeenSuccessful) + { + successStartTime = DateTime.UtcNow; + hasBeenSuccessful = true; + } + + if ((DateTime.UtcNow - successStartTime) >= requiredSuccessDuration) + { + return; // API has been responding successfully for at least 10 seconds + } + } + else + { + // 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", string httpMethod = "ANY") + { + // 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; + + // Split the route into parts and create each part + var routeParts = route.Trim('/').Split('/'); + string currentPath = ""; + string parentResourceId = rootResourceId; + + foreach (var part in routeParts) + { + 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 = parentResourceId, + PathPart = part + }); + parentResourceId = createResourceResponse.Id; + } + else + { + parentResourceId = existingResource.Id; + } + } + + // Create the method for the final resource + await _apiGatewayV1Client.PutMethodAsync(new PutMethodRequest + { + RestApiId = restApiId, + ResourceId = parentResourceId, + HttpMethod = httpMethod, + AuthorizationType = "NONE" + }); + + // Create the integration for the method + await _apiGatewayV1Client.PutIntegrationAsync(new PutIntegrationRequest + { + RestApiId = restApiId, + 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" + }); + + // Deploy the API + var deploymentResponse = 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..da52edcdc --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.IntegrationTests/HttpContextExtensionsTests.cs @@ -0,0 +1,325 @@ +// 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.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 +{ + [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))] + [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, _fixture.ReturnFullEventRestApiId, + async (context, config) => await context.ToApiGatewayRequest(config, ApiGatewayEmulatorMode.Rest), 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 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, _fixture.ReturnFullEventHttpApiV1Id, + async (context, config) => await context.ToApiGatewayRequest(config, ApiGatewayEmulatorMode.HttpV1), ApiGatewayEmulatorMode.HttpV1); + } + + [Theory] + [MemberData(nameof(HttpContextTestCases.V2TestCases), MemberType = typeof(HttpContextTestCases))] + [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, _fixture.ReturnFullEventHttpApiV2Id, + async (context, config) => await context.ToApiGatewayHttpV2Request(config), ApiGatewayEmulatorMode.HttpV2); + } + + [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 = 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); + + 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 = await toApiGatewayRequest(testCase.HttpContext, testCase.ApiGatewayRouteConfig); + + CompareApiGatewayRequests(expectedApiGatewayRequest, 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) + { + 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..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 @@ -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,503 @@ 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' + EndpointConfiguration: + Types: + - REGIONAL - 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' + EndpointConfiguration: + Types: + - REGIONAL + + 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' + EndpointConfiguration: + Types: + - REGIONAL + + 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' + EndpointConfiguration: + Types: + - REGIONAL 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..b925ea402 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextExtensionsTests.cs @@ -0,0 +1,248 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// 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 +{ + public class HttpContextExtensionsTests + { + [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 ToApiGatewayRequestRest_ConvertsCorrectly(string testName, HttpContextTestCase testCase) + { + // Arrange + var context = testCase.HttpContext; + + // Act + var result = await context.ToApiGatewayRequest(testCase.ApiGatewayRouteConfig, ApiGatewayEmulatorMode.Rest); + + // Assert + 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))] + [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 = 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 + 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 new file mode 100644 index 000000000..0d6442948 --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextTestCases.cs @@ -0,0 +1,327 @@ +// 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 HttpContextTestCase + { + HttpContext = CreateHttpContext("POST", "/test1/api/users/123/orders", + new Dictionary + { + { "User-Agent", "TestAgent" }, + { "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, 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("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"]); + 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_UrlEncodedQueryString", + new HttpContextTestCase + { + 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, emulatorMode) => + { + 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 HttpContextTestCase + { + 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, emulatorMode) => + { + var v1Result = Assert.IsType(result); + + 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"]); + } + } + } + }; + + yield return new object[] + { + "V1_UnicodeCharactersInPath", + new HttpContextTestCase + { + 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, emulatorMode) => + { + var v1Result = Assert.IsType(result); + + if (emulatorMode == ApiGatewayEmulatorMode.HttpV1) + { + 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 + { + 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"]); + } + } + } + }; + } + + public static IEnumerable V2TestCases() + { + yield return new object[] + { + "V2_SimpleGetRequest", + new HttpContextTestCase + { + 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, emulatorMode) => + { + 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("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_BinaryContent", + new HttpContextTestCase + { + 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, emulatorMode) => + { + 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 HttpContextTestCase + { + 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, emulatorMode) => + { + 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 HttpContextTestCase + { + 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, emulatorMode) => + { + 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 HttpContextTestCase + { + 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, emulatorMode) => + { + 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"]); + } + } + }; + } + + public 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 HttpContextTestCase + { + 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..192475e77 --- /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 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 = await 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..ff130cd7f --- /dev/null +++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs @@ -0,0 +1,60 @@ +// 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 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")] + [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 (var 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); + } +}