From 4ebde75c8f3c564cd310bcf3a7cef12cc104e6be Mon Sep 17 00:00:00 2001 From: Marcelo Mourao Date: Thu, 10 Sep 2026 16:07:58 -0300 Subject: [PATCH 1/2] feat: API Gateway emulator HTTP proxy integration for non-Lambda backends Add optional IntegrationType=Http so emulator routes can reverse-proxy to an HTTP Endpoint instead of invoking Lambda, keeping a single emulator origin for mixed local topologies. Closes #2568 Co-authored-by: Cursor --- .../f89246dc-be19-4bd4-aa8a-4f3354419b97.json | 11 +++ .../Models/ApiGatewayRouteConfig.cs | 5 ++ .../Processes/ApiGatewayEmulatorProcess.cs | 33 ++++++++- .../Services/ApiGatewayRouteConfigService.cs | 16 ++++ .../ApiGatewayRouteConfigServiceTests.cs | 74 +++++++++++++++++++ 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 .autover/changes/f89246dc-be19-4bd4-aa8a-4f3354419b97.json 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/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")); + } } From bd258a19bb6a9f8b6b03ab2ef5d24326c39a30d0 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Wed, 16 Sep 2026 16:34:43 -0700 Subject: [PATCH 2/2] Update README.md --- Tools/LambdaTestTool-v2/README.md | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) 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).