Skip to content

Commit

Permalink
Get happy path working from API Gateway emulator to Lambda function a…
Browse files Browse the repository at this point in the history
…nd back
  • Loading branch information
normj committed Jan 8, 2025
1 parent f8577af commit ef143c3
Show file tree
Hide file tree
Showing 12 changed files with 119 additions and 38 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<Description>A tool to help debug and test your .NET AWS Lambda functions locally.</Description>
Expand All @@ -15,6 +15,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AWSSDK.Lambda" Version="3.7.411.17" />
<PackageReference Include="Spectre.Console" Version="0.49.1" />
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
<PackageReference Include="Amazon.Lambda.APIGatewayEvents" Version="2.7.1" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ public static class ApiGatewayResponseExtensions
/// <param name="context">The <see cref="HttpContext"/> to use for the conversion.</param>
/// <param name="emulatorMode">The <see cref="ApiGatewayEmulatorMode"/> to use for the conversion.</param>
/// <returns>An <see cref="HttpResponse"/> representing the API Gateway response.</returns>
public static void ToHttpResponse(this APIGatewayProxyResponse apiResponse, HttpContext httpContext, ApiGatewayEmulatorMode emulatorMode)
public static async Task ToHttpResponseAsync(this APIGatewayProxyResponse apiResponse, HttpContext httpContext, ApiGatewayEmulatorMode emulatorMode)
{
var response = httpContext.Response;
response.Clear();

SetResponseHeaders(response, apiResponse.Headers, emulatorMode, apiResponse.MultiValueHeaders);
SetResponseBody(response, apiResponse.Body, apiResponse.IsBase64Encoded);
await SetResponseBodyAsync(response, apiResponse.Body, apiResponse.IsBase64Encoded);
SetContentTypeAndStatusCodeV1(response, apiResponse.Headers, apiResponse.MultiValueHeaders, apiResponse.StatusCode, emulatorMode);
}

Expand All @@ -36,13 +36,12 @@ public static void ToHttpResponse(this APIGatewayProxyResponse apiResponse, Http
/// </summary>
/// <param name="apiResponse">The API Gateway HTTP API v2 proxy response to convert.</param>
/// <param name="context">The <see cref="HttpContext"/> to use for the conversion.</param>
public static void ToHttpResponse(this APIGatewayHttpApiV2ProxyResponse apiResponse, HttpContext httpContext)
public static async Task ToHttpResponseAsync(this APIGatewayHttpApiV2ProxyResponse apiResponse, HttpContext httpContext)
{
var response = httpContext.Response;
response.Clear();

SetResponseHeaders(response, apiResponse.Headers, ApiGatewayEmulatorMode.HttpV2);
SetResponseBody(response, apiResponse.Body, apiResponse.IsBase64Encoded);
await SetResponseBodyAsync(response, apiResponse.Body, apiResponse.IsBase64Encoded);
SetContentTypeAndStatusCodeV2(response, apiResponse.Headers, apiResponse.StatusCode);
}

Expand Down Expand Up @@ -121,7 +120,7 @@ private static Dictionary<string, string> GetDefaultApiGatewayHeaders(ApiGateway
/// <param name="response">The <see cref="HttpResponse"/> to set the body on.</param>
/// <param name="body">The body content.</param>
/// <param name="isBase64Encoded">Whether the body is Base64 encoded.</param>
private static void SetResponseBody(HttpResponse response, string? body, bool isBase64Encoded)
private static async Task SetResponseBodyAsync(HttpResponse response, string? body, bool isBase64Encoded)
{
if (!string.IsNullOrEmpty(body))
{
Expand All @@ -135,8 +134,8 @@ private static void SetResponseBody(HttpResponse response, string? body, bool is
bodyBytes = Encoding.UTF8.GetBytes(body);
}

response.Body = new MemoryStream(bodyBytes);
response.ContentLength = bodyBytes.Length;
await response.Body.WriteAsync(bodyBytes, 0, bodyBytes.Length);
}
}

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

using System.Text;
using Amazon.Lambda.TestTool.Models;

namespace Amazon.Lambda.TestTool.Extensions;
Expand All @@ -16,18 +17,20 @@ public static class ApiGatewayResults
/// <param name="context">The <see cref="HttpContext"/> to update.</param>
/// <param name="emulatorMode">The API Gateway Emulator mode.</param>
/// <returns></returns>
public static IResult RouteNotFound(HttpContext context, ApiGatewayEmulatorMode emulatorMode)
public static async Task RouteNotFoundAsync(HttpContext context, ApiGatewayEmulatorMode emulatorMode)
{
if (emulatorMode == ApiGatewayEmulatorMode.Rest)
{
const string message = "{\"message\":\"Missing Authentication Token\"}";
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.Headers.Append("x-amzn-errortype", "MissingAuthenticationTokenException");
return Results.Json(new { message = "Missing Authentication Token" });
await context.Response.Body.WriteAsync(UTF8Encoding.UTF8.GetBytes(message));
}
else
{
const string message = "{\"message\":\"Not Found\"}";
context.Response.StatusCode = StatusCodes.Status404NotFound;
return Results.Json(new { message = "Not Found" });
await context.Response.Body.WriteAsync(UTF8Encoding.UTF8.GetBytes(message));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

using Amazon.Lambda.APIGatewayEvents;
using Amazon.Lambda.Model;
using Amazon.Lambda.TestTool.Commands.Settings;
using Amazon.Lambda.TestTool.Extensions;
using Amazon.Lambda.TestTool.Models;
using Amazon.Lambda.TestTool.Services;

using System.Text.Json;

namespace Amazon.Lambda.TestTool.Processes;

/// <summary>
Expand All @@ -28,6 +32,8 @@ public class ApiGatewayEmulatorProcess
/// </summary>
public required string ServiceUrl { get; init; }

private static readonly JsonSerializerOptions _jsonSerializationOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

/// <summary>
/// Creates the Web API and runs it in the background.
/// </summary>
Expand Down Expand Up @@ -59,26 +65,78 @@ public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, Can
app.Logger.LogInformation("The API Gateway Emulator is available at: {ServiceUrl}", serviceUrl);
});

app.Map("/{**catchAll}", (HttpContext context, IApiGatewayRouteConfigService routeConfigService) =>
app.Map("/{**catchAll}", async (HttpContext context, IApiGatewayRouteConfigService routeConfigService) =>
{
var routeConfig = routeConfigService.GetRouteConfig(context.Request.Method, context.Request.Path);
if (routeConfig == null)
{
app.Logger.LogInformation("Unable to find a configured Lambda route for the specified method and path: {Method} {Path}",
context.Request.Method, context.Request.Path);
return ApiGatewayResults.RouteNotFound(context, (ApiGatewayEmulatorMode) settings.ApiGatewayEmulatorMode);
await ApiGatewayResults.RouteNotFoundAsync(context, (ApiGatewayEmulatorMode)settings.ApiGatewayEmulatorMode);
return;
}

// Convert ASP.NET Core request to API Gateway event object
var lambdaRequestStream = new MemoryStream();
if (settings.ApiGatewayEmulatorMode.Equals(ApiGatewayEmulatorMode.HttpV2))
{
// TODO: Translate to APIGatewayHttpApiV2ProxyRequest
var lambdaRequest = await context.ToApiGatewayHttpV2Request(routeConfig);
JsonSerializer.Serialize<APIGatewayHttpApiV2ProxyRequest>(lambdaRequestStream, lambdaRequest, _jsonSerializationOptions);
}
else
{
// TODO: Translate to APIGatewayProxyRequest
var lambdaRequest = await context.ToApiGatewayRequest(routeConfig, settings.ApiGatewayEmulatorMode.Value);
JsonSerializer.Serialize<APIGatewayProxyRequest>(lambdaRequestStream, lambdaRequest, _jsonSerializationOptions);
}
lambdaRequestStream.Position = 0;

// Invoke Lamdba function via the test tool's Lambda Runtime API.
var invokeRequest = new InvokeRequest
{
FunctionName = routeConfig.LambdaResourceName,
InvocationType = InvocationType.RequestResponse,
PayloadStream = lambdaRequestStream
};

using var lambdaClient = CreateLambdaServiceClient(routeConfig);
var response = await lambdaClient.InvokeAsync(invokeRequest);

if (response.FunctionError != null)
{
// TODO: Mimic API Gateway's behavior when Lambda function has an exception during invocation.
context.Response.StatusCode = 500;
return;
}

return Results.Ok();
// Convert API Gateway response object returned from Lambda to ASP.NET Core response.
if (settings.ApiGatewayEmulatorMode.Equals(ApiGatewayEmulatorMode.HttpV2))
{
// TODO: handle the response not being in the APIGatewayHttpApiV2ProxyResponse format.
var lambdaResponse = JsonSerializer.Deserialize<APIGatewayHttpApiV2ProxyResponse>(response.Payload);
if (lambdaResponse == null)
{
app.Logger.LogError("Unable to deserialize the response from the Lambda function.");
context.Response.StatusCode = 500;
return;
}

await lambdaResponse.ToHttpResponseAsync(context);
return;
}
else
{
// TODO: handle the response not being in the APIGatewayHttpApiV2ProxyResponse format.
var lambdaResponse = JsonSerializer.Deserialize<APIGatewayProxyResponse>(response.Payload);
if (lambdaResponse == null)
{
app.Logger.LogError("Unable to deserialize the response from the Lambda function.");
context.Response.StatusCode = 500;
return;
}

await lambdaResponse.ToHttpResponseAsync(context, settings.ApiGatewayEmulatorMode.Value);
return;
}
});

var runTask = app.RunAsync(cancellationToken);
Expand All @@ -90,4 +148,15 @@ public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, Can
ServiceUrl = serviceUrl
};
}

private static IAmazonLambda CreateLambdaServiceClient(ApiGatewayRouteConfig routeConfig)
{
// TODO: Handle routeConfig.Endpoint to null and use the settings versions of runtime.
var lambdaConfig = new AmazonLambdaConfig
{
ServiceURL = routeConfig.Endpoint
};

return new AmazonLambdaClient(new Amazon.Runtime.BasicAWSCredentials("accessKey", "secretKey"), lambdaConfig);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ private bool IsRouteConfigValid(ApiGatewayRouteConfig routeConfig)
return false;
}

var segments = routeConfig.Path.Trim('/').Split('/');
var segments = routeConfig.Path.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
foreach (var segment in segments)
{
var regexPattern = "^(\\{[\\w.:-]+\\+?\\}|[a-zA-Z0-9.:_-]+)$";
Expand Down Expand Up @@ -186,7 +186,7 @@ private bool IsRouteConfigValid(ApiGatewayRouteConfig routeConfig)
// Route template: "/resource/{proxy+}"
// Request path: "/resource ---> Not a match
// Request path: "/resource/ ---> Is a match
var requestSegments = path.TrimStart('/').Split('/');
var requestSegments = path.TrimStart('/').Split('/', StringSplitOptions.RemoveEmptyEntries);

var candidates = new List<MatchResult>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ public static bool IsBinaryContent(string? contentType)
// Check if the content is binary
bool isBinary = HttpRequestUtility.IsBinaryContent(request.ContentType);

request.Body.Position = 0;
if (request.Body.CanSeek)
{
request.Body.Position = 0;
}

using (var memoryStream = new MemoryStream())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@

<ItemGroup>
<PackageReference Include="Amazon.Lambda.APIGatewayEvents" Version="2.7.1" />
<PackageReference Include="AWSSDK.APIGateway" Version="3.7.401.7" />
<PackageReference Include="AWSSDK.CloudFormation" Version="3.7.401.11" />
<PackageReference Include="AWSSDK.IdentityManagement" Version="3.7.402" />
<PackageReference Include="AWSSDK.ApiGatewayV2" Version="3.7.400.63" />
<PackageReference Include="AWSSDK.Lambda" Version="3.7.408.1" />
<PackageReference Include="AWSSDK.APIGateway" Version="3.7.401.18" />
<PackageReference Include="AWSSDK.CloudFormation" Version="3.7.401.21" />
<PackageReference Include="AWSSDK.IdentityManagement" Version="3.7.403.23" />
<PackageReference Include="AWSSDK.ApiGatewayV2" Version="3.7.400.74" />
<PackageReference Include="AWSSDK.Lambda" Version="3.7.411.17" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public async Task ToHttpResponse_RestAPIGatewayV1DecodesBase64()
};

var httpContext = new DefaultHttpContext();
testResponse.ToHttpResponse(httpContext, ApiGatewayEmulatorMode.Rest);
testResponse.ToHttpResponseAsync(httpContext, ApiGatewayEmulatorMode.Rest);
var actualResponse = await _httpClient.PostAsync(_fixture.ReturnDecodedParseBinRestApiUrl, new StringContent(JsonSerializer.Serialize(testResponse)));
await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpContext.Response);
Assert.Equal(200, (int)actualResponse.StatusCode);
Expand All @@ -72,7 +72,7 @@ public async Task ToHttpResponse_HttpV1APIGatewayV1DecodesBase64()
};

var httpContext = new DefaultHttpContext();
testResponse.ToHttpResponse(httpContext, ApiGatewayEmulatorMode.HttpV1);
testResponse.ToHttpResponseAsync(httpContext, ApiGatewayEmulatorMode.HttpV1);
var actualResponse = await _httpClient.PostAsync(_fixture.ParseAndReturnBodyHttpApiV1Url, new StringContent(JsonSerializer.Serialize(testResponse)));

await _fixture.ApiGatewayTestHelper.AssertResponsesEqual(actualResponse, httpContext.Response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public ApiGatewayTestHelper()
public async Task<(HttpResponseMessage actualResponse, HttpResponse httpTestResponse)> ExecuteTestRequest(APIGatewayProxyResponse testResponse, string apiUrl, ApiGatewayEmulatorMode emulatorMode)
{
var httpContext = new DefaultHttpContext();
testResponse.ToHttpResponse(httpContext, emulatorMode);
testResponse.ToHttpResponseAsync(httpContext, emulatorMode);
var serialized = JsonSerializer.Serialize(testResponse);
var actualResponse = await _httpClient.PostAsync(apiUrl, new StringContent(serialized));
return (actualResponse, httpContext.Response);
Expand All @@ -29,7 +29,7 @@ public ApiGatewayTestHelper()
public async Task<(HttpResponseMessage actualResponse, HttpResponse httpTestResponse)> ExecuteTestRequest(APIGatewayHttpApiV2ProxyResponse testResponse, string apiUrl)
{
var httpContext = new DefaultHttpContext();
testResponse.ToHttpResponse(httpContext);
testResponse.ToHttpResponseAsync(httpContext);
var serialized = JsonSerializer.Serialize(testResponse);
var actualResponse = await _httpClient.PostAsync(apiUrl, new StringContent(serialized));
return (actualResponse, httpContext.Response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Amazon.Lambda.RuntimeSupport" Version="1.11.0" />
<PackageReference Include="Amazon.Lambda.RuntimeSupport" Version="1.12.2" />
<PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.4.4" />
<PackageReference Include="AWSSDK.Lambda" Version="3.7.408.1" />
<PackageReference Include="AWSSDK.Lambda" Version="3.7.411.17" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Loading

0 comments on commit ef143c3

Please sign in to comment.