From 4ebde75c8f3c564cd310bcf3a7cef12cc104e6be Mon Sep 17 00:00:00 2001 From: Marcelo Mourao Date: Thu, 10 Sep 2026 16:07:58 -0300 Subject: [PATCH] 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")); + } }