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

Open
wants to merge 10 commits into
base: asmarp/api-gateway-emulator-skeleton
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
<PackageReference Include="Blazored.Modal" Version="7.3.1" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.11" />
<PackageReference Include="Amazon.Lambda.APIGatewayEvents" Version="2.7.1" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
using Amazon.Lambda.TestTool.Models;

gcbeattyAWS marked this conversation as resolved.
Show resolved Hide resolved
namespace Amazon.Lambda.TestTool.Extensions;

using Amazon.Lambda.TestTool.Models;
namespace Amazon.Lambda.TestTool.Extensions;
/// <summary>
/// A class that contains extension methods for the <see cref="Exception"/> class.
/// </summary>
public static class ExceptionExtensions
{
/// <summary>
/// True if the <paramref name="e"/> inherits from
/// <see cref="TestToolException"/>.
/// </summary>
public static bool IsExpectedException(this Exception e) =>
e is TestToolException;

/// </summary>
public static class ExceptionExtensions
{
/// <summary>
/// True if the <paramref name="e"/> inherits from
/// <see cref="TestToolException"/>.
/// </summary>
public static bool IsExpectedException(this Exception e) =>
e is TestToolException;
/// <summary>
/// Prints an exception in a user-friendly way.
/// </summary>
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)}";
}
/// </summary>
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)}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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;

/// <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>
/// <returns>An <see cref="APIGatewayHttpApiV2ProxyRequest"/> object representing the translated request.</returns>
public static APIGatewayHttpApiV2ProxyRequest ToApiGatewayHttpV2Request(
this HttpContext context,
ApiGatewayRouteConfig apiGatewayRouteConfig)
{
var request = context.Request;

var pathParameters = RouteTemplateUtility.ExtractPathParameters(apiGatewayRouteConfig.Path, request.Path);
gcbeattyAWS marked this conversation as resolved.
Show resolved Hide resolved

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

var httpApiV2ProxyRequest = new APIGatewayHttpApiV2ProxyRequest
{
RouteKey = $"{request.Method} {apiGatewayRouteConfig.Path}",
RawPath = request.Path,
RawQueryString = request.QueryString.Value,
Cookies = request.Cookies.Select(c => $"{c.Key}={c.Value}").ToArray(),
Headers = headers,
QueryStringParameters = queryStringParameters,
PathParameters = pathParameters ?? new Dictionary<string, string>(),
Body = HttpRequestUtility.ReadRequestBody(request),
Copy link
Member

Choose a reason for hiding this comment

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

Can you take note that when we get these ToAPIGatewayXXX() methods hooked up to the main code path that is asking for the APIGatewayHttpApiV2ProxyRequest. When we convert the APIGatewayHttpApiV2ProxyRequest to the MemoryStream to Lambda we should check the size of the stream is no more then 6MB. If so throw back the same error that users would get from API Gateway.

Copy link
Author

Choose a reason for hiding this comment

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

yeah i will connect with phil on where he wants to do this logic at

IsBase64Encoded = false,
RequestContext = new ProxyRequestContext
{
Http = new HttpDescription
{
Method = request.Method,
Path = request.Path,
Protocol = request.Protocol
},
RouteKey = $"{request.Method} {apiGatewayRouteConfig.Path}"
},
Version = "2.0"
};

if (HttpRequestUtility.IsBinaryContent(request.ContentType))
{
httpApiV2ProxyRequest.Body = Convert.ToBase64String(Encoding.UTF8.GetBytes(httpApiV2ProxyRequest.Body));
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>
/// <returns>An <see cref="APIGatewayProxyRequest"/> object representing the translated request.</returns>
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 = HttpUtility.UrlEncode(request.Path),
HttpMethod = request.Method,
Headers = headers,
MultiValueHeaders = multiValueHeaders,
QueryStringParameters = queryStringParameters,
MultiValueQueryStringParameters = multiValueQueryStringParameters,
PathParameters = pathParameters ?? new Dictionary<string, string>(),
Copy link
Member

Choose a reason for hiding this comment

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

Why are we initializing this to an empty collection if we don't have any. Is this what API Gateway does?

Copy link
Author

Choose a reason for hiding this comment

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

good catch. i just did a test
Invoke-WebRequest -Uri https://myapi-gateway-url.amazonaws.com/testfunction -Method GET
where it echos the api gateway request object and i see the value is null when there are no path parameters.

pathParameters: null,
  stageVariables: null,
  body: null,
  isBase64Encoded: false
}

i will update the code

Copy link
Author

Choose a reason for hiding this comment

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

ill also double check the query string and header default values

Copy link
Author

@gcbeattyAWS gcbeattyAWS Dec 12, 2024

Choose a reason for hiding this comment

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

ive updated the logic to handle this now

Body = HttpRequestUtility.ReadRequestBody(request),
IsBase64Encoded = false
};

if (HttpRequestUtility.IsBinaryContent(request.ContentType))
{
proxyRequest.Body = Convert.ToBase64String(Encoding.UTF8.GetBytes(proxyRequest.Body));
proxyRequest.IsBase64Encoded = true;
}

return proxyRequest;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A class that contains extension methods for the <see cref="IServiceCollection"/> interface.
/// </summary>
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;
/// <summary>
/// A class that contains extension methods for the <see cref="IServiceCollection"/> interface.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds a set of services for the .NET CLI portion of this application.
/// </summary>
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));
/// </summary>
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));
}

/// <summary>
/// Adds a set of services for the API Gateway emulator portion of this application.
/// </summary>
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));
}
/// </summary>
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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ public class ApiGatewayRouteConfig
/// The name of the Lambda function
/// </summary>
public required string LambdaResourceName { get; set; }

/// <summary>
/// The endpoint of the local Lambda Runtime API
/// </summary>
public string? Endpoint { get; set; }

/// <summary>
/// The HTTP Method for the API Gateway endpoint
/// </summary>
public required string HttpMethod { get; set; }

/// <summary>
/// The API Gateway HTTP Path of the Lambda function
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
namespace Amazon.Lambda.TestTool
{
/// <summary>
/// Utility class for handling HTTP requests in the context of API Gateway emulation.
/// </summary>
public static class HttpRequestUtility
{
/// <summary>
/// Determines whether the specified content type represents binary content.
/// </summary>
/// <param name="contentType">The content type to check.</param>
/// <returns>True if the content type represents binary content; otherwise, false.</returns>
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);
}

/// <summary>
/// Reads the body of the HTTP request as a string.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <returns>The body of the request as a string.</returns>
public static string ReadRequestBody(HttpRequest request)
{
using (var reader = new StreamReader(request.Body))
{
return reader.ReadToEnd();
}
}

/// <summary>
/// Extracts headers from the request, separating them into single-value and multi-value dictionaries.
/// </summary>
/// <param name="headers">The request headers.</param>
/// <returns>A tuple containing single-value and multi-value header dictionaries.</returns>
/// <example>
/// 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"] }
/// </example>
public static (IDictionary<string, string>, IDictionary<string, IList<string>>) ExtractHeaders(IHeaderDictionary headers)
{
var singleValueHeaders = new Dictionary<string, string>();
var multiValueHeaders = new Dictionary<string, IList<string>>();

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

return (singleValueHeaders, multiValueHeaders);
}

/// <summary>
/// Extracts query string parameters from the request, separating them into single-value and multi-value dictionaries.
/// </summary>
/// <param name="query">The query string collection.</param>
/// <returns>A tuple containing single-value and multi-value query parameter dictionaries.</returns>
/// <example>
/// For query string: ?param1=value1&amp;param2=value2&amp;param2=value3
///
/// The method will return:
/// singleValueParams: { "param1": "value1", "param2": "value3" }
/// multiValueParams: { "param1": ["value1"], "param2": ["value2", "value3"] }
/// </example>
public static (IDictionary<string, string>, IDictionary<string, IList<string>>) ExtractQueryStringParameters(IQueryCollection query)
{
var singleValueParams = new Dictionary<string, string>();
var multiValueParams = new Dictionary<string, IList<string>>();

foreach (var param in query)
{
singleValueParams[param.Key] = param.Value.Last() ?? "";
multiValueParams[param.Key] = [.. param.Value];
}

return (singleValueParams, multiValueParams);
}
}
}
Loading
Loading