Skip to content

Commit

Permalink
Add ApiGatewayHttpApiV2ProxyRequestTranslator and ApiGatewayProxyRequ…
Browse files Browse the repository at this point in the history
…estTranslator (#1901)
  • Loading branch information
gcbeattyAWS authored Dec 23, 2024
1 parent edd2698 commit 68ff1db
Show file tree
Hide file tree
Showing 14 changed files with 2,207 additions and 189 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -102,49 +103,18 @@ private static Dictionary<string, string> 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;
}

/// <summary>
/// Generates a random X-Amzn-Trace-Id for REST API mode.
/// </summary>
/// <returns>A string representing a random X-Amzn-Trace-Id in the format used by API Gateway for REST APIs.</returns>
/// <remarks>
/// 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
/// </remarks>
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";
}

/// <summary>
/// Generates a random API Gateway request ID for HTTP API v1 and v2.
/// </summary>
/// <returns>A string representing a random request ID in the format used by API Gateway for HTTP APIs.</returns>
/// <remarks>
/// The generated ID is a 15-character string consisting of lowercase letters and numbers, followed by an equals sign.
/// </remarks>
private static string GenerateRequestId()
{
return Guid.NewGuid().ToString("N").Substring(0, 8) + Guid.NewGuid().ToString("N").Substring(0, 7) + "=";
}

/// <summary>
/// Sets the response body on the <see cref="HttpResponse"/>.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides extension methods to translate an <see cref="HttpContext"/> to different types of API Gateway requests.
/// </summary>
public static class HttpContextExtensions
{
/// <summary>
/// Translates an <see cref="HttpContext"/> to an <see cref="APIGatewayHttpApiV2ProxyRequest"/>.
/// </summary>
/// <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 async Task<APIGatewayHttpApiV2ProxyRequest> 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;
}

/// <summary>
/// Translates an <see cref="HttpContext"/> to an <see cref="APIGatewayProxyRequest"/>.
/// </summary>
/// <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 async Task<APIGatewayProxyRequest> 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;
}
}
Loading

0 comments on commit 68ff1db

Please sign in to comment.