Skip to content

Commit 1695911

Browse files
committed
feat(event-handler): Add http ProxyEvent handler
1 parent 7c9a319 commit 1695911

File tree

7 files changed

+217
-17
lines changed

7 files changed

+217
-17
lines changed

aws_lambda_powertools/utilities/data_classes/alb_event.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,6 @@ class ALBEvent(BaseProxyEvent):
2121
def request_context(self) -> ALBEventRequestContext:
2222
return ALBEventRequestContext(self._data)
2323

24-
@property
25-
def http_method(self) -> str:
26-
return self["httpMethod"]
27-
28-
@property
29-
def path(self) -> str:
30-
return self["path"]
31-
3224
@property
3325
def multi_value_query_string_parameters(self) -> Optional[Dict[str, List[str]]]:
3426
return self.get("multiValueQueryStringParameters")

aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -212,15 +212,6 @@ def version(self) -> str:
212212
def resource(self) -> str:
213213
return self["resource"]
214214

215-
@property
216-
def path(self) -> str:
217-
return self["path"]
218-
219-
@property
220-
def http_method(self) -> str:
221-
"""The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT."""
222-
return self["httpMethod"]
223-
224215
@property
225216
def multi_value_headers(self) -> Dict[str, List[str]]:
226217
return self["multiValueHeaders"]
@@ -441,3 +432,12 @@ def path_parameters(self) -> Optional[Dict[str, str]]:
441432
@property
442433
def stage_variables(self) -> Optional[Dict[str, str]]:
443434
return self.get("stageVariables")
435+
436+
@property
437+
def path(self) -> str:
438+
return self.raw_path
439+
440+
@property
441+
def http_method(self) -> str:
442+
"""The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT."""
443+
return self.request_context.http.method

aws_lambda_powertools/utilities/data_classes/common.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ def is_base64_encoded(self) -> Optional[bool]:
5959
def body(self) -> Optional[str]:
6060
return self.get("body")
6161

62+
@property
63+
def path(self) -> str:
64+
return self["path"]
65+
66+
@property
67+
def http_method(self) -> str:
68+
"""The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT."""
69+
return self["httpMethod"]
70+
6271
def get_query_string_value(self, name: str, default_value: Optional[str] = None) -> Optional[str]:
6372
"""Get query string value by name
6473

aws_lambda_powertools/utilities/event_handler/__init__.py

Whitespace-only changes.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from enum import Enum
2+
from typing import Any, Callable, Dict, List, Tuple, Union
3+
4+
from aws_lambda_powertools.utilities.data_classes import ALBEvent, APIGatewayProxyEvent, APIGatewayProxyEventV2
5+
from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent
6+
from aws_lambda_powertools.utilities.typing import LambdaContext
7+
8+
9+
class ProxyEventType(Enum):
10+
http_api_v1 = "APIGatewayProxyEvent"
11+
http_api_v2 = "APIGatewayProxyEventV2"
12+
alb_event = "ALBEvent"
13+
14+
15+
class ApiGatewayResolver:
16+
def __init__(self, proxy_type: Enum = ProxyEventType.http_api_v1):
17+
self._proxy_type: Enum = proxy_type
18+
self._resolvers: List[Dict] = []
19+
20+
def get(self, uri: str, include_event: bool = False, include_context: bool = False, **kwargs):
21+
return self.route("GET", uri, include_event, include_context, **kwargs)
22+
23+
def post(self, uri: str, include_event: bool = False, include_context: bool = False, **kwargs):
24+
return self.route("POST", uri, include_event, include_context, **kwargs)
25+
26+
def put(self, uri: str, include_event: bool = False, include_context: bool = False, **kwargs):
27+
return self.route("PUT", uri, include_event, include_context, **kwargs)
28+
29+
def delete(self, uri: str, include_event: bool = False, include_context: bool = False, **kwargs):
30+
return self.route("DELETE", uri, include_event, include_context, **kwargs)
31+
32+
def route(
33+
self,
34+
method: str,
35+
uri: str,
36+
include_event: bool = False,
37+
include_context: bool = False,
38+
**kwargs,
39+
):
40+
def register_resolver(func: Callable[[Any, Any], Tuple[int, str, str]]):
41+
self._register(func, method.upper(), uri, include_event, include_context, kwargs)
42+
return func
43+
44+
return register_resolver
45+
46+
def resolve(self, event: Dict, context: LambdaContext) -> Dict:
47+
proxy_event: BaseProxyEvent = self._as_proxy_event(event)
48+
resolver: Callable[[Any], Tuple[int, str, str]]
49+
config: Dict
50+
resolver, config = self._find_resolver(proxy_event.http_method.upper(), proxy_event.path)
51+
kwargs = self._kwargs(proxy_event, context, config)
52+
result = resolver(**kwargs)
53+
return {"statusCode": result[0], "headers": {"Content-Type": result[1]}, "body": result[2]}
54+
55+
def _register(
56+
self,
57+
func: Callable[[Any, Any], Tuple[int, str, str]],
58+
http_method: str,
59+
uri_starts_with: str,
60+
include_event: bool,
61+
include_context: bool,
62+
kwargs: Dict,
63+
):
64+
kwargs["include_event"] = include_event
65+
kwargs["include_context"] = include_context
66+
self._resolvers.append(
67+
{
68+
"http_method": http_method,
69+
"uri_starts_with": uri_starts_with,
70+
"func": func,
71+
"config": kwargs,
72+
}
73+
)
74+
75+
def _as_proxy_event(self, event: Dict) -> Union[ALBEvent, APIGatewayProxyEvent, APIGatewayProxyEventV2]:
76+
if self._proxy_type == ProxyEventType.http_api_v1:
77+
return APIGatewayProxyEvent(event)
78+
if self._proxy_type == ProxyEventType.http_api_v2:
79+
return APIGatewayProxyEventV2(event)
80+
return ALBEvent(event)
81+
82+
def _find_resolver(self, http_method: str, path: str) -> Tuple[Callable, Dict]:
83+
for resolver in self._resolvers:
84+
expected_method = resolver["http_method"]
85+
if http_method != expected_method:
86+
continue
87+
path_starts_with = resolver["uri_starts_with"]
88+
if path.startswith(path_starts_with):
89+
return resolver["func"], resolver["config"]
90+
91+
raise ValueError(f"No resolver found for '{http_method}.{path}'")
92+
93+
@staticmethod
94+
def _kwargs(event: BaseProxyEvent, context: LambdaContext, config: Dict) -> Dict[str, Any]:
95+
kwargs: Dict[str, Any] = {}
96+
if config.get("include_event", False):
97+
kwargs["event"] = event
98+
if config.get("include_context", False):
99+
kwargs["context"] = context
100+
return kwargs
101+
102+
def __call__(self, event, context) -> Any:
103+
return self.resolve(event, context)

tests/functional/event_handler/__init__.py

Whitespace-only changes.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import json
2+
import os
3+
4+
import pytest
5+
6+
from aws_lambda_powertools.utilities.data_classes import ALBEvent, APIGatewayProxyEvent, APIGatewayProxyEventV2
7+
from aws_lambda_powertools.utilities.event_handler.api_gateway import ApiGatewayResolver, ProxyEventType
8+
from aws_lambda_powertools.utilities.typing import LambdaContext
9+
10+
11+
def load_event(file_name: str) -> dict:
12+
full_file_name = os.path.dirname(os.path.realpath(__file__)) + "/../../events/" + file_name
13+
with open(full_file_name) as fp:
14+
return json.load(fp)
15+
16+
17+
def test_alb_event():
18+
app = ApiGatewayResolver(proxy_type=ProxyEventType.alb_event)
19+
20+
@app.get("/lambda", include_event=True, include_context=True)
21+
def foo(event: ALBEvent, context: LambdaContext):
22+
assert isinstance(event, ALBEvent)
23+
assert context == {}
24+
return 200, "text/html", "foo"
25+
26+
result = app(load_event("albEvent.json"), {})
27+
28+
assert result["statusCode"] == 200
29+
assert result["headers"]["Content-Type"] == "text/html"
30+
assert result["body"] == "foo"
31+
32+
33+
def test_api_gateway_v1():
34+
app = ApiGatewayResolver(proxy_type=ProxyEventType.http_api_v1)
35+
36+
@app.get("/my/path", include_event=True, include_context=True)
37+
def get_lambda(event: APIGatewayProxyEvent, context: LambdaContext):
38+
assert isinstance(event, APIGatewayProxyEvent)
39+
assert context == {}
40+
return 200, "application/json", json.dumps({"foo": "value"})
41+
42+
result = app(load_event("apiGatewayProxyEvent.json"), {})
43+
44+
assert result["statusCode"] == 200
45+
assert result["headers"]["Content-Type"] == "application/json"
46+
47+
48+
def test_include_event_false():
49+
app = ApiGatewayResolver()
50+
51+
@app.get("/my/path")
52+
def get_lambda():
53+
return 200, "plain/html", "foo"
54+
55+
result = app(load_event("apiGatewayProxyEvent.json"), {})
56+
57+
assert result["statusCode"] == 200
58+
assert result["body"] == "foo"
59+
60+
61+
def test_api_gateway_v2():
62+
app = ApiGatewayResolver(proxy_type=ProxyEventType.http_api_v2)
63+
64+
@app.post("/my/path", include_event=True)
65+
def my_path(event: APIGatewayProxyEventV2):
66+
assert isinstance(event, APIGatewayProxyEventV2)
67+
post_data = json.loads(event.body or "{}")
68+
return 200, "plain/text", post_data["username"]
69+
70+
result = app(load_event("apiGatewayProxyV2Event.json"), {})
71+
72+
assert result["statusCode"] == 200
73+
assert result["headers"]["Content-Type"] == "plain/text"
74+
assert result["body"] == "tom"
75+
76+
77+
def test_no_matching():
78+
app = ApiGatewayResolver()
79+
80+
@app.get("/not_matching_get")
81+
def no_get_matching():
82+
raise RuntimeError()
83+
84+
@app.put("/no_matching")
85+
def no_put_matching():
86+
raise RuntimeError()
87+
88+
@app.delete("/no_matching")
89+
def no_delete_matching():
90+
raise RuntimeError()
91+
92+
def handler(event, context):
93+
app.resolve(event, context)
94+
95+
with pytest.raises(ValueError):
96+
handler(load_event("apiGatewayProxyEvent.json"), None)

0 commit comments

Comments
 (0)