diff --git a/.autover/changes/f89246dc-be19-4bd4-aa8a-4f3354419b97.json b/.autover/changes/f89246dc-be19-4bd4-aa8a-4f3354419b97.json
new file mode 100644
index 000000000..b0e3851f5
--- /dev/null
+++ b/.autover/changes/f89246dc-be19-4bd4-aa8a-4f3354419b97.json
@@ -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"
+ ]
+ }
+ ]
+}
diff --git a/Tools/LambdaTestTool-v2/README.md b/Tools/LambdaTestTool-v2/README.md
index dc45e5ceb..5e80ec45b 100644
--- a/Tools/LambdaTestTool-v2/README.md
+++ b/Tools/LambdaTestTool-v2/README.md
@@ -25,6 +25,7 @@ Test and debug your .NET AWS Lambda functions locally. The tool runs a local Lam
- [Single Route](#single-route)
- [Multiple Routes](#multiple-routes)
- [Wildcard Paths](#wildcard-paths)
+ - [HTTP Integration](#http-integration)
- [Event Sources](#event-sources)
- [SQS Event Source](#sqs-event-source)
- [DynamoDB Streams Event Source](#dynamodb-streams-event-source)
@@ -281,7 +282,8 @@ When using the API Gateway emulator, map routes to functions with the `APIGATEWA
- `LambdaResourceName` — the function name (matches the name in `AWS_LAMBDA_RUNTIME_API`).
- `HttpMethod` — e.g. `Get`, `Post` (matched case-insensitively).
- `Path` — the route template, e.g. `/add/{x}/{y}`.
-- `Endpoint` — the **base URL** of the Lambda Runtime API, e.g. `http://localhost:5050`. Do **not** append the function name here; that comes from `LambdaResourceName`. In Combined Mode this is the Lambda emulator's address.
+- `Endpoint` — the **base URL** of the Lambda Runtime API, e.g. `http://localhost:5050`. Do **not** append the function name here; that comes from `LambdaResourceName`. In Combined Mode this is the Lambda emulator's address. For an [HTTP integration](#http-integration) this is instead the base URL of the backend HTTP service to proxy to.
+- `IntegrationType` — optional. `Lambda` (the default) invokes a Lambda function; `Http` reverse-proxies the request to `Endpoint` instead of invoking a Lambda. See [HTTP Integration](#http-integration).
The value can be a single route object or an array of routes.
@@ -343,6 +345,37 @@ Use the `{proxy+}` syntax to proxy any additional path segments to a function. S
This maps `/root` to `RootFunction` and any deeper path (e.g. `/root/a/b`) to `MyOtherLambdaFunction`.
+### HTTP Integration
+
+By default a route invokes a Lambda function (`IntegrationType` of `Lambda`). Set `IntegrationType` to `Http` to have the emulator act as a **reverse proxy** instead: matching requests are forwarded to the route's `Endpoint` — a plain HTTP(s) service — rather than being converted to an API Gateway event and sent to a Lambda function.
+
+This lets you keep a single emulator origin in front of a mixed local topology, so a front end (or `curl`) can hit both your Lambda-backed routes and a non-Lambda backend (a running web API, a container, a third-party service) through the same host and port. The emulator forwards the method, path, query string, headers (except `Host`), and body, then relays the backend's status code, headers, and body back to the caller unchanged.
+
+For an HTTP integration:
+
+- `Endpoint` is the **base URL of the backend service** to proxy to (not a Lambda Runtime API). The incoming path and query string are appended to it. It must be a valid absolute `http`/`https` URL, or the route is rejected at startup.
+- `LambdaResourceName` is still required and is used to identify the route in logs.
+
+```json
+[
+ {
+ "LambdaResourceName": "AddLambdaFunction",
+ "HttpMethod": "Get",
+ "Path": "/add/{x}/{y}",
+ "Endpoint": "http://localhost:5050"
+ },
+ {
+ "LambdaResourceName": "LocalWebApi",
+ "HttpMethod": "Get",
+ "Path": "/api/{proxy+}",
+ "Endpoint": "http://localhost:6000",
+ "IntegrationType": "Http"
+ }
+]
+```
+
+With this config, `GET /add/5/3` invokes the `AddLambdaFunction` Lambda as usual, while `GET /api/orders/42` is proxied to `http://localhost:6000/api/orders/42` and its response is returned verbatim.
+
## Event Sources
The tool can poll a real AWS SQS queue or DynamoDB table stream and invoke your function with the batched events, mirroring how Lambda event source mappings work. These use real AWS credentials (via the `Profile`/`Region` keys or your default credential chain).
diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs
index eb6a54070..f42508ee7 100644
--- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Models/ApiGatewayRouteConfig.cs
@@ -27,4 +27,9 @@ public class ApiGatewayRouteConfig
/// The API Gateway HTTP Path of the Lambda function
///
public required string Path { get; set; }
+
+ ///
+ /// The integration type: "Lambda" (default) or "Http". When "Http", the request is proxied to the Endpoint URL instead of invoking a Lambda.
+ ///
+ public string? IntegrationType { get; set; }
}
diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs
index b09c74e12..0f849b8ef 100644
--- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/ApiGatewayEmulatorProcess.cs
@@ -53,6 +53,7 @@ public static ApiGatewayEmulatorProcess Startup(RunCommandSettings settings, Can
builder.Services.AddApiGatewayEmulatorServices();
builder.Services.AddSingleton();
+ builder.Services.AddHttpClient();
string? serviceHttpUrl = null;
string? serviceHttpsUrl = null;
@@ -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)
@@ -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))
{
diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs
index e8676be36..0416ee17d 100644
--- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs
+++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Services/ApiGatewayRouteConfigService.cs
@@ -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;
diff --git a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs
index a0cdb8cbb..aa73770f5 100644
--- a/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs
+++ b/Tools/LambdaTestTool-v2/tests/Amazon.Lambda.TestTool.UnitTests/Services/ApiGatewayRouteConfigServiceTests.cs
@@ -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
+ {
+ { 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
+ {
+ { 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
+ {
+ { Constants.LambdaConfigEnvironmentVariablePrefix, JsonSerializer.Serialize(routeConfig) }
+ });
+
+ var service = new ApiGatewayRouteConfigService(_mockEnvironmentManager.Object, _mockLogger.Object);
+
+ Assert.Null(service.GetRouteConfig("GET", "/proxy/hello"));
+ }
}