Skip to content

Commit 2864b46

Browse files
feat(apigateway): ignore trailing slashes in routes (APIGatewayRestResolver) (#1609)
Co-authored-by: Heitor Lessa <lessa@amazon.nl>
1 parent ec96b14 commit 2864b46

13 files changed

+462
-11
lines changed

Diff for: aws_lambda_powertools/event_handler/api_gateway.py

+21-2
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
# API GW/ALB decode non-safe URI chars; we must support them too
4747
_UNSAFE_URI = "%<> \[\]{}|^" # noqa: W605
4848
_NAMED_GROUP_BOUNDARY_PATTERN = rf"(?P\1[{_SAFE_URI}{_UNSAFE_URI}\\w]+)"
49+
_ROUTE_REGEX = "^{}$"
4950

5051

5152
class ProxyEventType(Enum):
@@ -562,7 +563,7 @@ def _has_debug(debug: Optional[bool] = None) -> bool:
562563
return powertools_dev_is_set()
563564

564565
@staticmethod
565-
def _compile_regex(rule: str):
566+
def _compile_regex(rule: str, base_regex: str = _ROUTE_REGEX):
566567
"""Precompile regex pattern
567568
568569
Logic
@@ -592,7 +593,7 @@ def _compile_regex(rule: str):
592593
NOTE: See #520 for context
593594
"""
594595
rule_regex: str = re.sub(_DYNAMIC_ROUTE_PATTERN, _NAMED_GROUP_BOUNDARY_PATTERN, rule)
595-
return re.compile("^{}$".format(rule_regex))
596+
return re.compile(base_regex.format(rule_regex))
596597

597598
def _to_proxy_event(self, event: Dict) -> BaseProxyEvent:
598599
"""Convert the event dict to the corresponding data class"""
@@ -819,6 +820,24 @@ def __init__(
819820
"""Amazon API Gateway REST and HTTP API v1 payload resolver"""
820821
super().__init__(ProxyEventType.APIGatewayProxyEvent, cors, debug, serializer, strip_prefixes)
821822

823+
# override route to ignore trailing "/" in routes for REST API
824+
def route(
825+
self,
826+
rule: str,
827+
method: Union[str, Union[List[str], Tuple[str]]],
828+
cors: Optional[bool] = None,
829+
compress: bool = False,
830+
cache_control: Optional[str] = None,
831+
):
832+
# NOTE: see #1552 for more context.
833+
return super().route(rule.rstrip("/"), method, cors, compress, cache_control)
834+
835+
# Override _compile_regex to exclude trailing slashes for route resolution
836+
@staticmethod
837+
def _compile_regex(rule: str, base_regex: str = _ROUTE_REGEX):
838+
839+
return super(APIGatewayRestResolver, APIGatewayRestResolver)._compile_regex(rule, "^{}/*$")
840+
822841

823842
class APIGatewayHttpResolver(ApiGatewayResolver):
824843
current_event: APIGatewayProxyEventV2

Diff for: docs/core/event_handler/api_gateway.md

+5-5
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,19 @@ Before you decorate your functions to handle a given path and HTTP method(s), yo
4242

4343
A resolver will handle request resolution, including [one or more routers](#split-routes-with-router), and give you access to the current event via typed properties.
4444

45-
For resolvers, we provide: `APIGatewayRestResolver`, `APIGatewayHttpResolver`, `ALBResolver`, and `LambdaFunctionUrlResolver` .
45+
For resolvers, we provide: `APIGatewayRestResolver`, `APIGatewayHttpResolver`, `ALBResolver`, and `LambdaFunctionUrlResolver`. From here on, we will default to `APIGatewayRestResolver` across examples.
4646

47-
???+ info
48-
We will use `APIGatewayRestResolver` as the default across examples.
47+
???+ info "Auto-serialization"
48+
We serialize `Dict` responses as JSON, trim whitespace for compact responses, and set content-type to `application/json`.
4949

5050
#### API Gateway REST API
5151

5252
When using Amazon API Gateway REST API to front your Lambda functions, you can use `APIGatewayRestResolver`.
5353

5454
Here's an example on how we can handle the `/todos` path.
5555

56-
???+ info
57-
We automatically serialize `Dict` responses as JSON, trim whitespace for compact responses, and set content-type to `application/json`.
56+
???+ info "Trailing slash in routes"
57+
For `APIGatewayRestResolver`, we seamless handle routes with a trailing slash (`/todos/`).
5858

5959
=== "getting_started_rest_api_resolver.py"
6060

Diff for: tests/e2e/event_handler/handlers/alb_handler.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
app = ALBResolver()
44

5+
# The reason we use post is that whoever is writing tests can easily assert on the
6+
# content being sent (body, headers, cookies, content-type) to reduce cognitive load.
7+
58

69
@app.post("/todos")
7-
def hello():
10+
def todos():
811
payload = app.current_event.json_body
912

1013
body = payload.get("body", "Hello World")

Diff for: tests/e2e/event_handler/handlers/api_gateway_http_handler.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66

77
app = APIGatewayHttpResolver()
88

9+
# The reason we use post is that whoever is writing tests can easily assert on the
10+
# content being sent (body, headers, cookies, content-type) to reduce cognitive load.
11+
912

1013
@app.post("/todos")
11-
def hello():
14+
def todos():
1215
payload = app.current_event.json_body
1316

1417
body = payload.get("body", "Hello World")

Diff for: tests/e2e/event_handler/handlers/api_gateway_rest_handler.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66

77
app = APIGatewayRestResolver()
88

9+
# The reason we use post is that whoever is writing tests can easily assert on the
10+
# content being sent (body, headers, cookies, content-type) to reduce cognitive load.
11+
912

1013
@app.post("/todos")
11-
def hello():
14+
def todos():
1215
payload = app.current_event.json_body
1316

1417
body = payload.get("body", "Hello World")

Diff for: tests/e2e/event_handler/handlers/lambda_function_url_handler.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66

77
app = LambdaFunctionUrlResolver()
88

9+
# The reason we use post is that whoever is writing tests can easily assert on the
10+
# content being sent (body, headers, cookies, content-type) to reduce cognitive load.
11+
912

1013
@app.post("/todos")
11-
def hello():
14+
def todos():
1215
payload = app.current_event.json_body
1316

1417
body = payload.get("body", "Hello World")
+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import pytest
2+
from requests import HTTPError, Request
3+
4+
from tests.e2e.utils import data_fetcher
5+
6+
7+
@pytest.fixture
8+
def alb_basic_listener_endpoint(infrastructure: dict) -> str:
9+
dns_name = infrastructure.get("ALBDnsName")
10+
port = infrastructure.get("ALBBasicListenerPort", "")
11+
return f"http://{dns_name}:{port}"
12+
13+
14+
@pytest.fixture
15+
def alb_multi_value_header_listener_endpoint(infrastructure: dict) -> str:
16+
dns_name = infrastructure.get("ALBDnsName")
17+
port = infrastructure.get("ALBMultiValueHeaderListenerPort", "")
18+
return f"http://{dns_name}:{port}"
19+
20+
21+
@pytest.fixture
22+
def apigw_rest_endpoint(infrastructure: dict) -> str:
23+
return infrastructure.get("APIGatewayRestUrl", "")
24+
25+
26+
@pytest.fixture
27+
def apigw_http_endpoint(infrastructure: dict) -> str:
28+
return infrastructure.get("APIGatewayHTTPUrl", "")
29+
30+
31+
@pytest.fixture
32+
def lambda_function_url_endpoint(infrastructure: dict) -> str:
33+
return infrastructure.get("LambdaFunctionUrl", "")
34+
35+
36+
def test_api_gateway_rest_trailing_slash(apigw_rest_endpoint):
37+
# GIVEN API URL ends in a trailing slash
38+
url = f"{apigw_rest_endpoint}todos/"
39+
body = "Hello World"
40+
41+
# WHEN
42+
response = data_fetcher.get_http_response(
43+
Request(
44+
method="POST",
45+
url=url,
46+
json={"body": body},
47+
)
48+
)
49+
50+
# THEN expect a HTTP 200 response
51+
assert response.status_code == 200
52+
53+
54+
def test_api_gateway_http_trailing_slash(apigw_http_endpoint):
55+
# GIVEN the URL for the API ends in a trailing slash API gateway should return a 404
56+
url = f"{apigw_http_endpoint}todos/"
57+
body = "Hello World"
58+
59+
# WHEN calling an invalid URL (with trailing slash) expect HTTPError exception from data_fetcher
60+
with pytest.raises(HTTPError):
61+
data_fetcher.get_http_response(
62+
Request(
63+
method="POST",
64+
url=url,
65+
json={"body": body},
66+
)
67+
)
68+
69+
70+
def test_lambda_function_url_trailing_slash(lambda_function_url_endpoint):
71+
# GIVEN the URL for the API ends in a trailing slash it should behave as if there was not one
72+
url = f"{lambda_function_url_endpoint}todos/" # the function url endpoint already has the trailing /
73+
body = "Hello World"
74+
75+
# WHEN calling an invalid URL (with trailing slash) expect HTTPError exception from data_fetcher
76+
with pytest.raises(HTTPError):
77+
data_fetcher.get_http_response(
78+
Request(
79+
method="POST",
80+
url=url,
81+
json={"body": body},
82+
)
83+
)
84+
85+
86+
def test_alb_url_trailing_slash(alb_multi_value_header_listener_endpoint):
87+
# GIVEN url has a trailing slash - it should behave as if there was not one
88+
url = f"{alb_multi_value_header_listener_endpoint}/todos/"
89+
body = "Hello World"
90+
91+
# WHEN calling an invalid URL (with trailing slash) expect HTTPError exception from data_fetcher
92+
with pytest.raises(HTTPError):
93+
data_fetcher.get_http_response(
94+
Request(
95+
method="POST",
96+
url=url,
97+
json={"body": body},
98+
)
99+
)

Diff for: tests/events/albEventPathTrailingSlash.json

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"requestContext": {
3+
"elb": {
4+
"targetGroupArn": "arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a"
5+
}
6+
},
7+
"httpMethod": "GET",
8+
"path": "/lambda/",
9+
"queryStringParameters": {
10+
"query": "1234ABCD"
11+
},
12+
"headers": {
13+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
14+
"accept-encoding": "gzip",
15+
"accept-language": "en-US,en;q=0.9",
16+
"connection": "keep-alive",
17+
"host": "lambda-alb-123578498.us-east-2.elb.amazonaws.com",
18+
"upgrade-insecure-requests": "1",
19+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36",
20+
"x-amzn-trace-id": "Root=1-5c536348-3d683b8b04734faae651f476",
21+
"x-forwarded-for": "72.12.164.125",
22+
"x-forwarded-port": "80",
23+
"x-forwarded-proto": "http",
24+
"x-imforwards": "20"
25+
},
26+
"body": "Test",
27+
"isBase64Encoded": false
28+
}
+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
{
2+
"version": "1.0",
3+
"resource": "/my/path",
4+
"path": "/my/path/",
5+
"httpMethod": "GET",
6+
"headers": {
7+
"Header1": "value1",
8+
"Header2": "value2"
9+
},
10+
"multiValueHeaders": {
11+
"Header1": [
12+
"value1"
13+
],
14+
"Header2": [
15+
"value1",
16+
"value2"
17+
]
18+
},
19+
"queryStringParameters": {
20+
"parameter1": "value1",
21+
"parameter2": "value"
22+
},
23+
"multiValueQueryStringParameters": {
24+
"parameter1": [
25+
"value1",
26+
"value2"
27+
],
28+
"parameter2": [
29+
"value"
30+
]
31+
},
32+
"requestContext": {
33+
"accountId": "123456789012",
34+
"apiId": "id",
35+
"authorizer": {
36+
"claims": null,
37+
"scopes": null
38+
},
39+
"domainName": "id.execute-api.us-east-1.amazonaws.com",
40+
"domainPrefix": "id",
41+
"extendedRequestId": "request-id",
42+
"httpMethod": "GET",
43+
"identity": {
44+
"accessKey": null,
45+
"accountId": null,
46+
"caller": null,
47+
"cognitoAuthenticationProvider": null,
48+
"cognitoAuthenticationType": null,
49+
"cognitoIdentityId": null,
50+
"cognitoIdentityPoolId": null,
51+
"principalOrgId": null,
52+
"sourceIp": "192.168.0.1/32",
53+
"user": null,
54+
"userAgent": "user-agent",
55+
"userArn": null,
56+
"clientCert": {
57+
"clientCertPem": "CERT_CONTENT",
58+
"subjectDN": "www.example.com",
59+
"issuerDN": "Example issuer",
60+
"serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1",
61+
"validity": {
62+
"notBefore": "May 28 12:30:02 2019 GMT",
63+
"notAfter": "Aug 5 09:36:04 2021 GMT"
64+
}
65+
}
66+
},
67+
"path": "/my/path",
68+
"protocol": "HTTP/1.1",
69+
"requestId": "id=",
70+
"requestTime": "04/Mar/2020:19:15:17 +0000",
71+
"requestTimeEpoch": 1583349317135,
72+
"resourceId": null,
73+
"resourcePath": "/my/path",
74+
"stage": "$default"
75+
},
76+
"pathParameters": null,
77+
"stageVariables": null,
78+
"body": "Hello from Lambda!",
79+
"isBase64Encoded": true
80+
}

0 commit comments

Comments
 (0)