Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> functions) {
this.config = null;
this.client = null;
populateMethodToMatcherMap(functions);
}

private LambdaAsyncClient initClient(LambdaProxyConfig config) {
/* Create our netty client */
var readTimeout = config.getReadTimeout();
Expand Down Expand Up @@ -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<String> matcher = methodToMatcherMap.get(method);
PathTemplateMatcher.PathMatchResult<String> 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);
Comment on lines 114 to 115
if (res == null) {
Expand All @@ -122,6 +132,25 @@ public Status execute(LightLambdaExchange exchange) {
}
}

/**
* Resolves the Lambda function configured for the given request path and HTTP method.
* <p>
* 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<String> matcher = this.methodToMatcherMap.get(method);
if (matcher == null)
return null;
PathTemplateMatcher.PathMatchResult<String> result = matcher.match(path);
return result == null ? null : result.getValue();
}

private void populateMethodToMatcherMap(final Map<String, String> functions) {
this.methodToMatcherMap.clear();
for (var entry : functions.entrySet()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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}.
* <p>
* 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<String, String> 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());
}
Comment on lines +89 to +93
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ public void testConstructor() {
MockLambdaProxyMiddleware mockLambdaProxyMiddleware = new MockLambdaProxyMiddleware();
Map<String, PathTemplateMatcher<String>> methodToMatcherMap = MockLambdaProxyMiddleware.methodToMatcherMap;
Assertions.assertFalse(methodToMatcherMap.isEmpty());
PathTemplateMatcher.PathMatchResult<String> result = methodToMatcherMap.get("get").match("/v1/pets/123");
PathTemplateMatcher<String> matcher = methodToMatcherMap.get("get");
Assertions.assertNotNull(matcher, "no matcher registered for the 'get' method");
PathTemplateMatcher.PathMatchResult<String> result = matcher.match("/v1/pets/123");
Assertions.assertNotNull(result, "no path template matched /v1/pets/123");
Assertions.assertEquals("PetsPetIdGetFunction", result.getValue());
}
}