From 913c36ea4e45ddd02e663b1d199a11686ee79389 Mon Sep 17 00:00:00 2001 From: Steve Hu Date: Tue, 21 Jul 2026 14:59:48 -0400 Subject: [PATCH] fixes #175 normalize the HTTP method when building the route table The route table was keyed by the raw method from the configuration while lookups used the lower cased request method, so a key written as /v1/pets@GET registered a route that no request could reach. Since #174 that failed as a clean ERR10086 instead of an NPE, which made the misconfiguration silent. Normalize both sides through a single normalizeMethod helper using Locale.ROOT, so an OPTIONS route is not broken by the Turkish dotless i under a non-ROOT default locale. Also split the configuration key on the last '@' rather than every '@', so a path containing the separator still parses, and skip a malformed key with a logged error instead of throwing ArrayIndexOutOfBoundsException during construction. Drop the redundant put; computeIfAbsent already inserts and the matcher is mutated in place. Co-Authored-By: Claude Opus 4.8 --- .../proxy/LambdaProxyMiddleware.java | 26 +++++++-- .../proxy/LambdaProxyMiddlewareTest.java | 54 +++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) 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 ea33f77..56ac4ca 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 @@ -25,6 +25,7 @@ import java.time.Duration; import java.util.Base64; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @@ -104,7 +105,7 @@ public Status execute(LightLambdaExchange exchange) { if (!exchange.hasFailedState()) { /* invoke lambda function */ var path = exchange.getRequest().getPath(); - var method = exchange.getRequest().getHttpMethod().toLowerCase(); + var method = normalizeMethod(exchange.getRequest().getHttpMethod()); LOG.debug("Request path: {} -- Request method: {} -- Start time: {}", path, method, System.currentTimeMillis()); var functionName = resolveFunctionName(path, method); if (functionName == null) { @@ -154,17 +155,32 @@ String resolveFunctionName(final String path, final String method) { private void populateMethodToMatcherMap(final Map functions) { this.methodToMatcherMap.clear(); for (var entry : functions.entrySet()) { - var endpoint = entry.getKey().split("@"); - var path = endpoint[0]; - var method = endpoint[1]; + var endpoint = entry.getKey(); + var separatorIndex = endpoint.lastIndexOf('@'); + if (separatorIndex < 1 || separatorIndex == endpoint.length() - 1) { + LOG.error("Skipping lambda-proxy function '{}': the key must use the 'path@method' format.", endpoint); + continue; + } + var path = endpoint.substring(0, separatorIndex); + var method = normalizeMethod(endpoint.substring(separatorIndex + 1)); PathTemplateMatcher matcher = this.methodToMatcherMap.computeIfAbsent(method, k -> new PathTemplateMatcher<>()); if (matcher.get(path) == null) matcher.add(path, entry.getValue()); - this.methodToMatcherMap.put(method, matcher); } } + /** + * Normalizes an HTTP method to lower case so that the route table built from the configuration + * and the lookup performed for an incoming request always agree on the key. + * + * @param method the HTTP method to normalize + * @return the method in lower case + */ + private static String normalizeMethod(final String method) { + return method.toLowerCase(Locale.ROOT); + } + private String invokeFunction( final LambdaAsyncClient client, final String functionName, 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 index 25703ae..d581557 100644 --- 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 @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.HashMap; import java.util.Map; /** @@ -91,4 +92,57 @@ public void testExecuteReturnsFailureStatusWhenPathDoesNotMatch() { () -> middleware.execute(exchangeFor("/v1/unknown", "GET"))); Assertions.assertEquals(LambdaProxyMiddleware.FAILED_TO_INVOKE_LAMBDA, status.getCode()); } + + /** + * The route table is keyed by the configured method and looked up with the request method, so both + * sides must agree on case. An upper case configuration key used to register a route that no + * request could ever reach. + */ + @Test + public void testUpperCaseConfiguredMethodStillRoutes() { + var middleware = new LambdaProxyMiddleware(Map.of("/v1/pets@GET", "PetsGetFunction")); + Assertions.assertEquals("PetsGetFunction", middleware.resolveFunctionName("/v1/pets", "get")); + } + + @Test + public void testMixedCaseConfiguredMethodStillRoutes() { + var middleware = new LambdaProxyMiddleware(Map.of("/v1/pets@Delete", "PetsDeleteFunction")); + Assertions.assertEquals("PetsDeleteFunction", middleware.resolveFunctionName("/v1/pets", "delete")); + } + + /** + * The request method is upper case on the wire, so an upper case configuration key must be + * reachable through the same normalization {@code execute} applies before the lookup. + */ + @Test + public void testUpperCaseConfiguredMethodIsReachableByAnUpperCaseRequest() { + var middleware = new LambdaProxyMiddleware(Map.of("/v1/pets@POST", "PetsPostFunction")); + var exchange = exchangeFor("/v1/pets", "POST"); + var method = exchange.getRequest().getHttpMethod().toLowerCase(java.util.Locale.ROOT); + Assertions.assertEquals("PetsPostFunction", + middleware.resolveFunctionName(exchange.getRequest().getPath(), method)); + } + + /** + * A path containing '@' must still split on the separator before the method rather than the first + * '@' in the key. + */ + @Test + public void testPathContainingSeparatorIsSplitOnTheLastSeparator() { + var middleware = new LambdaProxyMiddleware(Map.of("/v1/users/{user@domain}@get", "UserGetFunction")); + Assertions.assertEquals("UserGetFunction", middleware.resolveFunctionName("/v1/users/a@b.com", "get")); + } + + @Test + public void testMalformedConfigurationKeysAreSkippedWithoutFailingStartup() { + var functions = new HashMap(); + functions.put("/v1/pets", "MissingMethodFunction"); + functions.put("@get", "MissingPathFunction"); + functions.put("/v1/pets@", "EmptyMethodFunction"); + functions.put("/v1/pets@get", "PetsGetFunction"); + + var middleware = Assertions.assertDoesNotThrow(() -> new LambdaProxyMiddleware(functions)); + Assertions.assertEquals("PetsGetFunction", middleware.resolveFunctionName("/v1/pets", "get"), + "a malformed entry must not prevent the valid entries from being registered"); + } }