Skip to content

Commit 462a0be

Browse files
author
Ran Isenberg
committed
feat (parser): Feature request: Add parser API GW v1 proxy schema & envelope #402
1 parent 9be19b2 commit 462a0be

File tree

8 files changed

+282
-13
lines changed

8 files changed

+282
-13
lines changed

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",
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 APIGatewayProxyEvent
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 detail 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 {APIGatewayProxyEvent}")
30+
parsed_envelope = APIGatewayProxyEvent.parse_obj(data)
31+
logger.debug(f"Parsing event payload in `detail` with {model}")
32+
return self._parse(data=parsed_envelope.body, model=model)

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
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from datetime import datetime
2+
from typing import Any, Dict, List, Literal, Optional
3+
4+
from pydantic import BaseModel, root_validator
5+
from pydantic.networks import IPvAnyNetwork
6+
7+
8+
class ApiGatewayUserCertValidity(BaseModel):
9+
notBefore: str
10+
notAfter: str
11+
12+
13+
class ApiGatewayUserCert(BaseModel):
14+
clientCertPem: str
15+
subjectDN: str
16+
issuerDN: str
17+
serialNumber: str
18+
validity: ApiGatewayUserCertValidity
19+
20+
21+
class APIGatewayEventIdentity(BaseModel):
22+
accessKey: Optional[str]
23+
accountId: Optional[str]
24+
apiKey: Optional[str]
25+
apiKeyId: Optional[str]
26+
caller: Optional[str]
27+
cognitoAuthenticationProvider: Optional[str]
28+
cognitoAuthenticationType: Optional[str]
29+
cognitoIdentityId: Optional[str]
30+
cognitoIdentityPoolId: Optional[str]
31+
principalOrgId: Optional[str]
32+
sourceIp: IPvAnyNetwork
33+
user: Optional[str]
34+
userAgent: Optional[str]
35+
userArn: Optional[str]
36+
clientCert: Optional[ApiGatewayUserCert]
37+
38+
39+
class APIGatewayEventAuthorizer(BaseModel):
40+
claims: Optional[Dict[str, Any]]
41+
scopes: Optional[List[str]]
42+
43+
44+
class APIGatewayEventRequestContext(BaseModel):
45+
accountId: str
46+
apiId: str
47+
authorizer: APIGatewayEventAuthorizer
48+
stage: str
49+
protocol: str
50+
identity: APIGatewayEventIdentity
51+
requestId: str
52+
requestTime: str
53+
requestTimeEpoch: datetime
54+
resourceId: Optional[str]
55+
resourcePath: str
56+
domainName: Optional[str]
57+
domainPrefix: Optional[str]
58+
extendedRequestId: Optional[str]
59+
httpMethod: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
60+
path: str
61+
connectedAt: Optional[datetime]
62+
connectionId: Optional[str]
63+
eventType: Optional[Literal["CONNECT", "MESSAGE", "DISCONNECT"]]
64+
eventType: Optional[str]
65+
messageDirection: Optional[str]
66+
messageId: Optional[str]
67+
routeKey: Optional[str]
68+
operationName: Optional[str]
69+
70+
71+
class APIGatewayProxyEventModel(BaseModel):
72+
version: str
73+
resource: str
74+
path: str
75+
httpMethod: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
76+
headers: Dict[str, str]
77+
multiValueHeaders: Dict[str, List[str]]
78+
queryStringParameters: Optional[Dict[str, str]]
79+
multiValueQueryStringParameters: Optional[Dict[str, List[str]]]
80+
requestContext: APIGatewayEventRequestContext
81+
pathParameters: Optional[Dict[str, str]]
82+
stageVariables: Optional[Dict[str, str]]
83+
isBase64Encoded: bool
84+
body: str
85+
86+
@root_validator()
87+
def check_message_id(cls, values):
88+
message_id, event_type = values.get("messageId"), values.get("eventType")
89+
if message_id is not None and event_type != "MESSAGE":
90+
raise TypeError("messageId is available only when the `eventType` is `MESSAGE`")
91+
return values
92+
93+
@root_validator(pre=True)
94+
def check_both_http_methods(cls, values):
95+
http_method, req_ctx_http_method = values.get("httpMethod"), values.get("requestContext", {}).get(
96+
"httpMethod", ""
97+
)
98+
if http_method != req_ctx_http_method:
99+
raise TypeError("httpMethods and requestContext.httpMethod must be equal")
100+
return values
101+
102+
@root_validator(pre=True)
103+
def check_both_paths(cls, values):
104+
path, req_ctx_path = values.get("path"), values.get("requestContext", {}).get("path", "")
105+
if path != req_ctx_path:
106+
raise TypeError("path and requestContext.path must be equal")
107+
return values

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 `APIGatewayProxyEvent`. <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.

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+
}

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

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)