diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Amazon.Lambda.TestTool.csproj b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Amazon.Lambda.TestTool.csproj
index 7afdd2e7a..34bbacfb4 100644
--- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Amazon.Lambda.TestTool.csproj
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Amazon.Lambda.TestTool.csproj
@@ -19,6 +19,7 @@
+
diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ExceptionExtensions.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ExceptionExtensions.cs
index 7aa9e6b38..8a13b04aa 100644
--- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ExceptionExtensions.cs
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ExceptionExtensions.cs
@@ -1,29 +1,29 @@
-using Amazon.Lambda.TestTool.Models;
-
-namespace Amazon.Lambda.TestTool.Extensions;
-
+using Amazon.Lambda.TestTool.Models;
+
+namespace Amazon.Lambda.TestTool.Extensions;
+
///
/// A class that contains extension methods for the class.
-///
-public static class ExceptionExtensions
-{
- ///
- /// True if the inherits from
- /// .
- ///
- public static bool IsExpectedException(this Exception e) =>
- e is TestToolException;
-
+///
+public static class ExceptionExtensions
+{
+ ///
+ /// True if the inherits from
+ /// .
+ ///
+ public static bool IsExpectedException(this Exception e) =>
+ e is TestToolException;
+
///
/// Prints an exception in a user-friendly way.
- ///
- public static string PrettyPrint(this Exception? e)
- {
- if (null == e)
- return string.Empty;
-
- return $"{Environment.NewLine}{e.Message}" +
- $"{Environment.NewLine}{e.StackTrace}" +
- $"{PrettyPrint(e.InnerException)}";
- }
+ ///
+ public static string PrettyPrint(this Exception? e)
+ {
+ if (null == e)
+ return string.Empty;
+
+ return $"{Environment.NewLine}{e.Message}" +
+ $"{Environment.NewLine}{e.StackTrace}" +
+ $"{PrettyPrint(e.InnerException)}";
+ }
}
\ No newline at end of file
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..64aafb6cc
--- /dev/null
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/HttpContextExtensions.cs
@@ -0,0 +1,161 @@
+namespace Amazon.Lambda.TestTool.Extensions;
+
+using Amazon.Lambda.APIGatewayEvents;
+using Amazon.Lambda.TestTool.Models;
+using Amazon.Lambda.TestTool.Services;
+using Amazon.Lambda.TestTool.Utilities;
+using System.Text;
+using System.Web;
+using static Amazon.Lambda.APIGatewayEvents.APIGatewayHttpApiV2ProxyRequest;
+
+///
+/// Provides extension methods to translate an to different types of API Gateway requests.
+///
+public static class HttpContextExtensions
+{
+ ///
+ /// Translates an to an .
+ ///
+ /// The to be translated.
+ /// The configuration of the API Gateway route, including the HTTP method, path, and other metadata.
+ /// An object representing the translated request.
+ public static APIGatewayHttpApiV2ProxyRequest ToApiGatewayHttpV2Request(
+ this HttpContext context,
+ ApiGatewayRouteConfig apiGatewayRouteConfig)
+ {
+ var request = context.Request;
+
+ var pathParameters = RouteTemplateUtility.ExtractPathParameters(apiGatewayRouteConfig.Path, request.Path);
+
+ // Format 2.0 doesn't have multiValueHeaders or multiValueQueryStringParameters fields. Duplicate headers are combined with commas and included in the headers field.
+ var (_, allHeaders) = HttpRequestUtility.ExtractHeaders(request.Headers);
+ var headers = allHeaders.ToDictionary(
+ kvp => kvp.Key,
+ kvp => string.Join(",", kvp.Value)
+ );
+
+ // Duplicate query strings are combined with commas and included in the queryStringParameters field.
+ var (_, allQueryParams) = HttpRequestUtility.ExtractQueryStringParameters(request.Query);
+ var queryStringParameters = allQueryParams.ToDictionary(
+ kvp => kvp.Key,
+ kvp => string.Join(",", kvp.Value)
+ );
+
+ var httpApiV2ProxyRequest = new APIGatewayHttpApiV2ProxyRequest
+ {
+ RouteKey = $"{request.Method} {apiGatewayRouteConfig.Path}",
+ RawPath = request.Path.Value, // this should be decoded value
+ Body = HttpRequestUtility.ReadRequestBody(request),
+ IsBase64Encoded = false,
+ RequestContext = new ProxyRequestContext
+ {
+ Http = new HttpDescription
+ {
+ Method = request.Method,
+ Path = request.Path.Value, // this should be decoded value
+ Protocol = request.Protocol
+ },
+ 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;
+ }
+
+ 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))
+ {
+ httpApiV2ProxyRequest.Body = Convert.ToBase64String(Encoding.UTF8.GetBytes(httpApiV2ProxyRequest.Body));
+ httpApiV2ProxyRequest.IsBase64Encoded = true;
+ }
+
+ return httpApiV2ProxyRequest;
+ }
+
+ ///
+ /// Translates an to an .
+ ///
+ /// The to be translated.
+ /// The configuration of the API Gateway route, including the HTTP method, path, and other metadata.
+ /// An object representing the translated request.
+ public static APIGatewayProxyRequest ToApiGatewayRequest(
+ this HttpContext context,
+ ApiGatewayRouteConfig apiGatewayRouteConfig)
+ {
+ var request = context.Request;
+
+ var pathParameters = RouteTemplateUtility.ExtractPathParameters(apiGatewayRouteConfig.Path, request.Path);
+
+ var (headers, multiValueHeaders) = HttpRequestUtility.ExtractHeaders(request.Headers);
+ var (queryStringParameters, multiValueQueryStringParameters) = HttpRequestUtility.ExtractQueryStringParameters(request.Query);
+
+ var proxyRequest = new APIGatewayProxyRequest
+ {
+ Resource = apiGatewayRouteConfig.Path,
+ Path = request.Path.Value,
+ HttpMethod = request.Method,
+ Body = HttpRequestUtility.ReadRequestBody(request),
+ IsBase64Encoded = false
+ };
+
+ if (headers.Any())
+ {
+ proxyRequest.Headers = headers;
+ }
+
+ if (multiValueHeaders.Any())
+ {
+ proxyRequest.MultiValueHeaders = multiValueHeaders;
+ }
+
+ if (queryStringParameters.Any())
+ {
+ // this should be decoded value
+ proxyRequest.QueryStringParameters = queryStringParameters;
+ }
+
+ if (multiValueQueryStringParameters.Any())
+ {
+ // this should be decoded value
+ proxyRequest.MultiValueQueryStringParameters = multiValueQueryStringParameters;
+ }
+
+ if (pathParameters.Any())
+ {
+ // this should be decoded value
+ proxyRequest.PathParameters = pathParameters;
+ }
+
+ if (HttpRequestUtility.IsBinaryContent(request.ContentType))
+ {
+ proxyRequest.Body = Convert.ToBase64String(Encoding.UTF8.GetBytes(proxyRequest.Body));
+ proxyRequest.IsBase64Encoded = true;
+ }
+
+ return proxyRequest;
+ }
+}
diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ServiceCollectionExtensions.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ServiceCollectionExtensions.cs
index 09c3ad19e..5f5526d06 100644
--- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ServiceCollectionExtensions.cs
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Extensions/ServiceCollectionExtensions.cs
@@ -1,31 +1,31 @@
-using Amazon.Lambda.TestTool.Services;
-using Amazon.Lambda.TestTool.Services.IO;
-using Microsoft.Extensions.DependencyInjection.Extensions;
-
-namespace Amazon.Lambda.TestTool.Extensions;
-
-///
-/// A class that contains extension methods for the interface.
-///
-public static class ServiceCollectionExtensions
-{
+using Amazon.Lambda.TestTool.Services;
+using Amazon.Lambda.TestTool.Services.IO;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Amazon.Lambda.TestTool.Extensions;
+
+///
+/// A class that contains extension methods for the interface.
+///
+public static class ServiceCollectionExtensions
+{
///
/// Adds a set of services for the .NET CLI portion of this application.
- ///
- public static void AddCustomServices(this IServiceCollection serviceCollection,
- ServiceLifetime lifetime = ServiceLifetime.Singleton)
- {
- serviceCollection.TryAdd(new ServiceDescriptor(typeof(IToolInteractiveService), typeof(ConsoleInteractiveService), lifetime));
- serviceCollection.TryAdd(new ServiceDescriptor(typeof(IDirectoryManager), typeof(DirectoryManager), lifetime));
+ ///
+ public static void AddCustomServices(this IServiceCollection serviceCollection,
+ ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ {
+ serviceCollection.TryAdd(new ServiceDescriptor(typeof(IToolInteractiveService), typeof(ConsoleInteractiveService), lifetime));
+ serviceCollection.TryAdd(new ServiceDescriptor(typeof(IDirectoryManager), typeof(DirectoryManager), lifetime));
}
///
/// Adds a set of services for the API Gateway emulator portion of this application.
- ///
- public static void AddApiGatewayEmulatorServices(this IServiceCollection serviceCollection,
- ServiceLifetime lifetime = ServiceLifetime.Singleton)
- {
- serviceCollection.TryAdd(new ServiceDescriptor(typeof(IApiGatewayRouteConfigService), typeof(ApiGatewayRouteConfigService), lifetime));
- serviceCollection.TryAdd(new ServiceDescriptor(typeof(IEnvironmentManager), typeof(EnvironmentManager), lifetime));
- }
+ ///
+ public static void AddApiGatewayEmulatorServices(this IServiceCollection serviceCollection,
+ ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ {
+ serviceCollection.TryAdd(new ServiceDescriptor(typeof(IApiGatewayRouteConfigService), typeof(ApiGatewayRouteConfigService), lifetime));
+ serviceCollection.TryAdd(new ServiceDescriptor(typeof(IEnvironmentManager), typeof(EnvironmentManager), lifetime));
+ }
}
\ No newline at end of file
diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs
index 243a1d147..acc9d9405 100644
--- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs
@@ -9,17 +9,17 @@ public class ApiGatewayRouteConfig
/// The name of the Lambda function
///
public required string LambdaResourceName { get; set; }
-
+
///
/// The endpoint of the local Lambda Runtime API
///
public string? Endpoint { get; set; }
-
+
///
/// The HTTP Method for the API Gateway endpoint
///
public required string HttpMethod { get; set; }
-
+
///
/// The API Gateway HTTP Path of the Lambda function
///
diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs
index a714292d7..66a9ba7fc 100644
--- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs
@@ -1,78 +1,78 @@
-using Amazon.Lambda.TestTool.Commands.Settings;
-using Amazon.Lambda.TestTool.Extensions;
-using Amazon.Lambda.TestTool.Models;
-using Amazon.Lambda.TestTool.Services;
-
-namespace Amazon.Lambda.TestTool.Processes;
-
-///
-/// A process that runs the API Gatewat emulator.
-///
-public class ApiGatewayEmulatorProcess
-{
- ///
- /// The service provider that will contain all the registered services.
- ///
+using Amazon.Lambda.TestTool.Commands.Settings;
+using Amazon.Lambda.TestTool.Extensions;
+using Amazon.Lambda.TestTool.Models;
+using Amazon.Lambda.TestTool.Services;
+
+namespace Amazon.Lambda.TestTool.Processes;
+
+///
+/// A process that runs the API Gatewat emulator.
+///
+public class ApiGatewayEmulatorProcess
+{
+ ///
+ /// The service provider that will contain all the registered services.
+ ///
public required IServiceProvider Services { get; init; }
- ///
- /// The API Gatewat emulator task that was started.
- ///
+ ///
+ /// The API Gatewat emulator task that was started.
+ ///
public required Task RunningTask { get; init; }
///
/// The endpoint of the API Gatewat emulator.
- ///
+ ///
public required string ServiceUrl { get; init; }
///
/// Creates the Web API and runs it in the background.
- ///
- public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, CancellationToken cancellationToken = default)
- {
- var builder = WebApplication.CreateBuilder();
-
- builder.Services.AddApiGatewayEmulatorServices();
-
- var serviceUrl = $"http://{settings.Host}:{settings.ApiGatewayEmulatorPort}";
- builder.WebHost.UseUrls(serviceUrl);
+ ///
+ public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, CancellationToken cancellationToken = default)
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ builder.Services.AddApiGatewayEmulatorServices();
+
+ var serviceUrl = $"http://{settings.Host}:{settings.ApiGatewayEmulatorPort}";
+ builder.WebHost.UseUrls(serviceUrl);
builder.WebHost.SuppressStatusMessages(true);
builder.Services.AddHealthChecks();
- var app = builder.Build();
-
+ var app = builder.Build();
+
app.UseHttpsRedirection();
- app.MapHealthChecks("/health");
-
- app.Map("/{**catchAll}", (HttpContext context, IApiGatewayRouteConfigService routeConfigService) =>
- {
- var routeConfig = routeConfigService.GetRouteConfig(context.Request.Method, context.Request.Path);
- if (routeConfig == null)
- {
- return Results.NotFound("Route not found");
- }
-
- if (settings.ApiGatewayEmulatorMode.Equals(ApiGatewayEmulatorMode.HttpV2))
- {
- // TODO: Translate to APIGatewayHttpApiV2ProxyRequest
- }
- else
- {
- // TODO: Translate to APIGatewayProxyRequest
- }
-
- return Results.Ok();
- });
-
- var runTask = app.RunAsync(cancellationToken);
-
- return new ApiGatewayEmulatorProcess
- {
- Services = app.Services,
- RunningTask = runTask,
- ServiceUrl = serviceUrl
- };
- }
+ app.MapHealthChecks("/health");
+
+ app.Map("/{**catchAll}", (HttpContext context, IApiGatewayRouteConfigService routeConfigService) =>
+ {
+ var routeConfig = routeConfigService.GetRouteConfig(context.Request.Method, context.Request.Path);
+ if (routeConfig == null)
+ {
+ return Results.NotFound("Route not found");
+ }
+
+ if (settings.ApiGatewayEmulatorMode.Equals(ApiGatewayEmulatorMode.HttpV2))
+ {
+ // TODO: Translate to APIGatewayHttpApiV2ProxyRequest
+ }
+ else
+ {
+ // TODO: Translate to APIGatewayProxyRequest
+ }
+
+ return Results.Ok();
+ });
+
+ var runTask = app.RunAsync(cancellationToken);
+
+ return new ApiGatewayEmulatorProcess
+ {
+ Services = app.Services,
+ RunningTask = runTask,
+ ServiceUrl = serviceUrl
+ };
+ }
}
\ No newline at end of file
diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs
index 79e1ecd8d..9fae1780f 100644
--- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs
@@ -2,6 +2,7 @@
using System.Text.Json;
using Amazon.Lambda.TestTool.Models;
using Amazon.Lambda.TestTool.Services.IO;
+using Amazon.Lambda.TestTool.Utilities;
using Microsoft.AspNetCore.Routing.Template;
namespace Amazon.Lambda.TestTool.Services;
@@ -66,7 +67,7 @@ public ApiGatewayRouteConfigService(
{
var template = TemplateParser.Parse(routeConfig.Path);
- var matcher = new TemplateMatcher(template, GetDefaults(template));
+ var matcher = new TemplateMatcher(template, RouteTemplateUtility.GetDefaults(template));
var routeValueDictionary = new RouteValueDictionary();
if (!matcher.TryMatch(path, routeValueDictionary))
@@ -80,19 +81,4 @@ public ApiGatewayRouteConfigService(
return null;
}
-
- private RouteValueDictionary GetDefaults(RouteTemplate parsedTemplate)
- {
- var result = new RouteValueDictionary();
-
- foreach (var parameter in parsedTemplate.Parameters)
- {
- if (parameter.DefaultValue != null)
- {
- if (parameter.Name != null) result.Add(parameter.Name, parameter.DefaultValue);
- }
- }
-
- return result;
- }
}
\ No newline at end of file
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..593adfe68
--- /dev/null
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/HttpRequestUtility.cs
@@ -0,0 +1,95 @@
+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.
+ ///
+ /// The HTTP request.
+ /// The body of the request as a string.
+ public static string ReadRequestBody(HttpRequest request)
+ {
+ using (var reader = new StreamReader(request.Body))
+ {
+ return reader.ReadToEnd();
+ }
+ }
+
+ ///
+ /// Extracts headers from the request, separating them into single-value and multi-value dictionaries.
+ ///
+ /// The request headers.
+ /// A tuple containing single-value and multi-value header dictionaries.
+ ///
+ /// For headers:
+ /// Accept: text/html
+ /// Accept: application/xhtml+xml
+ /// X-Custom-Header: value1
+ ///
+ /// The method will return:
+ /// singleValueHeaders: { "Accept": "application/xhtml+xml", "X-Custom-Header": "value1" }
+ /// multiValueHeaders: { "Accept": ["text/html", "application/xhtml+xml"], "X-Custom-Header": ["value1"] }
+ ///
+ public static (IDictionary, IDictionary>) ExtractHeaders(IHeaderDictionary headers)
+ {
+ var singleValueHeaders = new Dictionary();
+ var multiValueHeaders = new Dictionary>();
+
+ foreach (var header in headers)
+ {
+ singleValueHeaders[header.Key] = header.Value.Last() ?? "";
+ multiValueHeaders[header.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¶m2=value2¶m2=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);
+ }
+}
diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs
new file mode 100644
index 000000000..fa9da4e0d
--- /dev/null
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/RouteTemplateUtility.cs
@@ -0,0 +1,66 @@
+namespace Amazon.Lambda.TestTool.Utilities;
+
+using Microsoft.AspNetCore.Routing.Template;
+
+///
+/// Provides utility methods for working with route templates and extracting path parameters.
+///
+public static class RouteTemplateUtility
+{
+ ///
+ /// Extracts path parameters from an actual path based on a route template.
+ ///
+ /// The route template to match against.
+ /// The actual path to extract parameters from.
+ /// A dictionary of extracted path parameters and their values.
+ ///
+ /// Using this method:
+ ///
+ /// var routeTemplate = "/users/{id}/orders/{orderId}";
+ /// var actualPath = "/users/123/orders/456";
+ /// var parameters = RouteTemplateUtility.ExtractPathParameters(routeTemplate, actualPath);
+ /// // parameters will contain: { {"id", "123"}, {"orderId", "456"} }
+ ///
+ ///
+ public static Dictionary ExtractPathParameters(string routeTemplate, string actualPath)
+ {
+ var template = TemplateParser.Parse(routeTemplate);
+ var matcher = new TemplateMatcher(template, GetDefaults(template));
+ var routeValues = new RouteValueDictionary();
+
+ if (matcher.TryMatch(actualPath, routeValues))
+ {
+ return routeValues.ToDictionary(rv => rv.Key, rv => rv.Value?.ToString() ?? string.Empty);
+ }
+
+ return new Dictionary();
+ }
+
+ ///
+ /// Gets the default values for parameters in a parsed route template.
+ ///
+ /// The parsed route template.
+ /// A dictionary of default values for the template parameters.
+ ///
+ /// Using this method:
+ ///
+ /// var template = TemplateParser.Parse("/api/{version=v1}/users/{id}");
+ /// var defaults = RouteTemplateUtility.GetDefaults(template);
+ /// // defaults will contain: { {"version", "v1"} }
+ ///
+ ///
+ public static RouteValueDictionary GetDefaults(RouteTemplate parsedTemplate)
+ {
+ var result = new RouteValueDictionary();
+
+ foreach (var parameter in parsedTemplate.Parameters)
+ {
+ if (parameter.DefaultValue != null)
+ {
+ if (parameter.Name != null) result.Add(parameter.Name, parameter.DefaultValue);
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/Utils.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/Utils.cs
index 2f9cd6ab7..4bbb19856 100644
--- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/Utils.cs
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Utilities/Utils.cs
@@ -1,66 +1,66 @@
-using System.Reflection;
-using System.Text.Json;
-
-namespace Amazon.Lambda.TestTool.Utilities;
-
+using System.Reflection;
+using System.Text.Json;
+
+namespace Amazon.Lambda.TestTool.Utilities;
+
///
/// A utility class that encapsulates common functionlity.
-///
-public static class Utils
-{
+///
+public static class Utils
+{
///
/// Determines the version of the tool.
- ///
- public static string DetermineToolVersion()
- {
- const string unknownVersion = "Unknown";
-
- AssemblyInformationalVersionAttribute? attribute = null;
- try
- {
- var assembly = Assembly.GetEntryAssembly();
- if (assembly == null)
- return unknownVersion;
- attribute = assembly.GetCustomAttribute();
- }
- catch (Exception)
- {
- // ignored
- }
-
- var version = attribute?.InformationalVersion;
-
- // Check to see if the version has a git commit id suffix and if so remove it.
- if (version != null && version.IndexOf('+') != -1)
- {
- version = version.Substring(0, version.IndexOf('+'));
- }
-
- return version ?? unknownVersion;
- }
-
- ///
- /// Attempt to pretty print the input string. If pretty print fails return back the input string in its original form.
- ///
- ///
- ///
- public static string TryPrettyPrintJson(string? data)
- {
- try
- {
- if (string.IsNullOrEmpty(data))
- return string.Empty;
-
- var doc = JsonDocument.Parse(data);
- var prettyPrintJson = JsonSerializer.Serialize(doc, new JsonSerializerOptions()
- {
- WriteIndented = true
- });
- return prettyPrintJson;
- }
- catch (Exception)
- {
- return data ?? string.Empty;
- }
- }
-}
+ ///
+ public static string DetermineToolVersion()
+ {
+ const string unknownVersion = "Unknown";
+
+ AssemblyInformationalVersionAttribute? attribute = null;
+ try
+ {
+ var assembly = Assembly.GetEntryAssembly();
+ if (assembly == null)
+ return unknownVersion;
+ attribute = assembly.GetCustomAttribute();
+ }
+ catch (Exception)
+ {
+ // ignored
+ }
+
+ var version = attribute?.InformationalVersion;
+
+ // Check to see if the version has a git commit id suffix and if so remove it.
+ if (version != null && version.IndexOf('+') != -1)
+ {
+ version = version.Substring(0, version.IndexOf('+'));
+ }
+
+ return version ?? unknownVersion;
+ }
+
+ ///
+ /// Attempt to pretty print the input string. If pretty print fails return back the input string in its original form.
+ ///
+ ///
+ ///
+ public static string TryPrettyPrintJson(string? data)
+ {
+ try
+ {
+ if (string.IsNullOrEmpty(data))
+ return string.Empty;
+
+ var doc = JsonDocument.Parse(data);
+ var prettyPrintJson = JsonSerializer.Serialize(doc, new JsonSerializerOptions()
+ {
+ WriteIndented = true
+ });
+ return prettyPrintJson;
+ }
+ catch (Exception)
+ {
+ return data ?? string.Empty;
+ }
+ }
+}
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..9c8103fbd
--- /dev/null
+++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Extensions/HttpContextExtensionsTests.cs
@@ -0,0 +1,502 @@
+namespace Amazon.Lambda.TestTool.UnitTests.Extensions;
+
+using Amazon.Lambda.TestTool.Extensions;
+using Amazon.Lambda.TestTool.Models;
+using Microsoft.AspNetCore.Http;
+using System.Web;
+using Xunit;
+
+public class HttpContextExtensionsTests
+{
+ [Fact]
+ public void ToApiGatewayHttpV2Request_ShouldReturnValidApiGatewayHttpApiV2ProxyRequest()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Scheme = "https";
+ request.Host = new HostString("api.example.com");
+ request.Path = "/api/users/123/orders";
+ request.QueryString = new QueryString("?status=pending&tag=important&tag=urgent");
+ request.Headers["User-Agent"] = "TestAgent";
+ request.Headers["Accept"] = new Microsoft.Extensions.Primitives.StringValues(new[] { "text/html", "application/json" });
+ request.Headers["Cookie"] = "session=abc123; theme=dark";
+ request.Headers["X-Custom-Header"] = "value1";
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "TestLambdaFunction",
+ Endpoint = "GET /api/users/{userId}/orders",
+ HttpMethod = "GET",
+ Path = "/api/users/{userId}/orders"
+ };
+
+ var result = context.ToApiGatewayHttpV2Request(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("2.0", result.Version);
+ Assert.Equal("GET /api/users/{userId}/orders", result.RouteKey);
+ Assert.Equal("/api/users/123/orders", result.RawPath);
+ Assert.Equal("status=pending&tag=important&tag=urgent", result.RawQueryString);
+ Assert.Equal(2, result.Cookies.Length);
+ Assert.Contains("session=abc123", result.Cookies);
+ Assert.Contains("theme=dark", result.Cookies);
+ Assert.Equal("123", result.PathParameters["userId"]);
+ Assert.Equal("GET", result.RequestContext.Http.Method);
+ Assert.Equal("/api/users/123/orders", result.RequestContext.Http.Path);
+ Assert.Equal("GET /api/users/{userId}/orders", result.RequestContext.RouteKey);
+
+ Assert.Equal("TestAgent", result.Headers["User-Agent"]);
+ Assert.Equal("text/html,application/json", result.Headers["Accept"]);
+ Assert.Equal("session=abc123; theme=dark", result.Headers["Cookie"]);
+ Assert.Equal("value1", result.Headers["X-Custom-Header"]);
+
+ Assert.Equal("pending", result.QueryStringParameters["status"]);
+ Assert.Equal("important,urgent", result.QueryStringParameters["tag"]);
+ }
+
+ [Fact]
+ public void ToApiGatewayHttpV2Request_WithEmptyCollections_ShouldNotSetParameters()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/notmatchingpath/123/orders";
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "TestLambdaFunction",
+ Endpoint = "GET /api/users/{userId}/orders",
+ HttpMethod = "GET",
+ Path = "/api/users/{userId}/orders"
+ };
+
+ var result = context.ToApiGatewayHttpV2Request(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Null(result.Headers);
+ Assert.Null(result.QueryStringParameters);
+ Assert.Null(result.PathParameters);
+ Assert.Null(result.Cookies);
+ }
+
+ [Fact]
+ public void ToApiGatewayHttpV2Request_WithBinaryContent_ShouldBase64EncodeBody()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "POST";
+ request.Path = "/api/users/123/avatar";
+ request.ContentType = "application/octet-stream";
+ var bodyContent = new byte[] { 1, 2, 3, 4, 5 };
+ request.Body = new MemoryStream(bodyContent);
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "UploadAvatarFunction",
+ Endpoint = "POST /api/users/{userId}/avatar",
+ HttpMethod = "POST",
+ Path = "/api/users/{userId}/avatar"
+ };
+
+ var result = context.ToApiGatewayHttpV2Request(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.True(result.IsBase64Encoded);
+ Assert.Equal(Convert.ToBase64String(bodyContent), result.Body);
+ Assert.Equal("123", result.PathParameters["userId"]);
+ Assert.Equal("POST /api/users/{userId}/avatar", result.RouteKey);
+ }
+
+ [Fact]
+ public void ToApiGatewayRequest_WithBinaryContent_ShouldBase64EncodeBody()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "POST";
+ request.Path = "/api/users/123/avatar";
+ request.ContentType = "application/octet-stream";
+ var bodyContent = new byte[] { 1, 2, 3, 4, 5 };
+ request.Body = new MemoryStream(bodyContent);
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "UploadAvatarFunction",
+ Endpoint = "POST /api/users/{userId}/avatar",
+ HttpMethod = "POST",
+ Path = "/api/users/{userId}/avatar"
+ };
+
+ var result = context.ToApiGatewayRequest(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.True(result.IsBase64Encoded);
+ Assert.Equal(Convert.ToBase64String(bodyContent), result.Body);
+ Assert.Equal("123", result.PathParameters["userId"]);
+ Assert.Equal("/api/users/{userId}/avatar", result.Resource);
+ Assert.Equal("POST", result.HttpMethod);
+ Assert.Equal(HttpUtility.UrlDecode("/api/users/123/avatar"), result.Path);
+ }
+
+ [Fact]
+ public void ToApiGatewayRequest_ShouldReturnValidApiGatewayProxyRequest()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Scheme = "https";
+ request.Host = new HostString("api.example.com");
+ request.Path = "/api/users/123/orders";
+ request.QueryString = new QueryString("?status=pending&tag=important&tag=urgent");
+ request.Headers["User-Agent"] = "TestAgent";
+ request.Headers["Accept"] = new Microsoft.Extensions.Primitives.StringValues(new[] { "text/html", "application/json" });
+ request.Headers["Cookie"] = "session=abc123; theme=dark";
+ request.Headers["X-Custom-Header"] = "value1";
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "TestLambdaFunction",
+ Endpoint = "GET /api/users/{userId}/orders",
+ HttpMethod = "GET",
+ Path = "/api/users/{userId}/orders"
+ };
+
+ var result = context.ToApiGatewayRequest(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("/api/users/{userId}/orders", result.Resource);
+ Assert.Equal(HttpUtility.UrlDecode("/api/users/123/orders"), result.Path);
+ Assert.Equal("GET", result.HttpMethod);
+
+ Assert.Equal("TestAgent", result.Headers["User-Agent"]);
+ Assert.Equal("application/json", result.Headers["Accept"]);
+ Assert.Equal("session=abc123; theme=dark", result.Headers["Cookie"]);
+ Assert.Equal("value1", result.Headers["X-Custom-Header"]);
+
+ Assert.Equal(new List { "TestAgent" }, result.MultiValueHeaders["User-Agent"]);
+ Assert.Equal(new List { "text/html", "application/json" }, result.MultiValueHeaders["Accept"]);
+ Assert.Equal(new List { "session=abc123; theme=dark" }, result.MultiValueHeaders["Cookie"]);
+ Assert.Equal(new List { "value1" }, result.MultiValueHeaders["X-Custom-Header"]);
+
+ Assert.Equal("pending", result.QueryStringParameters["status"]);
+ Assert.Equal("urgent", result.QueryStringParameters["tag"]);
+
+ Assert.Equal(new List { "pending" }, result.MultiValueQueryStringParameters["status"]);
+ Assert.Equal(new List { "important", "urgent" }, result.MultiValueQueryStringParameters["tag"]);
+
+ Assert.Equal("123", result.PathParameters["userId"]);
+ Assert.Equal(string.Empty, result.Body);
+ Assert.False(result.IsBase64Encoded);
+ }
+
+ [Fact]
+ public void ToApiGatewayRequest_WithEmptyCollections_ShouldNotSetParameters()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/notmatchingpath/123/orders";
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "TestLambdaFunction",
+ Endpoint = "GET /api/users/{userId}/orders",
+ HttpMethod = "GET",
+ Path = "/api/users/{userId}/orders"
+ };
+
+ var result = context.ToApiGatewayRequest(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Null(result.Headers);
+ Assert.Null(result.MultiValueHeaders);
+ Assert.Null(result.QueryStringParameters);
+ Assert.Null(result.MultiValueQueryStringParameters);
+ Assert.Null(result.PathParameters);
+ }
+
+ [Fact]
+ public void ToApiGatewayHttpV2Request_ShouldEncodeRawQueryString()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/search";
+ request.QueryString = new QueryString("?q=Hello%20World&tag=C%23%20Programming");
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "SearchFunction",
+ Endpoint = "GET /api/search",
+ HttpMethod = "GET",
+ Path = "/api/search"
+ };
+
+ var result = context.ToApiGatewayHttpV2Request(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("q=Hello%20World&tag=C%23%20Programming", result.RawQueryString);
+ }
+
+ [Fact]
+ public void ToApiGatewayHttpV2Request_ShouldDecodeQueryStringParameters()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/search";
+ request.QueryString = new QueryString("?q=Hello%20World&tag=C%23%20Programming&tag=.NET%20Core");
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "SearchFunction",
+ Endpoint = "GET /api/search",
+ HttpMethod = "GET",
+ Path = "/api/search"
+ };
+
+ var result = context.ToApiGatewayHttpV2Request(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("Hello World", result.QueryStringParameters["q"]);
+ Assert.Equal("C# Programming,.NET Core", result.QueryStringParameters["tag"]);
+ }
+
+ [Fact]
+ public void ToApiGatewayRequest_ShouldDecodeQueryStringParameters()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/search";
+ request.QueryString = new QueryString("?q=Hello%20World&tag=C%23%20Programming&tag=.NET%20Core");
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "SearchFunction",
+ Endpoint = "GET /api/search",
+ HttpMethod = "GET",
+ Path = "/api/search"
+ };
+
+ var result = context.ToApiGatewayRequest(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("Hello World", result.QueryStringParameters["q"]);
+ Assert.Equal(".NET Core", result.QueryStringParameters["tag"]);
+ Assert.Equal(new List { "Hello World" }, result.MultiValueQueryStringParameters["q"]);
+ Assert.Equal(new List { "C# Programming", ".NET Core" }, result.MultiValueQueryStringParameters["tag"]);
+ }
+
+ [Fact]
+ public void ToApiGatewayHttpV2Request_ShouldDecodePathWithSpecialCharacters()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/users/John%20Doe/orders/Summer%20Sale%202023";
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "UserOrdersFunction",
+ Endpoint = "GET /api/users/{username}/orders/{orderName}",
+ HttpMethod = "GET",
+ Path = "/api/users/{username}/orders/{orderName}"
+ };
+
+ var result = context.ToApiGatewayHttpV2Request(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("/api/users/John Doe/orders/Summer Sale 2023", result.RawPath);
+ Assert.Equal("/api/users/John Doe/orders/Summer Sale 2023", result.RequestContext.Http.Path);
+ Assert.Equal("John Doe", result.PathParameters["username"]);
+ Assert.Equal("Summer Sale 2023", result.PathParameters["orderName"]);
+ }
+
+ [Fact]
+ public void ToApiGatewayHttpV2Request_ShouldDecodePathWithUnicodeCharacters()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/products/%E2%98%95%20Coffee/reviews/%F0%9F%98%8A%20Happy";
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "ProductReviewsFunction",
+ Endpoint = "GET /api/products/{productName}/reviews/{reviewTitle}",
+ HttpMethod = "GET",
+ Path = "/api/products/{productName}/reviews/{reviewTitle}"
+ };
+
+ var result = context.ToApiGatewayHttpV2Request(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("/api/products/☕ Coffee/reviews/😊 Happy", result.RawPath);
+ Assert.Equal("/api/products/☕ Coffee/reviews/😊 Happy", result.RequestContext.Http.Path);
+ Assert.Equal("☕ Coffee", result.PathParameters["productName"]);
+ Assert.Equal("😊 Happy", result.PathParameters["reviewTitle"]);
+ }
+
+ [Fact]
+ public void ToApiGatewayRequest_ShouldDecodePathWithSpecialCharacters()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/users/John%20Doe/orders/Summer%20Sale%202023";
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "UserOrdersFunction",
+ Endpoint = "GET /api/users/{username}/orders/{orderName}",
+ HttpMethod = "GET",
+ Path = "/api/users/{username}/orders/{orderName}"
+ };
+
+ var result = context.ToApiGatewayRequest(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("/api/users/John Doe/orders/Summer Sale 2023", result.Path);
+ Assert.Equal("John Doe", result.PathParameters["username"]);
+ Assert.Equal("Summer Sale 2023", result.PathParameters["orderName"]);
+ }
+
+ [Fact]
+ public void ToApiGatewayRequest_ShouldDecodePathWithUnicodeCharacters()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/products/%E2%98%95%20Coffee/reviews/%F0%9F%98%8A%20Happy";
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "ProductReviewsFunction",
+ Endpoint = "GET /api/products/{productName}/reviews/{reviewTitle}",
+ HttpMethod = "GET",
+ Path = "/api/products/{productName}/reviews/{reviewTitle}"
+ };
+
+ var result = context.ToApiGatewayRequest(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("/api/products/☕ Coffee/reviews/😊 Happy", result.Path);
+ Assert.Equal("☕ Coffee", result.PathParameters["productName"]);
+ Assert.Equal("😊 Happy", result.PathParameters["reviewTitle"]);
+ }
+
+ [Fact]
+ public void ToApiGatewayHttpV2Request_ShouldNotDecodeUrlEncodedAndUnicodeHeaderValues()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/test";
+ request.Headers["X-Encoded-Header"] = "value%20with%20spaces";
+ request.Headers["X-Unicode-Header"] = "☕ Coffee";
+ request.Headers["X-Mixed-Header"] = "Hello%2C%20World%21%20☕";
+ request.Headers["X-Raw-Unicode"] = "\u2615 Coffee";
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "TestFunction",
+ Endpoint = "GET /api/test",
+ HttpMethod = "GET",
+ Path = "/api/test"
+ };
+
+ var result = context.ToApiGatewayHttpV2Request(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ 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"]);
+ }
+
+ [Fact]
+ public void ToApiGatewayRequest_ShouldNotDecodeUrlEncodedAndUnicodeHeaderValues()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/test";
+ request.Headers["X-Encoded-Header"] = "value%20with%20spaces";
+ request.Headers["X-Unicode-Header"] = "☕ Coffee";
+ request.Headers["X-Mixed-Header"] = "Hello%2C%20World%21%20☕";
+ request.Headers["X-Raw-Unicode"] = "\u2615 Coffee";
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "TestFunction",
+ Endpoint = "GET /api/test",
+ HttpMethod = "GET",
+ Path = "/api/test"
+ };
+
+ var result = context.ToApiGatewayRequest(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ 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"]);
+ }
+
+ [Fact]
+ public void ToApiGatewayHttpV2Request_ShouldHandleMultipleHeaderValuesWithUnicode()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/test";
+ request.Headers["X-Multi-Value"] = new string[] { "value1", "value2%20with%20spaces", "☕ Coffee", "value4%20☕" };
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "TestFunction",
+ Endpoint = "GET /api/test",
+ HttpMethod = "GET",
+ Path = "/api/test"
+ };
+
+ var result = context.ToApiGatewayHttpV2Request(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("value1,value2%20with%20spaces,☕ Coffee,value4%20☕", result.Headers["X-Multi-Value"]);
+ }
+
+ [Fact]
+ public void ToApiGatewayRequest_ShouldHandleMultipleHeaderValuesWithUnicode()
+ {
+ var context = new DefaultHttpContext();
+ var request = context.Request;
+ request.Method = "GET";
+ request.Path = "/api/test";
+ request.Headers["X-Multi-Value"] = new string[] { "value1", "value2%20with%20spaces", "☕ Coffee", "value4%20☕" };
+
+ var apiGatewayRouteConfig = new ApiGatewayRouteConfig
+ {
+ LambdaResourceName = "TestFunction",
+ Endpoint = "GET /api/test",
+ HttpMethod = "GET",
+ Path = "/api/test"
+ };
+
+ var result = context.ToApiGatewayRequest(apiGatewayRouteConfig);
+
+ Assert.NotNull(result);
+ Assert.Equal("value4%20☕", result.Headers["X-Multi-Value"]); // v1 API uses the last value for single-value headers
+ Assert.Equal(
+ new List { "value1", "value2%20with%20spaces", "☕ Coffee", "value4%20☕" },
+ result.MultiValueHeaders["X-Multi-Value"]
+ );
+ }
+
+}
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..01168c642
--- /dev/null
+++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/HttpRequestUtilityTests.cs
@@ -0,0 +1,84 @@
+namespace Amazon.Lambda.TestTool.UnitTests.Utilities;
+
+using Amazon.Lambda.TestTool.Utilities;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Primitives;
+using Moq;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+public class HttpRequestUtilityTests
+{
+ [Theory]
+ [InlineData("image/jpeg", true)]
+ [InlineData("audio/mpeg", true)]
+ [InlineData("video/mp4", true)]
+ [InlineData("application/octet-stream", true)]
+ [InlineData("application/zip", true)]
+ [InlineData("application/pdf", true)]
+ [InlineData("application/x-protobuf", true)]
+ [InlineData("application/wasm", true)]
+ [InlineData("text/plain", false)]
+ [InlineData("application/json", false)]
+ [InlineData(null, false)]
+ [InlineData("", false)]
+ public void IsBinaryContent_ReturnsExpectedResult(string contentType, bool expected)
+ {
+ var result = HttpRequestUtility.IsBinaryContent(contentType);
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void ReadRequestBody_ReturnsCorrectContent()
+ {
+ var content = "Test body content";
+ var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
+ var request = new Mock();
+ request.Setup(r => r.Body).Returns(stream);
+
+ var result = HttpRequestUtility.ReadRequestBody(request.Object);
+
+ Assert.Equal(content, result);
+ }
+
+ [Fact]
+ public void ExtractHeaders_ReturnsCorrectDictionaries()
+ {
+ var headers = new HeaderDictionary
+ {
+ { "Single", new StringValues("Value") },
+ { "Multi", new StringValues(new[] { "Value1", "Value2" }) }
+ };
+
+ var (singleValueHeaders, multiValueHeaders) = HttpRequestUtility.ExtractHeaders(headers);
+
+ Assert.Equal(2, singleValueHeaders.Count);
+ Assert.Equal(2, multiValueHeaders.Count);
+ Assert.Equal("Value", singleValueHeaders["Single"]);
+ Assert.Equal("Value2", singleValueHeaders["Multi"]);
+ Assert.Equal(new List { "Value" }, multiValueHeaders["Single"]);
+ Assert.Equal(new List { "Value1", "Value2" }, multiValueHeaders["Multi"]);
+ }
+
+ [Fact]
+ public void ExtractQueryStringParameters_ReturnsCorrectDictionaries()
+ {
+ var query = new QueryCollection(new Dictionary
+ {
+ { "Single", new StringValues("Value") },
+ { "Multi", new StringValues(new[] { "Value1", "Value2" }) }
+ });
+
+ var (singleValueParams, multiValueParams) = HttpRequestUtility.ExtractQueryStringParameters(query);
+
+ Assert.Equal(2, singleValueParams.Count);
+ Assert.Equal(2, multiValueParams.Count);
+ Assert.Equal("Value", singleValueParams["Single"]);
+ Assert.Equal("Value2", singleValueParams["Multi"]);
+ Assert.Equal(new List { "Value" }, multiValueParams["Single"]);
+ Assert.Equal(new List { "Value1", "Value2" }, multiValueParams["Multi"]);
+ }
+}
diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs
new file mode 100644
index 000000000..137afad58
--- /dev/null
+++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Utilities/RouteTemplateUtilityTests.cs
@@ -0,0 +1,89 @@
+namespace Amazon.Lambda.TestTool.UnitTests.Utilities;
+
+using Amazon.Lambda.TestTool.Utilities;
+using Microsoft.AspNetCore.Routing.Template;
+using Xunit;
+
+public class RouteTemplateUtilityTests
+{
+ [Theory]
+ [InlineData("/users/{id}", "/users/123", "id", "123")]
+ [InlineData("/users/{id}/orders/{orderId}", "/users/123/orders/456", "id", "123", "orderId", "456")]
+ [InlineData("/products/{category}/{id}", "/products/electronics/laptop-123", "category", "electronics", "id", "laptop-123")]
+ [InlineData("/api/{version}/users/{userId}", "/api/v1/users/abc-xyz", "version", "v1", "userId", "abc-xyz")]
+ public void ExtractPathParameters_ShouldExtractCorrectly(string routeTemplate, string actualPath, params string[] expectedKeyValuePairs)
+ {
+ // Arrange
+ var expected = new Dictionary();
+ for (int i = 0; i < expectedKeyValuePairs.Length; i += 2)
+ {
+ expected[expectedKeyValuePairs[i]] = expectedKeyValuePairs[i + 1];
+ }
+
+ // Act
+ var result = RouteTemplateUtility.ExtractPathParameters(routeTemplate, actualPath);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("/users/{id}", "/products/123")]
+ [InlineData("/api/{version}/users", "/api/v1/products")]
+ [InlineData("/products/{category}/{id}", "/products/electronics")]
+ public void ExtractPathParameters_ShouldReturnEmptyDictionary_WhenNoMatch(string routeTemplate, string actualPath)
+ {
+ // Act
+ var result = RouteTemplateUtility.ExtractPathParameters(routeTemplate, actualPath);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Theory]
+ [InlineData("/users/{id:int}", "/users/123", "id", "123")]
+ [InlineData("/users/{id:guid}", "/users/550e8400-e29b-41d4-a716-446655440000", "id", "550e8400-e29b-41d4-a716-446655440000")]
+ [InlineData("/api/{version:regex(^v[0-9]+$)}/users/{userId}", "/api/v1/users/abc-xyz", "version", "v1", "userId", "abc-xyz")]
+ public void ExtractPathParameters_ShouldHandleConstraints(string routeTemplate, string actualPath, params string[] expectedKeyValuePairs)
+ {
+ // Arrange
+ var expected = new Dictionary();
+ for (int i = 0; i < expectedKeyValuePairs.Length; i += 2)
+ {
+ expected[expectedKeyValuePairs[i]] = expectedKeyValuePairs[i + 1];
+ }
+
+ // Act
+ var result = RouteTemplateUtility.ExtractPathParameters(routeTemplate, actualPath);
+
+ // Assert
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void GetDefaults_ShouldReturnCorrectDefaults()
+ {
+ // Arrange
+ var template = TemplateParser.Parse("/api/{version=v1}/users/{id}");
+
+ // Act
+ var result = RouteTemplateUtility.GetDefaults(template);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("v1", result["version"]);
+ }
+
+ [Fact]
+ public void GetDefaults_ShouldReturnEmptyDictionary_WhenNoDefaults()
+ {
+ // Arrange
+ var template = TemplateParser.Parse("/api/{version}/users/{id}");
+
+ // Act
+ var result = RouteTemplateUtility.GetDefaults(template);
+
+ // Assert
+ Assert.Empty(result);
+ }
+}