From bd54fe98336deafbebe53fdc0bb5ea0a7d6e42fb Mon Sep 17 00:00:00 2001 From: Steve Hu Date: Tue, 21 Jul 2026 15:01:46 -0400 Subject: [PATCH] fixes #176 add regression test coverage for LambdaProxyMiddleware route miss Extract the matcher lookup from execute into a package-private resolveFunctionName so the routing behaviour can be asserted directly, and add a package-private constructor that builds only the route table without creating an AWS Lambda client. Add LambdaProxyMiddlewareTest covering the happy path, path template matching, and both routing-miss paths. The two miss cases reproduce the NPE from #173 when run against the pre-#174 code. Also guard the existing MockLambdaProxyMiddlewareTest assertion, which used the same unguarded map.get(method).match(path) pattern that #173 was about. Co-Authored-By: Claude Opus 4.8 --- .../proxy/LambdaProxyMiddleware.java | 37 +++++++- .../proxy/LambdaProxyMiddlewareTest.java | 94 +++++++++++++++++++ .../proxy/MockLambdaProxyMiddlewareTest.java | 5 +- 3 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/networknt/aws/lambda/handler/middleware/proxy/LambdaProxyMiddlewareTest.java diff --git a/src/main/java/com/networknt/aws/lambda/handler/middleware/proxy/LambdaProxyMiddleware.java b/src/main/java/com/networknt/aws/lambda/handler/middleware/proxy/LambdaProxyMiddleware.java index 9c74dc5..ea33f77 100644 --- a/src/main/java/com/networknt/aws/lambda/handler/middleware/proxy/LambdaProxyMiddleware.java +++ b/src/main/java/com/networknt/aws/lambda/handler/middleware/proxy/LambdaProxyMiddleware.java @@ -49,6 +49,18 @@ public LambdaProxyMiddleware() { LOG.info("LambdaProxyMiddleware is constructed"); } + /** + * Builds only the route table from the supplied function mapping, without creating an AWS Lambda + * client. Visible for testing so that the routing behaviour can be exercised without AWS access. + * + * @param functions the endpoint to Lambda function mapping, keyed by {@code path@method} + */ + LambdaProxyMiddleware(final Map functions) { + this.config = null; + this.client = null; + populateMethodToMatcherMap(functions); + } + private LambdaAsyncClient initClient(LambdaProxyConfig config) { /* Create our netty client */ var readTimeout = config.getReadTimeout(); @@ -94,13 +106,11 @@ public Status execute(LightLambdaExchange exchange) { var path = exchange.getRequest().getPath(); var method = exchange.getRequest().getHttpMethod().toLowerCase(); LOG.debug("Request path: {} -- Request method: {} -- Start time: {}", path, method, System.currentTimeMillis()); - PathTemplateMatcher matcher = methodToMatcherMap.get(method); - PathTemplateMatcher.PathMatchResult result = matcher == null ? null : matcher.match(path); - if (result == null) { + var functionName = resolveFunctionName(path, method); + if (functionName == null) { LOG.error("No lambda function found for path: {} and method: {}", path, method); return new Status(FAILED_TO_INVOKE_LAMBDA, path + "@" + method); } - var functionName = result.getValue(); LOG.trace("Function name: {}", functionName); var res = this.invokeFunction(this.client, functionName, exchange); if (res == null) { @@ -122,6 +132,25 @@ public Status execute(LightLambdaExchange exchange) { } } + /** + * Resolves the Lambda function configured for the given request path and HTTP method. + *

+ * Returns {@code null} both when no matcher is registered for the method and when the method has a + * matcher but no template matches the path, so that the caller can treat every routing miss the + * same way. + * + * @param path the request path + * @param method the request HTTP method, already normalized to lower case + * @return the configured Lambda function name, or {@code null} when the request does not route + */ + String resolveFunctionName(final String path, final String method) { + PathTemplateMatcher matcher = this.methodToMatcherMap.get(method); + if (matcher == null) + return null; + PathTemplateMatcher.PathMatchResult result = matcher.match(path); + return result == null ? null : result.getValue(); + } + private void populateMethodToMatcherMap(final Map functions) { this.methodToMatcherMap.clear(); for (var entry : functions.entrySet()) { diff --git a/src/test/java/com/networknt/aws/lambda/handler/middleware/proxy/LambdaProxyMiddlewareTest.java b/src/test/java/com/networknt/aws/lambda/handler/middleware/proxy/LambdaProxyMiddlewareTest.java new file mode 100644 index 0000000..25703ae --- /dev/null +++ b/src/test/java/com/networknt/aws/lambda/handler/middleware/proxy/LambdaProxyMiddlewareTest.java @@ -0,0 +1,94 @@ +package com.networknt.aws.lambda.handler.middleware.proxy; + +import com.amazonaws.services.lambda.runtime.Context; +import com.networknt.aws.lambda.InvocationResponse; +import com.networknt.aws.lambda.LambdaContext; +import com.networknt.aws.lambda.LightLambdaExchange; +import com.networknt.aws.lambda.TestUtils; +import com.networknt.status.Status; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +/** + * Routing tests for {@link LambdaProxyMiddleware}. + *

+ * These exercise the real middleware rather than {@code MockLambdaProxyMiddleware}, which resolves + * functions through a plain map lookup and therefore cannot cover the matcher logic at all. + */ +public class LambdaProxyMiddlewareTest { + + private static final Map FUNCTIONS = Map.of( + "/v1/pets@get", "PetsGetFunction", + "/v1/pets@post", "PetsPostFunction", + "/v1/pets/{petId}@get", "PetsPetIdGetFunction"); + + private static LightLambdaExchange exchangeFor(final String path, final String method) { + var requestEvent = TestUtils.createTestRequestEvent(); + requestEvent.setPath(path); + requestEvent.setHttpMethod(method); + + InvocationResponse invocation = InvocationResponse.builder() + .requestId("12345") + .event(requestEvent) + .build(); + + Context lambdaContext = new LambdaContext(invocation.getRequestId()); + final var exchange = new LightLambdaExchange(lambdaContext, null); + exchange.setInitialRequest(requestEvent); + return exchange; + } + + @Test + public void testResolveFunctionNameMatchesConfiguredRoute() { + var middleware = new LambdaProxyMiddleware(FUNCTIONS); + Assertions.assertEquals("PetsGetFunction", middleware.resolveFunctionName("/v1/pets", "get")); + Assertions.assertEquals("PetsPostFunction", middleware.resolveFunctionName("/v1/pets", "post")); + } + + @Test + public void testResolveFunctionNameMatchesPathTemplate() { + var middleware = new LambdaProxyMiddleware(FUNCTIONS); + Assertions.assertEquals("PetsPetIdGetFunction", middleware.resolveFunctionName("/v1/pets/123", "get")); + } + + /** + * Regression test for the NPE reported in issue #173. The path matches a configured function but + * no matcher is registered for the method, which used to dereference a null matcher. + */ + @Test + public void testResolveFunctionNameReturnsNullWhenMethodHasNoMatcher() { + var middleware = new LambdaProxyMiddleware(FUNCTIONS); + Assertions.assertNull(middleware.resolveFunctionName("/v1/pets", "delete")); + } + + @Test + public void testResolveFunctionNameReturnsNullWhenPathDoesNotMatch() { + var middleware = new LambdaProxyMiddleware(FUNCTIONS); + Assertions.assertNull(middleware.resolveFunctionName("/v1/unknown", "get")); + } + + /** + * Regression test for issue #173 at the middleware boundary. An unmapped method must produce a + * routing failure status rather than throwing. + */ + @Test + public void testExecuteReturnsFailureStatusWhenMethodHasNoMatcher() { + var middleware = new LambdaProxyMiddleware(FUNCTIONS); + Status status = Assertions.assertDoesNotThrow( + () -> middleware.execute(exchangeFor("/v1/pets", "DELETE"))); + Assertions.assertEquals(LambdaProxyMiddleware.FAILED_TO_INVOKE_LAMBDA, status.getCode()); + Assertions.assertTrue(status.getDescription().contains("/v1/pets@delete"), + "expected the failure description to identify the unroutable endpoint, but was: " + + status.getDescription()); + } + + @Test + public void testExecuteReturnsFailureStatusWhenPathDoesNotMatch() { + var middleware = new LambdaProxyMiddleware(FUNCTIONS); + Status status = Assertions.assertDoesNotThrow( + () -> middleware.execute(exchangeFor("/v1/unknown", "GET"))); + Assertions.assertEquals(LambdaProxyMiddleware.FAILED_TO_INVOKE_LAMBDA, status.getCode()); + } +} diff --git a/src/test/java/com/networknt/aws/lambda/middleware/proxy/MockLambdaProxyMiddlewareTest.java b/src/test/java/com/networknt/aws/lambda/middleware/proxy/MockLambdaProxyMiddlewareTest.java index 9237fd6..bb2d22d 100644 --- a/src/test/java/com/networknt/aws/lambda/middleware/proxy/MockLambdaProxyMiddlewareTest.java +++ b/src/test/java/com/networknt/aws/lambda/middleware/proxy/MockLambdaProxyMiddlewareTest.java @@ -12,7 +12,10 @@ public void testConstructor() { MockLambdaProxyMiddleware mockLambdaProxyMiddleware = new MockLambdaProxyMiddleware(); Map> methodToMatcherMap = MockLambdaProxyMiddleware.methodToMatcherMap; Assertions.assertFalse(methodToMatcherMap.isEmpty()); - PathTemplateMatcher.PathMatchResult result = methodToMatcherMap.get("get").match("/v1/pets/123"); + PathTemplateMatcher matcher = methodToMatcherMap.get("get"); + Assertions.assertNotNull(matcher, "no matcher registered for the 'get' method"); + PathTemplateMatcher.PathMatchResult result = matcher.match("/v1/pets/123"); + Assertions.assertNotNull(result, "no path template matched /v1/pets/123"); Assertions.assertEquals("PetsPetIdGetFunction", result.getValue()); } }