Skip to content

Commit 68d4110

Browse files
feat(parser): Support for API GW v1 proxy schema & envelope (#403)
Co-authored-by: Heitor Lessa <[email protected]>
1 parent 5bce279 commit 68d4110

File tree

8 files changed

+267
-13
lines changed

8 files changed

+267
-13
lines changed

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

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from .apigw import ApiGatewayEnvelope
12
from .base import BaseEnvelope
23
from .cloudwatch import CloudWatchLogsEnvelope
34
from .dynamodb import DynamoDBStreamEnvelope
@@ -7,6 +8,7 @@
78
from .sqs import SqsEnvelope
89

910
__all__ = [
11+
"ApiGatewayEnvelope",
1012
"CloudWatchLogsEnvelope",
1113
"DynamoDBStreamEnvelope",
1214
"EventBridgeEnvelope",
+32
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 APIGatewayProxyEventModel
5+
from ..types import Model
6+
from .base import BaseEnvelope
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class ApiGatewayEnvelope(BaseEnvelope):
12+
"""API Gateway 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 {APIGatewayProxyEventModel}")
30+
parsed_envelope = APIGatewayProxyEventModel.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

+10
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
from .alb import AlbModel, AlbRequestContext, AlbRequestContextData
2+
from .apigw import (
3+
APIGatewayEventAuthorizer,
4+
APIGatewayEventIdentity,
5+
APIGatewayEventRequestContext,
6+
APIGatewayProxyEventModel,
7+
)
28
from .cloudwatch import CloudWatchLogsData, CloudWatchLogsDecode, CloudWatchLogsLogEvent, CloudWatchLogsModel
39
from .dynamodb import DynamoDBStreamChangedRecordModel, DynamoDBStreamModel, DynamoDBStreamRecordModel
410
from .event_bridge import EventBridgeModel
@@ -70,4 +76,8 @@
7076
"SqsRecordModel",
7177
"SqsMsgAttributeModel",
7278
"SqsAttributesModel",
79+
"APIGatewayProxyEventModel",
80+
"APIGatewayEventRequestContext",
81+
"APIGatewayEventAuthorizer",
82+
"APIGatewayEventIdentity",
7383
]
+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from datetime import datetime
2+
from typing import Any, Dict, List, Optional
3+
4+
from pydantic import BaseModel, root_validator
5+
from pydantic.networks import IPvAnyNetwork
6+
7+
from ..types import Literal
8+
9+
10+
class ApiGatewayUserCertValidity(BaseModel):
11+
notBefore: str
12+
notAfter: str
13+
14+
15+
class ApiGatewayUserCert(BaseModel):
16+
clientCertPem: str
17+
subjectDN: str
18+
issuerDN: str
19+
serialNumber: str
20+
validity: ApiGatewayUserCertValidity
21+
22+
23+
class APIGatewayEventIdentity(BaseModel):
24+
accessKey: Optional[str]
25+
accountId: Optional[str]
26+
apiKey: Optional[str]
27+
apiKeyId: Optional[str]
28+
caller: Optional[str]
29+
cognitoAuthenticationProvider: Optional[str]
30+
cognitoAuthenticationType: Optional[str]
31+
cognitoIdentityId: Optional[str]
32+
cognitoIdentityPoolId: Optional[str]
33+
principalOrgId: Optional[str]
34+
sourceIp: IPvAnyNetwork
35+
user: Optional[str]
36+
userAgent: Optional[str]
37+
userArn: Optional[str]
38+
clientCert: Optional[ApiGatewayUserCert]
39+
40+
41+
class APIGatewayEventAuthorizer(BaseModel):
42+
claims: Optional[Dict[str, Any]]
43+
scopes: Optional[List[str]]
44+
45+
46+
class APIGatewayEventRequestContext(BaseModel):
47+
accountId: str
48+
apiId: str
49+
authorizer: APIGatewayEventAuthorizer
50+
stage: str
51+
protocol: str
52+
identity: APIGatewayEventIdentity
53+
requestId: str
54+
requestTime: str
55+
requestTimeEpoch: datetime
56+
resourceId: Optional[str]
57+
resourcePath: str
58+
domainName: Optional[str]
59+
domainPrefix: Optional[str]
60+
extendedRequestId: Optional[str]
61+
httpMethod: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
62+
path: str
63+
connectedAt: Optional[datetime]
64+
connectionId: Optional[str]
65+
eventType: Optional[Literal["CONNECT", "MESSAGE", "DISCONNECT"]]
66+
messageDirection: Optional[str]
67+
messageId: Optional[str]
68+
routeKey: Optional[str]
69+
operationName: Optional[str]
70+
71+
72+
class APIGatewayProxyEventModel(BaseModel):
73+
version: str
74+
resource: str
75+
path: str
76+
httpMethod: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
77+
headers: Dict[str, str]
78+
multiValueHeaders: Dict[str, List[str]]
79+
queryStringParameters: Optional[Dict[str, str]]
80+
multiValueQueryStringParameters: Optional[Dict[str, List[str]]]
81+
requestContext: APIGatewayEventRequestContext
82+
pathParameters: Optional[Dict[str, str]]
83+
stageVariables: Optional[Dict[str, str]]
84+
isBase64Encoded: bool
85+
body: str
86+
87+
@root_validator()
88+
def check_message_id(cls, values):
89+
message_id, event_type = values.get("messageId"), values.get("eventType")
90+
if message_id is not None and event_type != "MESSAGE":
91+
raise TypeError("messageId is available only when the `eventType` is `MESSAGE`")
92+
return values

Diff for: docs/utilities/parser.md

+11-10
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ Parser comes with the following built-in models:
161161
| **KinesisDataStreamModel** | Lambda Event Source payload for Amazon Kinesis Data Streams |
162162
| **SesModel** | Lambda Event Source payload for Amazon Simple Email Service |
163163
| **SnsModel** | Lambda Event Source payload for Amazon Simple Notification Service |
164+
| **APIGatewayProxyEvent** | Lambda Event Source payload for Amazon API Gateway |
164165

165166
### extending built-in models
166167

@@ -294,16 +295,16 @@ Here's an example of parsing a model found in an event coming from EventBridge,
294295

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

297-
| Envelope name | Behaviour | Return |
298-
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
299-
| **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]]]` |
300-
| **EventBridgeEnvelope** | 1. Parses data using `EventBridgeModel`. <br/> 2. Parses `detail` key using your model and returns it. | `Model` |
301-
| **SqsEnvelope** | 1. Parses data using `SqsModel`. <br/> 2. Parses records in `body` key using your model and return them in a list. | `List[Model]` |
302-
| **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]` |
303-
| **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]` |
304-
| **SnsEnvelope** | 1. Parses data using `SnsModel`. <br/> 2. Parses records in `body` key using your model and return them in a list. | `List[Model]` |
305-
| **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]` |
306-
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` |
307308
### bringing your own envelope
308309

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

Diff for: tests/events/apiGatewayProxyEvent.json

+13-3
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,20 @@
4949
"cognitoIdentityId": null,
5050
"cognitoIdentityPoolId": null,
5151
"principalOrgId": null,
52-
"sourceIp": "IP",
52+
"sourceIp": "192.168.0.1/32",
5353
"user": null,
5454
"userAgent": "user-agent",
55-
"userArn": null
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+
}
5666
},
5767
"path": "/my/path",
5868
"protocol": "HTTP/1.1",
@@ -67,4 +77,4 @@
6777
"stageVariables": null,
6878
"body": "Hello from Lambda!",
6979
"isBase64Encoded": true
70-
}
80+
}

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

+5
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,8 @@ class MyKinesisBusiness(BaseModel):
8181
class MyCloudWatchBusiness(BaseModel):
8282
my_message: str
8383
user: str
84+
85+
86+
class MyApiGatewayBusiness(BaseModel):
87+
message: str
88+
username: str

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

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from aws_lambda_powertools.utilities.parser import envelopes, event_parser
2+
from aws_lambda_powertools.utilities.parser.models import APIGatewayProxyEventModel
3+
from aws_lambda_powertools.utilities.typing import LambdaContext
4+
from tests.functional.parser.schemas import MyApiGatewayBusiness
5+
from tests.functional.parser.utils import load_event
6+
7+
8+
@event_parser(model=MyApiGatewayBusiness, envelope=envelopes.ApiGatewayEnvelope)
9+
def handle_apigw_with_envelope(event: MyApiGatewayBusiness, _: LambdaContext):
10+
assert event.message == "Hello"
11+
assert event.username == "Ran"
12+
13+
14+
@event_parser(model=APIGatewayProxyEventModel)
15+
def handle_apigw_event(event: APIGatewayProxyEventModel, _: LambdaContext):
16+
assert event.body == "Hello from Lambda!"
17+
return event
18+
19+
20+
def test_apigw_event_with_envelope():
21+
event = load_event("apiGatewayProxyEvent.json")
22+
event["body"] = '{"message": "Hello", "username": "Ran"}'
23+
handle_apigw_with_envelope(event, LambdaContext())
24+
25+
26+
def test_apigw_event():
27+
event = load_event("apiGatewayProxyEvent.json")
28+
parsed_event: APIGatewayProxyEventModel = handle_apigw_event(event, LambdaContext())
29+
assert parsed_event.version == event["version"]
30+
assert parsed_event.resource == event["resource"]
31+
assert parsed_event.path == event["path"]
32+
assert parsed_event.headers == event["headers"]
33+
assert parsed_event.multiValueHeaders == event["multiValueHeaders"]
34+
assert parsed_event.queryStringParameters == event["queryStringParameters"]
35+
assert parsed_event.multiValueQueryStringParameters == event["multiValueQueryStringParameters"]
36+
37+
request_context = parsed_event.requestContext
38+
assert request_context.accountId == event["requestContext"]["accountId"]
39+
assert request_context.apiId == event["requestContext"]["apiId"]
40+
41+
authorizer = request_context.authorizer
42+
assert authorizer.claims is None
43+
assert authorizer.scopes is None
44+
45+
assert request_context.domainName == event["requestContext"]["domainName"]
46+
assert request_context.domainPrefix == event["requestContext"]["domainPrefix"]
47+
assert request_context.extendedRequestId == event["requestContext"]["extendedRequestId"]
48+
assert request_context.httpMethod == event["requestContext"]["httpMethod"]
49+
50+
identity = request_context.identity
51+
assert identity.accessKey == event["requestContext"]["identity"]["accessKey"]
52+
assert identity.accountId == event["requestContext"]["identity"]["accountId"]
53+
assert identity.caller == event["requestContext"]["identity"]["caller"]
54+
assert (
55+
identity.cognitoAuthenticationProvider == event["requestContext"]["identity"]["cognitoAuthenticationProvider"]
56+
)
57+
assert identity.cognitoAuthenticationType == event["requestContext"]["identity"]["cognitoAuthenticationType"]
58+
assert identity.cognitoIdentityId == event["requestContext"]["identity"]["cognitoIdentityId"]
59+
assert identity.cognitoIdentityPoolId == event["requestContext"]["identity"]["cognitoIdentityPoolId"]
60+
assert identity.principalOrgId == event["requestContext"]["identity"]["principalOrgId"]
61+
assert str(identity.sourceIp) == event["requestContext"]["identity"]["sourceIp"]
62+
assert identity.user == event["requestContext"]["identity"]["user"]
63+
assert identity.userAgent == event["requestContext"]["identity"]["userAgent"]
64+
assert identity.userArn == event["requestContext"]["identity"]["userArn"]
65+
assert identity.clientCert is not None
66+
assert identity.clientCert.clientCertPem == event["requestContext"]["identity"]["clientCert"]["clientCertPem"]
67+
assert identity.clientCert.subjectDN == event["requestContext"]["identity"]["clientCert"]["subjectDN"]
68+
assert identity.clientCert.issuerDN == event["requestContext"]["identity"]["clientCert"]["issuerDN"]
69+
assert identity.clientCert.serialNumber == event["requestContext"]["identity"]["clientCert"]["serialNumber"]
70+
assert (
71+
identity.clientCert.validity.notBefore
72+
== event["requestContext"]["identity"]["clientCert"]["validity"]["notBefore"]
73+
)
74+
assert (
75+
identity.clientCert.validity.notAfter
76+
== event["requestContext"]["identity"]["clientCert"]["validity"]["notAfter"]
77+
)
78+
79+
assert request_context.path == event["requestContext"]["path"]
80+
assert request_context.protocol == event["requestContext"]["protocol"]
81+
assert request_context.requestId == event["requestContext"]["requestId"]
82+
assert request_context.requestTime == event["requestContext"]["requestTime"]
83+
convert_time = int(round(request_context.requestTimeEpoch.timestamp() * 1000))
84+
assert convert_time == 1583349317135
85+
assert request_context.resourceId == event["requestContext"]["resourceId"]
86+
assert request_context.resourcePath == event["requestContext"]["resourcePath"]
87+
assert request_context.stage == event["requestContext"]["stage"]
88+
89+
assert parsed_event.pathParameters == event["pathParameters"]
90+
assert parsed_event.stageVariables == event["stageVariables"]
91+
assert parsed_event.body == event["body"]
92+
assert parsed_event.isBase64Encoded == event["isBase64Encoded"]
93+
94+
assert request_context.connectedAt is None
95+
assert request_context.connectionId is None
96+
assert request_context.eventType is None
97+
assert request_context.messageDirection is None
98+
assert request_context.messageId is None
99+
assert request_context.routeKey is None
100+
assert request_context.operationName is None
101+
assert identity.apiKey is None
102+
assert identity.apiKeyId is None

0 commit comments

Comments
 (0)