Skip to content
Open
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
11 changes: 11 additions & 0 deletions .autover/changes/f89246dc-be19-4bd4-aa8a-4f3354419b97.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"Projects": [
{
"Name": "Amazon.Lambda.TestTool",
"Type": "Minor",
"ChangelogMessages": [
"API Gateway emulator: optional IntegrationType=Http to reverse-proxy requests to a non-Lambda HTTP Endpoint while keeping the emulator origin"
]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,9 @@ public class ApiGatewayRouteConfig
/// The API Gateway HTTP Path of the Lambda function
/// </summary>
public required string Path { get; set; }

/// <summary>
/// The integration type: "Lambda" (default) or "Http". When "Http", the request is proxied to the Endpoint URL instead of invoking a Lambda.
/// </summary>
public string? IntegrationType { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, Can

builder.Services.AddApiGatewayEmulatorServices();
builder.Services.AddSingleton<ILambdaClient, LambdaClient>();
builder.Services.AddHttpClient();

string? serviceHttpUrl = null;
string? serviceHttpsUrl = null;
Expand Down Expand Up @@ -83,7 +84,7 @@ public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, Can
app.Logger.LogInformation("The API Gateway Emulator is available at: {ServiceUrl}", serviceHttpsUrl ?? serviceHttpUrl);
});

app.Map("/{**catchAll}", async (HttpContext context, IApiGatewayRouteConfigService routeConfigService, ILambdaClient lambdaClient) =>
app.Map("/{**catchAll}", async (HttpContext context, IApiGatewayRouteConfigService routeConfigService, ILambdaClient lambdaClient, IHttpClientFactory httpClientFactory) =>
{
var routeConfig = routeConfigService.GetRouteConfig(context.Request.Method, context.Request.Path);
if (routeConfig == null)
Expand All @@ -94,7 +95,35 @@ public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, Can
return;
}

// Convert ASP.NET Core request to API Gateway event object
// HTTP integration: proxy request to the backend URL
if (string.Equals(routeConfig.IntegrationType, "Http", StringComparison.OrdinalIgnoreCase))
{
var endpoint = routeConfig.Endpoint ?? throw new InvalidOperationException($"HTTP route {routeConfig.LambdaResourceName} requires Endpoint.");
var targetUrl = $"{endpoint.TrimEnd('/')}{context.Request.Path}{context.Request.QueryString}";
var httpClient = httpClientFactory.CreateClient();
using var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUrl);
if (context.Request.ContentLength > 0 && (context.Request.Method == "POST" || context.Request.Method == "PUT" || context.Request.Method == "PATCH"))
{
request.Content = new StreamContent(context.Request.Body);
if (context.Request.ContentType != null)
request.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType);
}
foreach (var header in context.Request.Headers.Where(h => !string.Equals(h.Key, "Host", StringComparison.OrdinalIgnoreCase)))
{
if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()))
request.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
}
var response = await httpClient.SendAsync(request, context.RequestAborted);
context.Response.StatusCode = (int)response.StatusCode;
foreach (var header in response.Headers)
context.Response.Headers[header.Key] = string.Join(", ", header.Value);
if (response.Content.Headers.ContentType != null)
context.Response.ContentType = response.Content.Headers.ContentType.ToString();
await response.Content.CopyToAsync(context.Response.Body, context.RequestAborted);
return;
}

// Convert ASP.NET Core request to API Gateway event object (Lambda integration)
var lambdaRequestStream = new MemoryStream();
if (settings.ApiGatewayEmulatorMode.Equals(ApiGatewayEmulatorMode.HttpV2))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,22 @@ private bool IsRouteConfigValid(ApiGatewayRouteConfig routeConfig)
return false;
}

if (string.Equals(routeConfig.IntegrationType, "Http", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(routeConfig.Endpoint))
{
_logger.LogError("HTTP integration requires a non-empty Endpoint for route {Lambda} {Method} {Path}.",
routeConfig.LambdaResourceName, routeConfig.HttpMethod, routeConfig.Path);
return false;
}
if (!Uri.TryCreate(routeConfig.Endpoint, UriKind.Absolute, out var uri) || !uri.Scheme.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
_logger.LogError("HTTP integration Endpoint must be a valid HTTP(s) URL for route {Lambda} {Method} {Path}.",
routeConfig.LambdaResourceName, routeConfig.HttpMethod, routeConfig.Path);
return false;
}
}

// Special case for root path
if (routeConfig.Path == "/") return true;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -471,4 +471,78 @@ public void ProperlyMatchRouteConfigs()
Assert.Equal("F1", result20?.LambdaResourceName);
Assert.Equal("F1", result21?.LambdaResourceName);
}

[Fact]
public void Constructor_LoadsHttpIntegrationWhenEndpointIsValid()
{
var routeConfig = new ApiGatewayRouteConfig
{
LambdaResourceName = "HttpBackend",
HttpMethod = "GET",
Path = "/proxy/{proxy+}",
IntegrationType = "Http",
Endpoint = "http://127.0.0.1:5000"
};

_mockEnvironmentManager
.Setup(m => m.GetEnvironmentVariables())
.Returns(new Dictionary<string, string>
{
{ Constants.LambdaConfigEnvironmentVariablePrefix, JsonSerializer.Serialize(routeConfig) }
});

var service = new ApiGatewayRouteConfigService(_mockEnvironmentManager.Object, _mockLogger.Object);

var result = service.GetRouteConfig("GET", "/proxy/hello");
Assert.NotNull(result);
Assert.Equal("Http", result.IntegrationType);
Assert.Equal("http://127.0.0.1:5000", result.Endpoint);
}

[Fact]
public void Constructor_IgnoresHttpIntegrationWithoutEndpoint()
{
var routeConfig = new ApiGatewayRouteConfig
{
LambdaResourceName = "HttpBackend",
HttpMethod = "GET",
Path = "/proxy/{proxy+}",
IntegrationType = "Http"
};

_mockEnvironmentManager
.Setup(m => m.GetEnvironmentVariables())
.Returns(new Dictionary<string, string>
{
{ Constants.LambdaConfigEnvironmentVariablePrefix, JsonSerializer.Serialize(routeConfig) }
});

var service = new ApiGatewayRouteConfigService(_mockEnvironmentManager.Object, _mockLogger.Object);

Assert.Null(service.GetRouteConfig("GET", "/proxy/hello"));
}

[Fact]
public void Constructor_IgnoresHttpIntegrationWithInvalidEndpoint()
{
var routeConfig = new ApiGatewayRouteConfig
{
LambdaResourceName = "HttpBackend",
HttpMethod = "GET",
Path = "/proxy/{proxy+}",
IntegrationType = "Http",
Endpoint = "not-a-url"
};

_mockEnvironmentManager
.Setup(m => m.GetEnvironmentVariables())
.Returns(new Dictionary<string, string>
{
{ Constants.LambdaConfigEnvironmentVariablePrefix, JsonSerializer.Serialize(routeConfig) }
});

var service = new ApiGatewayRouteConfigService(_mockEnvironmentManager.Object, _mockLogger.Object);

Assert.Null(service.GetRouteConfig("GET", "/proxy/hello"));
}
}