From d0ede31154d5f66fd3f459be1c4d9fdf9257443f Mon Sep 17 00:00:00 2001 From: Ran Isenberg Date: Mon, 24 May 2021 09:11:14 +0300 Subject: [PATCH 1/3] feat: Add APIGateway*EventModelV2 to parser #434 --- .../utilities/parser/envelopes/__init__.py | 2 + .../utilities/parser/envelopes/apigwv2.py | 32 +++++++ .../utilities/parser/models/__init__.py | 16 ++++ .../utilities/parser/models/apigwv2.py | 72 +++++++++++++++ docs/utilities/parser.md | 22 ++--- tests/events/apiGatewayProxyV2Event.json | 4 +- tests/events/apiGatewayProxyV2IamEvent.json | 8 +- ...piGatewayProxyV2LambdaAuthorizerEvent.json | 4 +- tests/functional/parser/test_apigwv2.py | 92 +++++++++++++++++++ tests/functional/test_data_classes.py | 2 +- 10 files changed, 235 insertions(+), 19 deletions(-) create mode 100644 aws_lambda_powertools/utilities/parser/envelopes/apigwv2.py create mode 100644 aws_lambda_powertools/utilities/parser/models/apigwv2.py create mode 100644 tests/functional/parser/test_apigwv2.py diff --git a/aws_lambda_powertools/utilities/parser/envelopes/__init__.py b/aws_lambda_powertools/utilities/parser/envelopes/__init__.py index e6f63c4792d..1b118d28117 100644 --- a/aws_lambda_powertools/utilities/parser/envelopes/__init__.py +++ b/aws_lambda_powertools/utilities/parser/envelopes/__init__.py @@ -1,4 +1,5 @@ from .apigw import ApiGatewayEnvelope +from .apigwv2 import ApiGatewayV2Envelope from .base import BaseEnvelope from .cloudwatch import CloudWatchLogsEnvelope from .dynamodb import DynamoDBStreamEnvelope @@ -9,6 +10,7 @@ __all__ = [ "ApiGatewayEnvelope", + "ApiGatewayV2Envelope", "CloudWatchLogsEnvelope", "DynamoDBStreamEnvelope", "EventBridgeEnvelope", diff --git a/aws_lambda_powertools/utilities/parser/envelopes/apigwv2.py b/aws_lambda_powertools/utilities/parser/envelopes/apigwv2.py new file mode 100644 index 00000000000..a627e4da0e5 --- /dev/null +++ b/aws_lambda_powertools/utilities/parser/envelopes/apigwv2.py @@ -0,0 +1,32 @@ +import logging +from typing import Any, Dict, Optional, Type, Union + +from ..models import APIGatewayProxyEventV2Model +from ..types import Model +from .base import BaseEnvelope + +logger = logging.getLogger(__name__) + + +class ApiGatewayV2Envelope(BaseEnvelope): + """API Gateway V2 envelope to extract data within body 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 V2 {APIGatewayProxyEventV2Model}") + parsed_envelope = APIGatewayProxyEventV2Model.parse_obj(data) + logger.debug(f"Parsing event payload in `detail` with {model}") + return self._parse(data=parsed_envelope.body, model=model) diff --git a/aws_lambda_powertools/utilities/parser/models/__init__.py b/aws_lambda_powertools/utilities/parser/models/__init__.py index 0e59b2197a8..e3fb50a2d5d 100644 --- a/aws_lambda_powertools/utilities/parser/models/__init__.py +++ b/aws_lambda_powertools/utilities/parser/models/__init__.py @@ -5,6 +5,15 @@ APIGatewayEventRequestContext, APIGatewayProxyEventModel, ) +from .apigwv2 import ( + APIGatewayProxyEventV2Model, + RequestContextV2, + RequestContextV2Authorizer, + RequestContextV2AuthorizerIam, + RequestContextV2AuthorizerIamCognito, + RequestContextV2AuthorizerJwt, + RequestContextV2Http, +) from .cloudwatch import CloudWatchLogsData, CloudWatchLogsDecode, CloudWatchLogsLogEvent, CloudWatchLogsModel from .dynamodb import DynamoDBStreamChangedRecordModel, DynamoDBStreamModel, DynamoDBStreamRecordModel from .event_bridge import EventBridgeModel @@ -35,6 +44,13 @@ from .sqs import SqsAttributesModel, SqsModel, SqsMsgAttributeModel, SqsRecordModel __all__ = [ + "APIGatewayProxyEventV2Model", + "RequestContextV2", + "RequestContextV2Http", + "RequestContextV2Authorizer", + "RequestContextV2AuthorizerJwt", + "RequestContextV2AuthorizerIam", + "RequestContextV2AuthorizerIamCognito", "CloudWatchLogsData", "CloudWatchLogsDecode", "CloudWatchLogsLogEvent", diff --git a/aws_lambda_powertools/utilities/parser/models/apigwv2.py b/aws_lambda_powertools/utilities/parser/models/apigwv2.py new file mode 100644 index 00000000000..14e767c574c --- /dev/null +++ b/aws_lambda_powertools/utilities/parser/models/apigwv2.py @@ -0,0 +1,72 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field +from pydantic.networks import IPvAnyNetwork + +from ..types import Literal + + +class RequestContextV2AuthorizerIamCognito(BaseModel): + amr: List[str] + identityId: str + identityPoolId: str + + +class RequestContextV2AuthorizerIam(BaseModel): + accessKey: Optional[str] + accountId: Optional[str] + callerId: Optional[str] + principalOrgId: Optional[str] + userArn: Optional[str] + userId: Optional[str] + cognitoIdentity: RequestContextV2AuthorizerIamCognito + + +class RequestContextV2AuthorizerJwt(BaseModel): + claims: Dict[str, Any] + scopes: List[str] + + +class RequestContextV2Authorizer(BaseModel): + jwt: Optional[RequestContextV2AuthorizerJwt] + iam: Optional[RequestContextV2AuthorizerIam] + lambda_value: Optional[Dict[str, Any]] = Field(None, alias="lambda") + + +class RequestContextV2Http(BaseModel): + method: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + path: str + protocol: str + sourceIp: IPvAnyNetwork + userAgent: str + + +class RequestContextV2(BaseModel): + accountId: str + apiId: str + authorizer: Optional[RequestContextV2Authorizer] + domainName: str + domainPrefix: str + requestId: str + routeKey: str + stage: str + property + time: str + timeEpoch: datetime + http: RequestContextV2Http + + +class APIGatewayProxyEventV2Model(BaseModel): + version: str + routeKey: str + rawPath: str + rawQueryString: str + cookies: Optional[List[str]] + headers: Dict[str, str] + queryStringParameters: Dict[str, str] + pathParameters: Optional[Dict[str, str]] + stageVariables: Optional[Dict[str, str]] + requestContext: RequestContextV2 + body: str + isBase64Encoded: bool diff --git a/docs/utilities/parser.md b/docs/utilities/parser.md index 83fca6b6741..8e2dd05ad51 100644 --- a/docs/utilities/parser.md +++ b/docs/utilities/parser.md @@ -295,17 +295,17 @@ 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`.
2. Parses records in `NewImage` and `OldImage` keys using your model.
3. Returns a list with a dictionary containing `NewImage` and `OldImage` keys | `List[Dict[str, Optional[Model]]]` | -| **EventBridgeEnvelope** | 1. Parses data using `EventBridgeModel`.
2. Parses `detail` key using your model and returns it. | `Model` | -| **SqsEnvelope** | 1. Parses data using `SqsModel`.
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.
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.
2. Parses records in in `Records` key using your model and returns them in a list. | `List[Model]` | -| **SnsEnvelope** | 1. Parses data using `SnsModel`.
2. Parses records in `body` key using your model and return them in a list. | `List[Model]` | -| **SnsSqsEnvelope** | 1. Parses data using `SqsModel`.
2. Parses SNS records in `body` key using `SnsNotificationModel`.
3. Parses data in `Message` key using your model and return them in a list. | `List[Model]` | -| **ApiGatewayEnvelope** | 1. Parses data using `APIGatewayProxyEventModel`.
2. Parses `body` key using your model and returns it. | `Model` | - +| Envelope name | Behaviour | Return | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| **DynamoDBStreamEnvelope** | 1. Parses data using `DynamoDBStreamModel`.
2. Parses records in `NewImage` and `OldImage` keys using your model.
3. Returns a list with a dictionary containing `NewImage` and `OldImage` keys | `List[Dict[str, Optional[Model]]]` | +| **EventBridgeEnvelope** | 1. Parses data using `EventBridgeModel`.
2. Parses `detail` key using your model and returns it. | `Model` | +| **SqsEnvelope** | 1. Parses data using `SqsModel`.
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.
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.
2. Parses records in in `Records` key using your model and returns them in a list. | `List[Model]` | +| **SnsEnvelope** | 1. Parses data using `SnsModel`.
2. Parses records in `body` key using your model and return them in a list. | `List[Model]` | +| **SnsSqsEnvelope** | 1. Parses data using `SqsModel`.
2. Parses SNS records in `body` key using `SnsNotificationModel`.
3. Parses data in `Message` key using your model and return them in a list. | `List[Model]` | +| **ApiGatewayEnvelope** | 1. Parses data using `APIGatewayProxyEventModel`.
2. Parses `body` key using your model and returns it. | `Model` | +| **ApiGatewayV2Envelope** | 1. Parses data using `APIGatewayProxyEventV2Model`.
2. Parses `body` key using your model and returns it. | `Model` | ### Bringing your own envelope You can create your own Envelope model and logic by inheriting from `BaseEnvelope`, and implementing the `parse` method. diff --git a/tests/events/apiGatewayProxyV2Event.json b/tests/events/apiGatewayProxyV2Event.json index 4d0cfdf5703..5e001934fee 100644 --- a/tests/events/apiGatewayProxyV2Event.json +++ b/tests/events/apiGatewayProxyV2Event.json @@ -36,7 +36,7 @@ "method": "POST", "path": "/my/path", "protocol": "HTTP/1.1", - "sourceIp": "IP", + "sourceIp": "192.168.0.1/32", "userAgent": "agent" }, "requestId": "id", @@ -54,4 +54,4 @@ "stageVariable1": "value1", "stageVariable2": "value2" } -} +} \ No newline at end of file diff --git a/tests/events/apiGatewayProxyV2IamEvent.json b/tests/events/apiGatewayProxyV2IamEvent.json index 73d50d78a4a..43f33e1678d 100644 --- a/tests/events/apiGatewayProxyV2IamEvent.json +++ b/tests/events/apiGatewayProxyV2IamEvent.json @@ -29,7 +29,9 @@ "accountId": "1234567890", "callerId": "AROA7ZJZYVRE7C3DUXHH6:CognitoIdentityCredentials", "cognitoIdentity": { - "amr" : ["foo"], + "amr": [ + "foo" + ], "identityId": "us-east-1:3f291106-8703-466b-8f2b-3ecee1ca56ce", "identityPoolId": "us-east-1:4f291106-8703-466b-8f2b-3ecee1ca56ce" }, @@ -47,7 +49,7 @@ "method": "GET", "path": "/my/path", "protocol": "HTTP/1.1", - "sourceIp": "IP", + "sourceIp": "192.168.0.1/32", "userAgent": "agent" } }, @@ -57,4 +59,4 @@ }, "body": "{\r\n\t\"a\": 1\r\n}", "isBase64Encoded": false -} +} \ No newline at end of file diff --git a/tests/events/apiGatewayProxyV2LambdaAuthorizerEvent.json b/tests/events/apiGatewayProxyV2LambdaAuthorizerEvent.json index 75d1574f854..cae3130de80 100644 --- a/tests/events/apiGatewayProxyV2LambdaAuthorizerEvent.json +++ b/tests/events/apiGatewayProxyV2LambdaAuthorizerEvent.json @@ -37,7 +37,7 @@ "method": "GET", "path": "/my/path", "protocol": "HTTP/1.1", - "sourceIp": "IP", + "sourceIp": "192.168.0.1/32", "userAgent": "agent" } }, @@ -47,4 +47,4 @@ }, "body": "{\r\n\t\"a\": 1\r\n}", "isBase64Encoded": false -} +} \ No newline at end of file diff --git a/tests/functional/parser/test_apigwv2.py b/tests/functional/parser/test_apigwv2.py new file mode 100644 index 00000000000..ee6a4790cd4 --- /dev/null +++ b/tests/functional/parser/test_apigwv2.py @@ -0,0 +1,92 @@ +from aws_lambda_powertools.utilities.parser import envelopes, event_parser +from aws_lambda_powertools.utilities.parser.models import ( + APIGatewayProxyEventV2Model, + RequestContextV2, + RequestContextV2Authorizer, +) +from aws_lambda_powertools.utilities.typing import LambdaContext +from tests.functional.parser.schemas import MyApiGatewayBusiness +from tests.functional.utils import load_event + + +@event_parser(model=MyApiGatewayBusiness, envelope=envelopes.ApiGatewayV2Envelope) +def handle_apigw_with_envelope(event: MyApiGatewayBusiness, _: LambdaContext): + assert event.message == "Hello" + assert event.username == "Ran" + + +@event_parser(model=APIGatewayProxyEventV2Model) +def handle_apigw_event(event: APIGatewayProxyEventV2Model, _: LambdaContext): + return event + + +def test_apigw_v2_event_with_envelope(): + event = load_event("apiGatewayProxyV2Event.json") + event["body"] = '{"message": "Hello", "username": "Ran"}' + handle_apigw_with_envelope(event, LambdaContext()) + + +def test_apigw_v2_event_jwt_authorizer(): + event = load_event("apiGatewayProxyV2Event.json") + parsed_event: APIGatewayProxyEventV2Model = handle_apigw_event(event, LambdaContext()) + assert parsed_event.version == event["version"] + assert parsed_event.routeKey == event["routeKey"] + assert parsed_event.rawPath == event["rawPath"] + assert parsed_event.rawQueryString == event["rawQueryString"] + assert parsed_event.cookies == event["cookies"] + assert parsed_event.cookies[0] == "cookie1" + assert parsed_event.headers == event["headers"] + assert parsed_event.queryStringParameters == event["queryStringParameters"] + assert parsed_event.queryStringParameters["parameter2"] == "value" + + request_context = parsed_event.requestContext + assert request_context.accountId == event["requestContext"]["accountId"] + assert request_context.apiId == event["requestContext"]["apiId"] + assert request_context.authorizer.jwt.claims == event["requestContext"]["authorizer"]["jwt"]["claims"] + assert request_context.authorizer.jwt.scopes == event["requestContext"]["authorizer"]["jwt"]["scopes"] + assert request_context.domainName == event["requestContext"]["domainName"] + assert request_context.domainPrefix == event["requestContext"]["domainPrefix"] + + http = request_context.http + assert http.method == "POST" + assert http.path == "/my/path" + assert http.protocol == "HTTP/1.1" + assert str(http.sourceIp) == "192.168.0.1/32" + assert http.userAgent == "agent" + + assert request_context.requestId == event["requestContext"]["requestId"] + assert request_context.routeKey == event["requestContext"]["routeKey"] + assert request_context.stage == event["requestContext"]["stage"] + assert request_context.time == event["requestContext"]["time"] + convert_time = int(round(request_context.timeEpoch.timestamp() * 1000)) + assert convert_time == event["requestContext"]["timeEpoch"] + assert parsed_event.body == event["body"] + assert parsed_event.pathParameters == event["pathParameters"] + assert parsed_event.isBase64Encoded == event["isBase64Encoded"] + assert parsed_event.stageVariables == event["stageVariables"] + + +def test_api_gateway_proxy_v2_event_lambda_authorizer(): + event = load_event("apiGatewayProxyV2LambdaAuthorizerEvent.json") + parsed_event: APIGatewayProxyEventV2Model = handle_apigw_event(event, LambdaContext()) + request_context: RequestContextV2 = parsed_event.requestContext + assert request_context is not None + lambda_props: RequestContextV2Authorizer = request_context.authorizer.lambda_value + assert lambda_props is not None + assert lambda_props["key"] == "value" + + +def test_api_gateway_proxy_v2_event_iam_authorizer(): + event = load_event("apiGatewayProxyV2IamEvent.json") + parsed_event: APIGatewayProxyEventV2Model = handle_apigw_event(event, LambdaContext()) + iam = parsed_event.requestContext.authorizer.iam + assert iam is not None + assert iam.accessKey == "ARIA2ZJZYVUEREEIHAKY" + assert iam.accountId == "1234567890" + assert iam.callerId == "AROA7ZJZYVRE7C3DUXHH6:CognitoIdentityCredentials" + assert iam.cognitoIdentity.amr == ["foo"] + assert iam.cognitoIdentity.identityId == "us-east-1:3f291106-8703-466b-8f2b-3ecee1ca56ce" + assert iam.cognitoIdentity.identityPoolId == "us-east-1:4f291106-8703-466b-8f2b-3ecee1ca56ce" + assert iam.principalOrgId == "AwsOrgId" + assert iam.userArn == "arn:aws:iam::1234567890:user/Admin" + assert iam.userId == "AROA2ZJZYVRE7Y3TUXHH6" diff --git a/tests/functional/test_data_classes.py b/tests/functional/test_data_classes.py index f56d0700e6f..07648f84ee9 100644 --- a/tests/functional/test_data_classes.py +++ b/tests/functional/test_data_classes.py @@ -743,7 +743,7 @@ def test_api_gateway_proxy_v2_event(): assert http.method == "POST" assert http.path == "/my/path" assert http.protocol == "HTTP/1.1" - assert http.source_ip == "IP" + assert http.source_ip == "192.168.0.1/32" assert http.user_agent == "agent" assert request_context.request_id == event["requestContext"]["requestId"] From 7e01f2d3fd9670e1c106dbd3ff3e2bf78f729d80 Mon Sep 17 00:00:00 2001 From: Ran Isenberg <60175085+risenberg-cyberark@users.noreply.github.com> Date: Fri, 28 May 2021 14:33:37 +0300 Subject: [PATCH 2/3] Update aws_lambda_powertools/utilities/parser/models/apigwv2.py no idea how that got into there ;) Co-authored-by: Heitor Lessa --- aws_lambda_powertools/utilities/parser/models/apigwv2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/parser/models/apigwv2.py b/aws_lambda_powertools/utilities/parser/models/apigwv2.py index 14e767c574c..4243315bb21 100644 --- a/aws_lambda_powertools/utilities/parser/models/apigwv2.py +++ b/aws_lambda_powertools/utilities/parser/models/apigwv2.py @@ -51,7 +51,6 @@ class RequestContextV2(BaseModel): requestId: str routeKey: str stage: str - property time: str timeEpoch: datetime http: RequestContextV2Http From 65eb3c88434b5e9e413756fb21a53a1503098440 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 28 May 2021 14:42:42 +0200 Subject: [PATCH 3/3] docs(parser): add new API GW Proxy v2 model --- docs/utilities/parser.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/utilities/parser.md b/docs/utilities/parser.md index 8e2dd05ad51..11dbaca48a8 100644 --- a/docs/utilities/parser.md +++ b/docs/utilities/parser.md @@ -162,6 +162,7 @@ Parser comes with the following built-in models: | **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 | +| **APIGatewayProxyEventV2Model** | Lambda Event Source payload for Amazon API Gateway v2 payload | ### extending built-in models