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"); + } }