Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ApiGatewayHttpApiV2ProxyRequestTranslator and ApiGatewayProxyRequestTranslator #1901

Merged
merged 3 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
gcbeattyAWS marked this conversation as resolved.
Show resolved Hide resolved

// 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: small inconsistency between headers and query params. Here you are joining on ", " and below on ","

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is intentional. from my testing the headers to have a space in them but query string params do not

);

// 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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we always update the content-length since we are updating the body?

Copy link
Author

@gcbeattyAWS gcbeattyAWS Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so i checked this an apparently rest does not set this by default.

Edit: that was for the v1 actually.

i can still update this to always set it. let me double check

Copy link
Author

@gcbeattyAWS gcbeattyAWS Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i checked on this. it needs to be the original content-length which is why i dont need to re-set it. my integration test fails (the real api gateway expects the original content length)

}

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we make the "HTTP/1.1" assumption? This is more likely to get updated and we wouldn't know to update it

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ive put this here so it matches api gateway

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();
gcbeattyAWS marked this conversation as resolved.
Show resolved Hide resolved
}

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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for example. escapedatastring encodes * as %20 which is different than how api gateway rest api does it (it keeps * as *)

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
Loading