Skip to content

Commit 995c56e

Browse files
feat(parser): add support for API Gateway HTTP API #434 (#441)
Co-authored-by: Heitor Lessa <[email protected]> Co-authored-by: heitorlessa <[email protected]>
1 parent bfb67e7 commit 995c56e

File tree

10 files changed

+235
-19
lines changed

10 files changed

+235
-19
lines changed

Diff for: aws_lambda_powertools/utilities/parser/envelopes/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .apigw import ApiGatewayEnvelope
2+
from .apigwv2 import ApiGatewayV2Envelope
23
from .base import BaseEnvelope
34
from .cloudwatch import CloudWatchLogsEnvelope
45
from .dynamodb import DynamoDBStreamEnvelope
@@ -9,6 +10,7 @@
910

1011
__all__ = [
1112
"ApiGatewayEnvelope",
13+
"ApiGatewayV2Envelope",
1214
"CloudWatchLogsEnvelope",
1315
"DynamoDBStreamEnvelope",
1416
"EventBridgeEnvelope",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import logging
2+
from typing import Any, Dict, Optional, Type, Union
3+
4+
from ..models import APIGatewayProxyEventV2Model
5+
from ..types import Model
6+
from .base import BaseEnvelope
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class ApiGatewayV2Envelope(BaseEnvelope):
12+
"""API Gateway V2 envelope to extract data within body key"""
13+
14+
def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) -> Optional[Model]:
15+
"""Parses data found with model provided
16+
17+
Parameters
18+
----------
19+
data : Dict
20+
Lambda event to be parsed
21+
model : Type[Model]
22+
Data model provided to parse after extracting data using envelope
23+
24+
Returns
25+
-------
26+
Any
27+
Parsed detail payload with model provided
28+
"""
29+
logger.debug(f"Parsing incoming data with Api Gateway model V2 {APIGatewayProxyEventV2Model}")
30+
parsed_envelope = APIGatewayProxyEventV2Model.parse_obj(data)
31+
logger.debug(f"Parsing event payload in `detail` with {model}")
32+
return self._parse(data=parsed_envelope.body, model=model)

Diff for: aws_lambda_powertools/utilities/parser/models/__init__.py

+16
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
APIGatewayEventRequestContext,
66
APIGatewayProxyEventModel,
77
)
8+
from .apigwv2 import (
9+
APIGatewayProxyEventV2Model,
10+
RequestContextV2,
11+
RequestContextV2Authorizer,
12+
RequestContextV2AuthorizerIam,
13+
RequestContextV2AuthorizerIamCognito,
14+
RequestContextV2AuthorizerJwt,
15+
RequestContextV2Http,
16+
)
817
from .cloudwatch import CloudWatchLogsData, CloudWatchLogsDecode, CloudWatchLogsLogEvent, CloudWatchLogsModel
918
from .dynamodb import DynamoDBStreamChangedRecordModel, DynamoDBStreamModel, DynamoDBStreamRecordModel
1019
from .event_bridge import EventBridgeModel
@@ -35,6 +44,13 @@
3544
from .sqs import SqsAttributesModel, SqsModel, SqsMsgAttributeModel, SqsRecordModel
3645

3746
__all__ = [
47+
"APIGatewayProxyEventV2Model",
48+
"RequestContextV2",
49+
"RequestContextV2Http",
50+
"RequestContextV2Authorizer",
51+
"RequestContextV2AuthorizerJwt",
52+
"RequestContextV2AuthorizerIam",
53+
"RequestContextV2AuthorizerIamCognito",
3854
"CloudWatchLogsData",
3955
"CloudWatchLogsDecode",
4056
"CloudWatchLogsLogEvent",
+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from datetime import datetime
2+
from typing import Any, Dict, List, Optional
3+
4+
from pydantic import BaseModel, Field
5+
from pydantic.networks import IPvAnyNetwork
6+
7+
from ..types import Literal
8+
9+
10+
class RequestContextV2AuthorizerIamCognito(BaseModel):
11+
amr: List[str]
12+
identityId: str
13+
identityPoolId: str
14+
15+
16+
class RequestContextV2AuthorizerIam(BaseModel):
17+
accessKey: Optional[str]
18+
accountId: Optional[str]
19+
callerId: Optional[str]
20+
principalOrgId: Optional[str]
21+
userArn: Optional[str]
22+
userId: Optional[str]
23+
cognitoIdentity: RequestContextV2AuthorizerIamCognito
24+
25+
26+
class RequestContextV2AuthorizerJwt(BaseModel):
27+
claims: Dict[str, Any]
28+
scopes: List[str]
29+
30+
31+
class RequestContextV2Authorizer(BaseModel):
32+
jwt: Optional[RequestContextV2AuthorizerJwt]
33+
iam: Optional[RequestContextV2AuthorizerIam]
34+
lambda_value: Optional[Dict[str, Any]] = Field(None, alias="lambda")
35+
36+
37+
class RequestContextV2Http(BaseModel):
38+
method: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
39+
path: str
40+
protocol: str
41+
sourceIp: IPvAnyNetwork
42+
userAgent: str
43+
44+
45+
class RequestContextV2(BaseModel):
46+
accountId: str
47+
apiId: str
48+
authorizer: Optional[RequestContextV2Authorizer]
49+
domainName: str
50+
domainPrefix: str
51+
requestId: str
52+
routeKey: str
53+
stage: str
54+
time: str
55+
timeEpoch: datetime
56+
http: RequestContextV2Http
57+
58+
59+
class APIGatewayProxyEventV2Model(BaseModel):
60+
version: str
61+
routeKey: str
62+
rawPath: str
63+
rawQueryString: str
64+
cookies: Optional[List[str]]
65+
headers: Dict[str, str]
66+
queryStringParameters: Dict[str, str]
67+
pathParameters: Optional[Dict[str, str]]
68+
stageVariables: Optional[Dict[str, str]]
69+
requestContext: RequestContextV2
70+
body: str
71+
isBase64Encoded: bool

Diff for: docs/utilities/parser.md

+12-11
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ Parser comes with the following built-in models:
162162
| **SesModel** | Lambda Event Source payload for Amazon Simple Email Service |
163163
| **SnsModel** | Lambda Event Source payload for Amazon Simple Notification Service |
164164
| **APIGatewayProxyEvent** | Lambda Event Source payload for Amazon API Gateway |
165+
| **APIGatewayProxyEventV2Model** | Lambda Event Source payload for Amazon API Gateway v2 payload |
165166

166167
### extending built-in models
167168

@@ -295,17 +296,17 @@ Here's an example of parsing a model found in an event coming from EventBridge,
295296

296297
Parser comes with the following built-in envelopes, where `Model` in the return section is your given model.
297298

298-
| Envelope name | Behaviour | Return |
299-
| -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
300-
| **DynamoDBStreamEnvelope** | 1. Parses data using `DynamoDBStreamModel`. <br/> 2. Parses records in `NewImage` and `OldImage` keys using your model. <br/> 3. Returns a list with a dictionary containing `NewImage` and `OldImage` keys | `List[Dict[str, Optional[Model]]]` |
301-
| **EventBridgeEnvelope** | 1. Parses data using `EventBridgeModel`. <br/> 2. Parses `detail` key using your model and returns it. | `Model` |
302-
| **SqsEnvelope** | 1. Parses data using `SqsModel`. <br/> 2. Parses records in `body` key using your model and return them in a list. | `List[Model]` |
303-
| **CloudWatchLogsEnvelope** | 1. Parses data using `CloudwatchLogsModel` which will base64 decode and decompress it. <br/> 2. Parses records in `message` key using your model and return them in a list. | `List[Model]` |
304-
| **KinesisDataStreamEnvelope** | 1. Parses data using `KinesisDataStreamModel` which will base64 decode it. <br/> 2. Parses records in in `Records` key using your model and returns them in a list. | `List[Model]` |
305-
| **SnsEnvelope** | 1. Parses data using `SnsModel`. <br/> 2. Parses records in `body` key using your model and return them in a list. | `List[Model]` |
306-
| **SnsSqsEnvelope** | 1. Parses data using `SqsModel`. <br/> 2. Parses SNS records in `body` key using `SnsNotificationModel`. <br/> 3. Parses data in `Message` key using your model and return them in a list. | `List[Model]` |
307-
| **ApiGatewayEnvelope** | 1. Parses data using `APIGatewayProxyEventModel`. <br/> 2. Parses `body` key using your model and returns it. | `Model` |
308-
299+
| Envelope name | Behaviour | Return |
300+
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
301+
| **DynamoDBStreamEnvelope** | 1. Parses data using `DynamoDBStreamModel`. <br/> 2. Parses records in `NewImage` and `OldImage` keys using your model. <br/> 3. Returns a list with a dictionary containing `NewImage` and `OldImage` keys | `List[Dict[str, Optional[Model]]]` |
302+
| **EventBridgeEnvelope** | 1. Parses data using `EventBridgeModel`. <br/> 2. Parses `detail` key using your model and returns it. | `Model` |
303+
| **SqsEnvelope** | 1. Parses data using `SqsModel`. <br/> 2. Parses records in `body` key using your model and return them in a list. | `List[Model]` |
304+
| **CloudWatchLogsEnvelope** | 1. Parses data using `CloudwatchLogsModel` which will base64 decode and decompress it. <br/> 2. Parses records in `message` key using your model and return them in a list. | `List[Model]` |
305+
| **KinesisDataStreamEnvelope** | 1. Parses data using `KinesisDataStreamModel` which will base64 decode it. <br/> 2. Parses records in in `Records` key using your model and returns them in a list. | `List[Model]` |
306+
| **SnsEnvelope** | 1. Parses data using `SnsModel`. <br/> 2. Parses records in `body` key using your model and return them in a list. | `List[Model]` |
307+
| **SnsSqsEnvelope** | 1. Parses data using `SqsModel`. <br/> 2. Parses SNS records in `body` key using `SnsNotificationModel`. <br/> 3. Parses data in `Message` key using your model and return them in a list. | `List[Model]` |
308+
| **ApiGatewayEnvelope** | 1. Parses data using `APIGatewayProxyEventModel`. <br/> 2. Parses `body` key using your model and returns it. | `Model` |
309+
| **ApiGatewayV2Envelope** | 1. Parses data using `APIGatewayProxyEventV2Model`. <br/> 2. Parses `body` key using your model and returns it. | `Model` |
309310
### Bringing your own envelope
310311

311312
You can create your own Envelope model and logic by inheriting from `BaseEnvelope`, and implementing the `parse` method.

Diff for: tests/events/apiGatewayProxyV2Event.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"method": "POST",
3737
"path": "/my/path",
3838
"protocol": "HTTP/1.1",
39-
"sourceIp": "IP",
39+
"sourceIp": "192.168.0.1/32",
4040
"userAgent": "agent"
4141
},
4242
"requestId": "id",
@@ -54,4 +54,4 @@
5454
"stageVariable1": "value1",
5555
"stageVariable2": "value2"
5656
}
57-
}
57+
}

Diff for: tests/events/apiGatewayProxyV2IamEvent.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
"accountId": "1234567890",
3030
"callerId": "AROA7ZJZYVRE7C3DUXHH6:CognitoIdentityCredentials",
3131
"cognitoIdentity": {
32-
"amr" : ["foo"],
32+
"amr": [
33+
"foo"
34+
],
3335
"identityId": "us-east-1:3f291106-8703-466b-8f2b-3ecee1ca56ce",
3436
"identityPoolId": "us-east-1:4f291106-8703-466b-8f2b-3ecee1ca56ce"
3537
},
@@ -47,7 +49,7 @@
4749
"method": "GET",
4850
"path": "/my/path",
4951
"protocol": "HTTP/1.1",
50-
"sourceIp": "IP",
52+
"sourceIp": "192.168.0.1/32",
5153
"userAgent": "agent"
5254
}
5355
},
@@ -57,4 +59,4 @@
5759
},
5860
"body": "{\r\n\t\"a\": 1\r\n}",
5961
"isBase64Encoded": false
60-
}
62+
}

Diff for: tests/events/apiGatewayProxyV2LambdaAuthorizerEvent.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"method": "GET",
3838
"path": "/my/path",
3939
"protocol": "HTTP/1.1",
40-
"sourceIp": "IP",
40+
"sourceIp": "192.168.0.1/32",
4141
"userAgent": "agent"
4242
}
4343
},
@@ -47,4 +47,4 @@
4747
},
4848
"body": "{\r\n\t\"a\": 1\r\n}",
4949
"isBase64Encoded": false
50-
}
50+
}

Diff for: tests/functional/parser/test_apigwv2.py

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from aws_lambda_powertools.utilities.parser import envelopes, event_parser
2+
from aws_lambda_powertools.utilities.parser.models import (
3+
APIGatewayProxyEventV2Model,
4+
RequestContextV2,
5+
RequestContextV2Authorizer,
6+
)
7+
from aws_lambda_powertools.utilities.typing import LambdaContext
8+
from tests.functional.parser.schemas import MyApiGatewayBusiness
9+
from tests.functional.utils import load_event
10+
11+
12+
@event_parser(model=MyApiGatewayBusiness, envelope=envelopes.ApiGatewayV2Envelope)
13+
def handle_apigw_with_envelope(event: MyApiGatewayBusiness, _: LambdaContext):
14+
assert event.message == "Hello"
15+
assert event.username == "Ran"
16+
17+
18+
@event_parser(model=APIGatewayProxyEventV2Model)
19+
def handle_apigw_event(event: APIGatewayProxyEventV2Model, _: LambdaContext):
20+
return event
21+
22+
23+
def test_apigw_v2_event_with_envelope():
24+
event = load_event("apiGatewayProxyV2Event.json")
25+
event["body"] = '{"message": "Hello", "username": "Ran"}'
26+
handle_apigw_with_envelope(event, LambdaContext())
27+
28+
29+
def test_apigw_v2_event_jwt_authorizer():
30+
event = load_event("apiGatewayProxyV2Event.json")
31+
parsed_event: APIGatewayProxyEventV2Model = handle_apigw_event(event, LambdaContext())
32+
assert parsed_event.version == event["version"]
33+
assert parsed_event.routeKey == event["routeKey"]
34+
assert parsed_event.rawPath == event["rawPath"]
35+
assert parsed_event.rawQueryString == event["rawQueryString"]
36+
assert parsed_event.cookies == event["cookies"]
37+
assert parsed_event.cookies[0] == "cookie1"
38+
assert parsed_event.headers == event["headers"]
39+
assert parsed_event.queryStringParameters == event["queryStringParameters"]
40+
assert parsed_event.queryStringParameters["parameter2"] == "value"
41+
42+
request_context = parsed_event.requestContext
43+
assert request_context.accountId == event["requestContext"]["accountId"]
44+
assert request_context.apiId == event["requestContext"]["apiId"]
45+
assert request_context.authorizer.jwt.claims == event["requestContext"]["authorizer"]["jwt"]["claims"]
46+
assert request_context.authorizer.jwt.scopes == event["requestContext"]["authorizer"]["jwt"]["scopes"]
47+
assert request_context.domainName == event["requestContext"]["domainName"]
48+
assert request_context.domainPrefix == event["requestContext"]["domainPrefix"]
49+
50+
http = request_context.http
51+
assert http.method == "POST"
52+
assert http.path == "/my/path"
53+
assert http.protocol == "HTTP/1.1"
54+
assert str(http.sourceIp) == "192.168.0.1/32"
55+
assert http.userAgent == "agent"
56+
57+
assert request_context.requestId == event["requestContext"]["requestId"]
58+
assert request_context.routeKey == event["requestContext"]["routeKey"]
59+
assert request_context.stage == event["requestContext"]["stage"]
60+
assert request_context.time == event["requestContext"]["time"]
61+
convert_time = int(round(request_context.timeEpoch.timestamp() * 1000))
62+
assert convert_time == event["requestContext"]["timeEpoch"]
63+
assert parsed_event.body == event["body"]
64+
assert parsed_event.pathParameters == event["pathParameters"]
65+
assert parsed_event.isBase64Encoded == event["isBase64Encoded"]
66+
assert parsed_event.stageVariables == event["stageVariables"]
67+
68+
69+
def test_api_gateway_proxy_v2_event_lambda_authorizer():
70+
event = load_event("apiGatewayProxyV2LambdaAuthorizerEvent.json")
71+
parsed_event: APIGatewayProxyEventV2Model = handle_apigw_event(event, LambdaContext())
72+
request_context: RequestContextV2 = parsed_event.requestContext
73+
assert request_context is not None
74+
lambda_props: RequestContextV2Authorizer = request_context.authorizer.lambda_value
75+
assert lambda_props is not None
76+
assert lambda_props["key"] == "value"
77+
78+
79+
def test_api_gateway_proxy_v2_event_iam_authorizer():
80+
event = load_event("apiGatewayProxyV2IamEvent.json")
81+
parsed_event: APIGatewayProxyEventV2Model = handle_apigw_event(event, LambdaContext())
82+
iam = parsed_event.requestContext.authorizer.iam
83+
assert iam is not None
84+
assert iam.accessKey == "ARIA2ZJZYVUEREEIHAKY"
85+
assert iam.accountId == "1234567890"
86+
assert iam.callerId == "AROA7ZJZYVRE7C3DUXHH6:CognitoIdentityCredentials"
87+
assert iam.cognitoIdentity.amr == ["foo"]
88+
assert iam.cognitoIdentity.identityId == "us-east-1:3f291106-8703-466b-8f2b-3ecee1ca56ce"
89+
assert iam.cognitoIdentity.identityPoolId == "us-east-1:4f291106-8703-466b-8f2b-3ecee1ca56ce"
90+
assert iam.principalOrgId == "AwsOrgId"
91+
assert iam.userArn == "arn:aws:iam::1234567890:user/Admin"
92+
assert iam.userId == "AROA2ZJZYVRE7Y3TUXHH6"

Diff for: tests/functional/test_data_classes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ def test_api_gateway_proxy_v2_event():
743743
assert http.method == "POST"
744744
assert http.path == "/my/path"
745745
assert http.protocol == "HTTP/1.1"
746-
assert http.source_ip == "IP"
746+
assert http.source_ip == "192.168.0.1/32"
747747
assert http.user_agent == "agent"
748748

749749
assert request_context.request_id == event["requestContext"]["requestId"]

0 commit comments

Comments
 (0)