Skip to content

feat(parser): Support for API GW v1 proxy schema & envelope #403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 21, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .apigw import ApiGatewayEnvelope
from .base import BaseEnvelope
from .cloudwatch import CloudWatchLogsEnvelope
from .dynamodb import DynamoDBStreamEnvelope
Expand All @@ -7,6 +8,7 @@
from .sqs import SqsEnvelope

__all__ = [
"ApiGatewayEnvelope",
"CloudWatchLogsEnvelope",
"DynamoDBStreamEnvelope",
"EventBridgeEnvelope",
Expand Down
32 changes: 32 additions & 0 deletions aws_lambda_powertools/utilities/parser/envelopes/apigw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import logging
from typing import Any, Dict, Optional, Type, Union

from ..models import APIGatewayProxyEventModel
from ..types import Model
from .base import BaseEnvelope

logger = logging.getLogger(__name__)


class ApiGatewayEnvelope(BaseEnvelope):
"""Api Gateway envelope to extract data within detail key"""

def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) -> Optional[Model]:
"""Parses data found with model provided

Parameters
----------
data : Dict
Lambda event to be parsed
model : Type[Model]
Data model provided to parse after extracting data using envelope

Returns
-------
Any
Parsed detail payload with model provided
"""
logger.debug(f"Parsing incoming data with Api Gateway model {APIGatewayProxyEventModel}")
parsed_envelope = APIGatewayProxyEventModel.parse_obj(data)
logger.debug(f"Parsing event payload in `detail` with {model}")
return self._parse(data=parsed_envelope.body, model=model)
10 changes: 10 additions & 0 deletions aws_lambda_powertools/utilities/parser/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
from .alb import AlbModel, AlbRequestContext, AlbRequestContextData
from .apigw import (
APIGatewayEventAuthorizer,
APIGatewayEventIdentity,
APIGatewayEventRequestContext,
APIGatewayProxyEventModel,
)
from .cloudwatch import CloudWatchLogsData, CloudWatchLogsDecode, CloudWatchLogsLogEvent, CloudWatchLogsModel
from .dynamodb import DynamoDBStreamChangedRecordModel, DynamoDBStreamModel, DynamoDBStreamRecordModel
from .event_bridge import EventBridgeModel
Expand Down Expand Up @@ -70,4 +76,8 @@
"SqsRecordModel",
"SqsMsgAttributeModel",
"SqsAttributesModel",
"APIGatewayProxyEventModel",
"APIGatewayEventRequestContext",
"APIGatewayEventAuthorizer",
"APIGatewayEventIdentity",
]
109 changes: 109 additions & 0 deletions aws_lambda_powertools/utilities/parser/models/apigw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from datetime import datetime
from typing import Any, Dict, List, Optional

from pydantic import BaseModel, root_validator
from pydantic.networks import IPvAnyNetwork

from ..types import Literal


class ApiGatewayUserCertValidity(BaseModel):
notBefore: str
notAfter: str


class ApiGatewayUserCert(BaseModel):
clientCertPem: str
subjectDN: str
issuerDN: str
serialNumber: str
validity: ApiGatewayUserCertValidity


class APIGatewayEventIdentity(BaseModel):
accessKey: Optional[str]
accountId: Optional[str]
apiKey: Optional[str]
apiKeyId: Optional[str]
caller: Optional[str]
cognitoAuthenticationProvider: Optional[str]
cognitoAuthenticationType: Optional[str]
cognitoIdentityId: Optional[str]
cognitoIdentityPoolId: Optional[str]
principalOrgId: Optional[str]
sourceIp: IPvAnyNetwork
user: Optional[str]
userAgent: Optional[str]
userArn: Optional[str]
clientCert: Optional[ApiGatewayUserCert]


class APIGatewayEventAuthorizer(BaseModel):
claims: Optional[Dict[str, Any]]
scopes: Optional[List[str]]


class APIGatewayEventRequestContext(BaseModel):
accountId: str
apiId: str
authorizer: APIGatewayEventAuthorizer
stage: str
protocol: str
identity: APIGatewayEventIdentity
requestId: str
requestTime: str
requestTimeEpoch: datetime
resourceId: Optional[str]
resourcePath: str
domainName: Optional[str]
domainPrefix: Optional[str]
extendedRequestId: Optional[str]
httpMethod: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
path: str
connectedAt: Optional[datetime]
connectionId: Optional[str]
eventType: Optional[Literal["CONNECT", "MESSAGE", "DISCONNECT"]]
eventType: Optional[str]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this perhaps a typo as it's defined twice?

That's for WebSockets and the line 65 is correct

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like a bad copy paste :)
removed

messageDirection: Optional[str]
messageId: Optional[str]
routeKey: Optional[str]
operationName: Optional[str]


class APIGatewayProxyEventModel(BaseModel):
version: str
resource: str
path: str
httpMethod: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
headers: Dict[str, str]
multiValueHeaders: Dict[str, List[str]]
queryStringParameters: Optional[Dict[str, str]]
multiValueQueryStringParameters: Optional[Dict[str, List[str]]]
requestContext: APIGatewayEventRequestContext
pathParameters: Optional[Dict[str, str]]
stageVariables: Optional[Dict[str, str]]
isBase64Encoded: bool
body: str

@root_validator()
def check_message_id(cls, values):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add a note why this check is needed? e.g. spoofing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would you like to write here? basically what i saw that in the v1 version there's a duplication 3 fields, so i made sure that all values are actually the same. V2 removes this duplication

message_id, event_type = values.get("messageId"), values.get("eventType")
if message_id is not None and event_type != "MESSAGE":
raise TypeError("messageId is available only when the `eventType` is `MESSAGE`")
return values

@root_validator(pre=True)
def check_both_http_methods(cls, values):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add a note why this check is needed?

Copy link
Contributor Author

@ran-isenberg ran-isenberg Apr 19, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would you like to write here? basically what i saw that in the v1 version there's a duplication 3 fields, so i made sure that all values are actually the same. V2 removes this duplication

http_method, req_ctx_http_method = values.get("httpMethod"), values.get("requestContext", {}).get(
"httpMethod", ""
)
if http_method != req_ctx_http_method:
raise TypeError("httpMethods and requestContext.httpMethod must be equal")
return values

@root_validator(pre=True)
def check_both_paths(cls, values):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add a note why this check is needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would you like to write here? basically what i saw that in the v1 version there's a duplication 3 fields, so i made sure that all values are actually the same. V2 removes this duplication

path, req_ctx_path = values.get("path"), values.get("requestContext", {}).get("path", "")
if path != req_ctx_path:
raise TypeError("path and requestContext.path must be equal")
return values
21 changes: 11 additions & 10 deletions docs/utilities/parser.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ Parser comes with the following built-in models:
| **KinesisDataStreamModel** | Lambda Event Source payload for Amazon Kinesis Data Streams |
| **SesModel** | Lambda Event Source payload for Amazon Simple Email Service |
| **SnsModel** | Lambda Event Source payload for Amazon Simple Notification Service |
| **APIGatewayProxyEvent** | Lambda Event Source payload for Amazon API Gateway |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API Gateway v1, v2, or both? e.g. REST vs HTTP API. By my first look I'd guess REST API

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

V1


### extending built-in models

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

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

| Envelope name | Behaviour | Return |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| **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]]]` |
| **EventBridgeEnvelope** | 1. Parses data using `EventBridgeModel`. <br/> 2. Parses `detail` key using your model and returns it. | `Model` |
| **SqsEnvelope** | 1. Parses data using `SqsModel`. <br/> 2. Parses records in `body` key using your model and return them in a list. | `List[Model]` |
| **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]` |
| **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]` |
| **SnsEnvelope** | 1. Parses data using `SnsModel`. <br/> 2. Parses records in `body` key using your model and return them in a list. | `List[Model]` |
| **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]` |

| Envelope name | Behaviour | Return |
| -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| **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]]]` |
| **EventBridgeEnvelope** | 1. Parses data using `EventBridgeModel`. <br/> 2. Parses `detail` key using your model and returns it. | `Model` |
| **SqsEnvelope** | 1. Parses data using `SqsModel`. <br/> 2. Parses records in `body` key using your model and return them in a list. | `List[Model]` |
| **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]` |
| **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]` |
| **SnsEnvelope** | 1. Parses data using `SnsModel`. <br/> 2. Parses records in `body` key using your model and return them in a list. | `List[Model]` |
| **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]` |
| **ApiGatewayEnvelope** 1. Parses data using `APIGatewayProxyEventModel`. <br/> 2. Parses `body` key using your model and returns it. | `Model` |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API Gateway v1, v2, or both? e.g. REST vs HTTP API. By my first look I'd guess REST API

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

V1

### bringing your own envelope

You can create your own Envelope model and logic by inheriting from `BaseEnvelope`, and implementing the `parse` method.
Expand Down
16 changes: 13 additions & 3 deletions tests/events/apiGatewayProxyEvent.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,20 @@
"cognitoIdentityId": null,
"cognitoIdentityPoolId": null,
"principalOrgId": null,
"sourceIp": "IP",
"sourceIp": "192.168.0.1/32",
"user": null,
"userAgent": "user-agent",
"userArn": null
"userArn": null,
"clientCert": {
"clientCertPem": "CERT_CONTENT",
"subjectDN": "www.example.com",
"issuerDN": "Example issuer",
"serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1",
"validity": {
"notBefore": "May 28 12:30:02 2019 GMT",
"notAfter": "Aug 5 09:36:04 2021 GMT"
}
}
},
"path": "/my/path",
"protocol": "HTTP/1.1",
Expand All @@ -67,4 +77,4 @@
"stageVariables": null,
"body": "Hello from Lambda!",
"isBase64Encoded": true
}
}
5 changes: 5 additions & 0 deletions tests/functional/parser/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,8 @@ class MyKinesisBusiness(BaseModel):
class MyCloudWatchBusiness(BaseModel):
my_message: str
user: str


class MyApiGatewayBusiness(BaseModel):
message: str
username: str
102 changes: 102 additions & 0 deletions tests/functional/parser/test_apigw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from aws_lambda_powertools.utilities.parser import envelopes, event_parser
from aws_lambda_powertools.utilities.parser.models import APIGatewayProxyEventModel
from aws_lambda_powertools.utilities.typing import LambdaContext
from tests.functional.parser.schemas import MyApiGatewayBusiness
from tests.functional.parser.utils import load_event


@event_parser(model=MyApiGatewayBusiness, envelope=envelopes.ApiGatewayEnvelope)
def handle_apigw_with_envelope(event: MyApiGatewayBusiness, _: LambdaContext):
assert event.message == "Hello"
assert event.username == "Ran"


@event_parser(model=APIGatewayProxyEventModel)
def handle_apigw_event(event: APIGatewayProxyEventModel, _: LambdaContext):
assert event.body == "Hello from Lambda!"
return event


def test_apigw_event_with_envelope():
event = load_event("apiGatewayProxyEvent.json")
event["body"] = '{"message": "Hello", "username": "Ran"}'
handle_apigw_with_envelope(event, LambdaContext())


def test_apigw_event():
event = load_event("apiGatewayProxyEvent.json")
parsed_event: APIGatewayProxyEventModel = handle_apigw_event(event, LambdaContext())
assert parsed_event.version == event["version"]
assert parsed_event.resource == event["resource"]
assert parsed_event.path == event["path"]
assert parsed_event.headers == event["headers"]
assert parsed_event.multiValueHeaders == event["multiValueHeaders"]
assert parsed_event.queryStringParameters == event["queryStringParameters"]
assert parsed_event.multiValueQueryStringParameters == event["multiValueQueryStringParameters"]

request_context = parsed_event.requestContext
assert request_context.accountId == event["requestContext"]["accountId"]
assert request_context.apiId == event["requestContext"]["apiId"]

authorizer = request_context.authorizer
assert authorizer.claims is None
assert authorizer.scopes is None

assert request_context.domainName == event["requestContext"]["domainName"]
assert request_context.domainPrefix == event["requestContext"]["domainPrefix"]
assert request_context.extendedRequestId == event["requestContext"]["extendedRequestId"]
assert request_context.httpMethod == event["requestContext"]["httpMethod"]

identity = request_context.identity
assert identity.accessKey == event["requestContext"]["identity"]["accessKey"]
assert identity.accountId == event["requestContext"]["identity"]["accountId"]
assert identity.caller == event["requestContext"]["identity"]["caller"]
assert (
identity.cognitoAuthenticationProvider == event["requestContext"]["identity"]["cognitoAuthenticationProvider"]
)
assert identity.cognitoAuthenticationType == event["requestContext"]["identity"]["cognitoAuthenticationType"]
assert identity.cognitoIdentityId == event["requestContext"]["identity"]["cognitoIdentityId"]
assert identity.cognitoIdentityPoolId == event["requestContext"]["identity"]["cognitoIdentityPoolId"]
assert identity.principalOrgId == event["requestContext"]["identity"]["principalOrgId"]
assert str(identity.sourceIp) == event["requestContext"]["identity"]["sourceIp"]
assert identity.user == event["requestContext"]["identity"]["user"]
assert identity.userAgent == event["requestContext"]["identity"]["userAgent"]
assert identity.userArn == event["requestContext"]["identity"]["userArn"]
assert identity.clientCert is not None
assert identity.clientCert.clientCertPem == event["requestContext"]["identity"]["clientCert"]["clientCertPem"]
assert identity.clientCert.subjectDN == event["requestContext"]["identity"]["clientCert"]["subjectDN"]
assert identity.clientCert.issuerDN == event["requestContext"]["identity"]["clientCert"]["issuerDN"]
assert identity.clientCert.serialNumber == event["requestContext"]["identity"]["clientCert"]["serialNumber"]
assert (
identity.clientCert.validity.notBefore
== event["requestContext"]["identity"]["clientCert"]["validity"]["notBefore"]
)
assert (
identity.clientCert.validity.notAfter
== event["requestContext"]["identity"]["clientCert"]["validity"]["notAfter"]
)

assert request_context.path == event["requestContext"]["path"]
assert request_context.protocol == event["requestContext"]["protocol"]
assert request_context.requestId == event["requestContext"]["requestId"]
assert request_context.requestTime == event["requestContext"]["requestTime"]
convert_time = int(round(request_context.requestTimeEpoch.timestamp() * 1000))
assert convert_time == 1583349317135
assert request_context.resourceId == event["requestContext"]["resourceId"]
assert request_context.resourcePath == event["requestContext"]["resourcePath"]
assert request_context.stage == event["requestContext"]["stage"]

assert parsed_event.pathParameters == event["pathParameters"]
assert parsed_event.stageVariables == event["stageVariables"]
assert parsed_event.body == event["body"]
assert parsed_event.isBase64Encoded == event["isBase64Encoded"]

assert request_context.connectedAt is None
assert request_context.connectionId is None
assert request_context.eventType is None
assert request_context.messageDirection is None
assert request_context.messageId is None
assert request_context.routeKey is None
assert request_context.operationName is None
assert identity.apiKey is None
assert identity.apiKeyId is None