Skip to content

Commit

Permalink
PR comments
Browse files Browse the repository at this point in the history
  • Loading branch information
gcbeattyAWS committed Dec 20, 2024
1 parent 9d38917 commit d96af57
Show file tree
Hide file tree
Showing 10 changed files with 530 additions and 374 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace Amazon.Lambda.TestTool.Extensions;

using System.Text;
using System.Web;
using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.TestTool.Models;
Expand All @@ -20,19 +21,20 @@ public static class HttpContextExtensions
/// <param name="context">The <see cref="HttpContext"/> to be translated.</param>
/// <param name="apiGatewayRouteConfig">The configuration of the API Gateway route, including the HTTP method, path, and other metadata.</param>
/// <returns>An <see cref="APIGatewayHttpApiV2ProxyRequest"/> object representing the translated request.</returns>
public static APIGatewayHttpApiV2ProxyRequest ToApiGatewayHttpV2Request(
public static async Task<APIGatewayHttpApiV2ProxyRequest> ToApiGatewayHttpV2Request(
this HttpContext context,
ApiGatewayRouteConfig apiGatewayRouteConfig)
{
var request = context.Request;
var currentTime = DateTimeOffset.UtcNow;
var body = HttpRequestUtility.ReadRequestBody(request);
var body = await HttpRequestUtility.ReadRequestBody(request);
var contentLength = HttpRequestUtility.CalculateContentLength(request, body);

var pathParameters = RouteTemplateUtility.ExtractPathParameters(apiGatewayRouteConfig.Path, request.Path);

// Format 2.0 doesn't have multiValueHeaders or multiValueQueryStringParameters fields. Duplicate headers are combined with commas and included in the headers field.
var (_, allHeaders) = HttpRequestUtility.ExtractHeaders(request.Headers);
// 2.0 also lowercases all header keys
var (_, allHeaders) = HttpRequestUtility.ExtractHeaders(request.Headers, true);
var headers = allHeaders.ToDictionary(
kvp => kvp.Key,
kvp => string.Join(", ", kvp.Value)
Expand Down Expand Up @@ -91,6 +93,7 @@ public static APIGatewayHttpApiV2ProxyRequest ToApiGatewayHttpV2Request(
}

httpApiV2ProxyRequest.RawQueryString = string.Empty; // default is empty string

if (queryStringParameters.Any())
{
// this should be decoded value
Expand Down Expand Up @@ -123,35 +126,52 @@ public static APIGatewayHttpApiV2ProxyRequest ToApiGatewayHttpV2Request(
/// <param name="context">The <see cref="HttpContext"/> to be translated.</param>
/// <param name="apiGatewayRouteConfig">The configuration of the API Gateway route, including the HTTP method, path, and other metadata.</param>
/// <returns>An <see cref="APIGatewayProxyRequest"/> object representing the translated request.</returns>
public static APIGatewayProxyRequest ToApiGatewayRequest(
public static async Task<APIGatewayProxyRequest> ToApiGatewayRequest(
this HttpContext context,
ApiGatewayRouteConfig apiGatewayRouteConfig)
ApiGatewayRouteConfig apiGatewayRouteConfig,
ApiGatewayEmulatorMode emulatorMode)
{
var request = context.Request;
var body = HttpRequestUtility.ReadRequestBody(request);
var body = await HttpRequestUtility.ReadRequestBody(request);
var contentLength = HttpRequestUtility.CalculateContentLength(request, body);

var pathParameters = RouteTemplateUtility.ExtractPathParameters(apiGatewayRouteConfig.Path, request.Path);

var (headers, multiValueHeaders) = HttpRequestUtility.ExtractHeaders(request.Headers);
var (queryStringParameters, multiValueQueryStringParameters) = HttpRequestUtility.ExtractQueryStringParameters(request.Query);

if (!headers.ContainsKey("content-length"))
if (!headers.ContainsKey("content-length") && emulatorMode != ApiGatewayEmulatorMode.Rest) // rest doesnt set content-length by default
{
headers["content-length"] = contentLength.ToString();
multiValueHeaders["content-length"] = new List<string> { contentLength.ToString() };
multiValueHeaders["content-length"] = [contentLength.ToString()];
}

if (!headers.ContainsKey("content-type"))
{
headers["content-type"] = "text/plain; charset=utf-8";
multiValueHeaders["content-type"] = new List<string> { "text/plain; charset=utf-8" };
multiValueHeaders["content-type"] = ["text/plain; charset=utf-8"];
}

// This is the decoded value
var path = request.Path.Value;

if (emulatorMode == ApiGatewayEmulatorMode.HttpV1 || emulatorMode == ApiGatewayEmulatorMode.Rest) // rest and httpv1 uses the encoded value for path an
{
path = request.Path.ToUriComponent();
}

if (emulatorMode == ApiGatewayEmulatorMode.Rest) // rest uses encoded value for the path params
{
var encodedPathParameters = pathParameters.ToDictionary(
kvp => kvp.Key,
kvp => Uri.EscapeUriString(kvp.Value)); // intentionally using EscapeURiString over EscapeDataString since EscapeURiString correctly handles reserved characters :/?#[]@!$&'()*+,;= in this case
pathParameters = encodedPathParameters;
}

var proxyRequest = new APIGatewayProxyRequest
{
Resource = apiGatewayRouteConfig.Path,
Path = request.Path.Value,
Path = path,
HttpMethod = request.Method,
Body = body,
IsBase64Encoded = false
Expand Down Expand Up @@ -181,13 +201,11 @@ public static APIGatewayProxyRequest ToApiGatewayRequest(

if (pathParameters.Any())
{
// this should be decoded value
proxyRequest.PathParameters = pathParameters;
}

if (HttpRequestUtility.IsBinaryContent(request.ContentType))
{
// we already converted it when we read the body so we dont need to re-convert it
proxyRequest.IsBase64Encoded = true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System.Text;
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

using System.Text;

namespace Amazon.Lambda.TestTool.Utilities;

Expand Down Expand Up @@ -32,7 +35,7 @@ public static bool IsBinaryContent(string? contentType)
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <returns>The body of the request as a string, or null if the body is empty.</returns>
public static string? ReadRequestBody(HttpRequest request)
public static async Task<string?> ReadRequestBody(HttpRequest request)
{
if (request.ContentLength == 0 || request.Body == null || !request.Body.CanRead)
{
Expand All @@ -46,7 +49,7 @@ public static bool IsBinaryContent(string? contentType)

using (var memoryStream = new MemoryStream())
{
request.Body.CopyTo(memoryStream);
await request.Body.CopyToAsync(memoryStream);

// If the stream is empty, return null
if (memoryStream.Length == 0)
Expand All @@ -67,7 +70,7 @@ public static bool IsBinaryContent(string? contentType)
// For text data, read as string
using (var reader = new StreamReader(memoryStream))
{
string content = reader.ReadToEnd();
string content = await reader.ReadToEndAsync();
return string.IsNullOrWhiteSpace(content) ? null : content;
}
}
Expand All @@ -80,6 +83,7 @@ public static bool IsBinaryContent(string? contentType)
/// Extracts headers from the request, separating them into single-value and multi-value dictionaries.
/// </summary>
/// <param name="headers">The request headers.</param>
/// <param name="lowerCaseKeyName">Whether to lowercase the key name or not.</param>
/// <returns>A tuple containing single-value and multi-value header dictionaries.</returns>
/// <example>
/// For headers:
Expand All @@ -91,15 +95,16 @@ public static bool IsBinaryContent(string? contentType)
/// singleValueHeaders: { "Accept": "application/xhtml+xml", "X-Custom-Header": "value1" }
/// multiValueHeaders: { "Accept": ["text/html", "application/xhtml+xml"], "X-Custom-Header": ["value1"] }
/// </example>
public static (IDictionary<string, string>, IDictionary<string, IList<string>>) ExtractHeaders(IHeaderDictionary headers)
public static (IDictionary<string, string>, IDictionary<string, IList<string>>) ExtractHeaders(IHeaderDictionary headers, bool lowerCaseKeyName = false)
{
var singleValueHeaders = new Dictionary<string, string>();
var multiValueHeaders = new Dictionary<string, IList<string>>();
var singleValueHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var multiValueHeaders = new Dictionary<string, IList<string>>(StringComparer.OrdinalIgnoreCase);

foreach (var header in headers)
{
singleValueHeaders[header.Key.ToLower()] = header.Value.Last() ?? "";
multiValueHeaders[header.Key.ToLower()] = [.. header.Value];
var key = lowerCaseKeyName ? header.Key.ToLower() : header.Key;
singleValueHeaders[key] = header.Value.Last() ?? "";
multiValueHeaders[key] = [.. header.Value];
}

return (singleValueHeaders, multiValueHeaders);
Expand Down Expand Up @@ -139,7 +144,7 @@ public static (IDictionary<string, string>, IDictionary<string, IList<string>>)
/// The generated ID is a 145character string consisting of lowercase letters and numbers, followed by an equals sign.
public static string GenerateRequestId()
{
return Guid.NewGuid().ToString("N").Substring(0, 8) + Guid.NewGuid().ToString("N").Substring(0, 7) + "=";
return $"{Guid.NewGuid().ToString("N").Substring(0, 8)}{Guid.NewGuid().ToString("N").Substring(0, 7)}=";
}

/// <summary>
Expand All @@ -161,7 +166,7 @@ public static string GenerateTraceId()
return $"Root=1-{timestamp}-{guid1.Substring(0, 12)}{guid2.Substring(0, 12)};Parent={Guid.NewGuid().ToString("N").Substring(0, 16)};Sampled=0;Lineage=1:{Guid.NewGuid().ToString("N").Substring(0, 8)}:0";
}

public static long CalculateContentLength(HttpRequest request, string body)
public static long CalculateContentLength(HttpRequest request, string? body)
{
if (!string.IsNullOrEmpty(body))
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
namespace Amazon.Lambda.TestTool.Utilities;
namespace Amazon.Lambda.TestTool.Utilities;

using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Routing.Template;

/// <summary>
/// Provides utility methods for working with route templates and extracting path parameters.
/// </summary>
public static class RouteTemplateUtility
{
private const string TemporaryPrefix = "__aws_param__";

/// <summary>
/// Extracts path parameters from an actual path based on a route template.
/// </summary>
Expand All @@ -24,43 +27,88 @@ public static class RouteTemplateUtility
/// </example>
public static Dictionary<string, string> ExtractPathParameters(string routeTemplate, string actualPath)
{
// Preprocess the route template to convert from .net style format to aws
routeTemplate = PreprocessRouteTemplate(routeTemplate);

var template = TemplateParser.Parse(routeTemplate);
var matcher = new TemplateMatcher(template, GetDefaults(template));
var matcher = new TemplateMatcher(template, new RouteValueDictionary());
var routeValues = new RouteValueDictionary();

if (matcher.TryMatch(actualPath, routeValues))
{
return routeValues.ToDictionary(rv => rv.Key, rv => rv.Value?.ToString() ?? string.Empty);
var result = new Dictionary<string, string>();

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<string, string>();
}

/// <summary>
/// Gets the default values for parameters in a parsed route template.
/// Preprocesses a route template to make it compatible with ASP.NET Core's TemplateMatcher.
/// </summary>
/// <param name="parsedTemplate">The parsed route template.</param>
/// <returns>A dictionary of default values for the template parameters.</returns>
/// <example>
/// Using this method:
/// <code>
/// var template = TemplateParser.Parse("/api/{version=v1}/users/{id}");
/// var defaults = RouteTemplateUtility.GetDefaults(template);
/// // defaults will contain: { {"version", "v1"} }
/// </code>
/// </example>
public static RouteValueDictionary GetDefaults(RouteTemplate parsedTemplate)
/// <param name="template">The original route template, potentially in AWS API Gateway format.</param>
/// <returns>A preprocessed route template compatible with ASP.NET Core's TemplateMatcher.</returns>
/// <remarks>
/// 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})
/// </remarks>
private static string PreprocessRouteTemplate(string template)
{
var result = new RouteValueDictionary();
// Convert AWS-style {proxy+} to ASP.NET Core style {*proxy}
template = Regex.Replace(template, @"\{(\w+)\+\}", "{*$1}");

// Handle AWS-style "constraints" by replacing them with temporary parameter names
return Regex.Replace(template, @"\{([^}]+):([^}]+)\}", match =>
{
var paramName = match.Groups[1].Value;
var constraint = match.Groups[2].Value;

// There is a low chance that one of the parameters being used actually follows the syntax of {TemporaryPrefix}{paramName}__{constraint}.
// But i dont think its signifigant enough to worry about.
return $"{{{TemporaryPrefix}{paramName}__{constraint}}}";
});
}

foreach (var parameter in parsedTemplate.Parameters)
/// <summary>
/// Restores the original parameter name after processing by TemplateMatcher.
/// </summary>
/// <param name="processedName">The parameter name after processing and matching.</param>
/// <returns>The original parameter name.</returns>
/// <remarks>
/// This method reverses the transformation done in PreprocessRouteTemplate.
/// For example, "__aws_param__abc__int" would be restored to "abc:int".
/// </remarks>
private static string RestoreOriginalParamName(string processedName)
{
if (processedName.StartsWith(TemporaryPrefix))
{
if (parameter.DefaultValue != null)
var parts = processedName.Substring(TemporaryPrefix.Length).Split("__", 2);
if (parts.Length == 2)
{
if (parameter.Name != null) result.Add(parameter.Name, parameter.DefaultValue);
return $"{parts[0]}:{parts[1]}";
}
}

return result;
return processedName;
}
}
Loading

0 comments on commit d96af57

Please sign in to comment.