diff --git a/aws_lambda_powertools/event_handler/__init__.py b/aws_lambda_powertools/event_handler/__init__.py index 85298cfc15c..7bdd9a97f72 100644 --- a/aws_lambda_powertools/event_handler/__init__.py +++ b/aws_lambda_powertools/event_handler/__init__.py @@ -14,7 +14,7 @@ from aws_lambda_powertools.event_handler.lambda_function_url import ( LambdaFunctionUrlResolver, ) -from aws_lambda_powertools.event_handler.vpc_lattice import VPCLatticeResolver +from aws_lambda_powertools.event_handler.vpc_lattice import VPCLatticeResolver, VPCLatticeV2Resolver __all__ = [ "AppSyncResolver", @@ -26,4 +26,5 @@ "LambdaFunctionUrlResolver", "Response", "VPCLatticeResolver", + "VPCLatticeV2Resolver", ] diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 2163d7d762e..46cb5587135 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -22,6 +22,7 @@ APIGatewayProxyEventV2, LambdaFunctionUrlEvent, VPCLatticeEvent, + VPCLatticeEventV2, ) from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent from aws_lambda_powertools.utilities.typing import LambdaContext @@ -43,6 +44,7 @@ class ProxyEventType(Enum): APIGatewayProxyEventV2 = "APIGatewayProxyEventV2" ALBEvent = "ALBEvent" VPCLatticeEvent = "VPCLatticeEvent" + VPCLatticeEventV2 = "VPCLatticeEventV2" LambdaFunctionUrlEvent = "LambdaFunctionUrlEvent" @@ -999,6 +1001,9 @@ def _to_proxy_event(self, event: Dict) -> BaseProxyEvent: if self._proxy_type == ProxyEventType.VPCLatticeEvent: logger.debug("Converting event to VPC Lattice contract") return VPCLatticeEvent(event) + if self._proxy_type == ProxyEventType.VPCLatticeEventV2: + logger.debug("Converting event to VPC LatticeV2 contract") + return VPCLatticeEventV2(event) logger.debug("Converting event to ALB contract") return ALBEvent(event) diff --git a/aws_lambda_powertools/event_handler/vpc_lattice.py b/aws_lambda_powertools/event_handler/vpc_lattice.py index b3cb042b40b..bcee046e382 100644 --- a/aws_lambda_powertools/event_handler/vpc_lattice.py +++ b/aws_lambda_powertools/event_handler/vpc_lattice.py @@ -5,7 +5,7 @@ ApiGatewayResolver, ProxyEventType, ) -from aws_lambda_powertools.utilities.data_classes import VPCLatticeEvent +from aws_lambda_powertools.utilities.data_classes import VPCLatticeEvent, VPCLatticeEventV2 class VPCLatticeResolver(ApiGatewayResolver): @@ -51,3 +51,48 @@ def __init__( ): """Amazon VPC Lattice resolver""" super().__init__(ProxyEventType.VPCLatticeEvent, cors, debug, serializer, strip_prefixes) + + +class VPCLatticeV2Resolver(ApiGatewayResolver): + """VPC Lattice resolver + + Documentation: + - https://docs.aws.amazon.com/lambda/latest/dg/services-vpc-lattice.html + - https://docs.aws.amazon.com/lambda/latest/dg/services-vpc-lattice.html#vpc-lattice-receiving-events + + Examples + -------- + Simple example integrating with Tracer + + ```python + from aws_lambda_powertools import Tracer + from aws_lambda_powertools.event_handler import VPCLatticeV2Resolver + + tracer = Tracer() + app = VPCLatticeV2Resolver() + + @app.get("/get-call") + def simple_get(): + return {"message": "Foo"} + + @app.post("/post-call") + def simple_post(): + post_data: dict = app.current_event.json_body + return {"message": post_data} + + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context) + """ + + current_event: VPCLatticeEventV2 + + def __init__( + self, + cors: Optional[CORSConfig] = None, + debug: Optional[bool] = None, + serializer: Optional[Callable[[Dict], str]] = None, + strip_prefixes: Optional[List[Union[str, Pattern]]] = None, + ): + """Amazon VPC Lattice resolver""" + super().__init__(ProxyEventType.VPCLatticeEventV2, cors, debug, serializer, strip_prefixes) diff --git a/aws_lambda_powertools/utilities/data_classes/__init__.py b/aws_lambda_powertools/utilities/data_classes/__init__.py index 99754266928..d245bc35f0d 100644 --- a/aws_lambda_powertools/utilities/data_classes/__init__.py +++ b/aws_lambda_powertools/utilities/data_classes/__init__.py @@ -27,7 +27,7 @@ from .ses_event import SESEvent from .sns_event import SNSEvent from .sqs_event import SQSEvent -from .vpc_lattice import VPCLatticeEvent +from .vpc_lattice import VPCLatticeEvent, VPCLatticeEventV2 __all__ = [ "APIGatewayProxyEvent", @@ -56,4 +56,5 @@ "event_source", "AWSConfigRuleEvent", "VPCLatticeEvent", + "VPCLatticeEventV2", ] diff --git a/aws_lambda_powertools/utilities/data_classes/vpc_lattice.py b/aws_lambda_powertools/utilities/data_classes/vpc_lattice.py index 35194f1f3f0..00ba5136eec 100644 --- a/aws_lambda_powertools/utilities/data_classes/vpc_lattice.py +++ b/aws_lambda_powertools/utilities/data_classes/vpc_lattice.py @@ -4,7 +4,7 @@ BaseHeadersSerializer, HttpApiHeadersSerializer, ) -from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent +from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent, DictWrapper from aws_lambda_powertools.utilities.data_classes.shared_functions import ( base64_decode, get_header_value, @@ -12,7 +12,7 @@ ) -class VPCLatticeEvent(BaseProxyEvent): +class VPCLatticeEventBase(BaseProxyEvent): @property def body(self) -> str: """The VPC Lattice body.""" @@ -30,11 +30,6 @@ def headers(self) -> Dict[str, str]: """The VPC Lattice event headers.""" return self["headers"] - @property - def is_base64_encoded(self) -> bool: - """A boolean flag to indicate if the applicable request payload is Base64-encode""" - return self["is_base64_encoded"] - @property def decoded_body(self) -> str: """Dynamically base64 decode body as a str""" @@ -48,24 +43,6 @@ def method(self) -> str: """The VPC Lattice method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT.""" return self["method"] - @property - def query_string_parameters(self) -> Dict[str, str]: - """The request query string parameters.""" - return self["query_string_parameters"] - - @property - def raw_path(self) -> str: - """The raw VPC Lattice request path.""" - return self["raw_path"] - - # VPCLattice event has no path field - # Added here for consistency with the BaseProxyEvent class - @property - def path(self) -> str: - return self["raw_path"] - - # VPCLattice event has no http_method field - # Added here for consistency with the BaseProxyEvent class @property def http_method(self) -> str: """The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT.""" @@ -140,3 +117,137 @@ def get_header_value( def header_serializer(self) -> BaseHeadersSerializer: # When using the VPC Lattice integration, we have multiple HTTP Headers. return HttpApiHeadersSerializer() + + +class VPCLatticeEvent(VPCLatticeEventBase): + @property + def raw_path(self) -> str: + """The raw VPC Lattice request path.""" + return self["raw_path"] + + @property + def is_base64_encoded(self) -> bool: + """A boolean flag to indicate if the applicable request payload is Base64-encode""" + return self["is_base64_encoded"] + + # VPCLattice event has no path field + # Added here for consistency with the BaseProxyEvent class + @property + def path(self) -> str: + return self["raw_path"] + + @property + def query_string_parameters(self) -> Dict[str, str]: + """The request query string parameters.""" + return self["query_string_parameters"] + + +class vpcLatticeEventV2Identity(DictWrapper): + @property + def source_vpc_arn(self) -> Optional[str]: + """The VPC Lattice v2 Event requestContext Identity sourceVpcArn""" + return self.get("sourceVpcArn") + + @property + def get_type(self) -> Optional[str]: + """The VPC Lattice v2 Event requestContext Identity type""" + return self.get("type") + + @property + def principal(self) -> Optional[str]: + """The VPC Lattice v2 Event requestContext principal""" + return self.get("principal") + + @property + def principal_org_id(self) -> Optional[str]: + """The VPC Lattice v2 Event requestContext principalOrgID""" + return self.get("principalOrgID") + + @property + def session_name(self) -> Optional[str]: + """The VPC Lattice v2 Event requestContext sessionName""" + return self.get("sessionName") + + @property + def x509_subject_cn(self) -> Optional[str]: + """The VPC Lattice v2 Event requestContext X509SubjectCn""" + return self.get("X509SubjectCn") + + @property + def x509_issuer_ou(self) -> Optional[str]: + """The VPC Lattice v2 Event requestContext X509IssuerOu""" + return self.get("X509IssuerOu") + + @property + def x509_san_dns(self) -> Optional[str]: + """The VPC Lattice v2 Event requestContext X509SanDns""" + return self.get("x509SanDns") + + @property + def x509_san_uri(self) -> Optional[str]: + """The VPC Lattice v2 Event requestContext X509SanUri""" + return self.get("X509SanUri") + + @property + def x509_san_name_cn(self) -> Optional[str]: + """The VPC Lattice v2 Event requestContext X509SanNameCn""" + return self.get("X509SanNameCn") + + +class vpcLatticeEventV2RequestContext(DictWrapper): + @property + def service_network_arn(self) -> str: + """The VPC Lattice v2 Event requestContext serviceNetworkArn""" + return self["serviceNetworkArn"] + + @property + def service_arn(self) -> str: + """The VPC Lattice v2 Event requestContext serviceArn""" + return self["serviceArn"] + + @property + def target_group_arn(self) -> str: + """The VPC Lattice v2 Event requestContext targetGroupArn""" + return self["targetGroupArn"] + + @property + def identity(self) -> vpcLatticeEventV2Identity: + """The VPC Lattice v2 Event requestContext identity""" + return vpcLatticeEventV2Identity(self["identity"]) + + @property + def region(self) -> str: + """The VPC Lattice v2 Event requestContext serviceNetworkArn""" + return self["region"] + + @property + def time_epoch(self) -> float: + """The VPC Lattice v2 Event requestContext timeEpoch""" + return self["timeEpoch"] + + +class VPCLatticeEventV2(VPCLatticeEventBase): + @property + def version(self) -> str: + """The VPC Lattice v2 Event version""" + return self["version"] + + @property + def is_base64_encoded(self) -> Optional[bool]: + """A boolean flag to indicate if the applicable request payload is Base64-encode""" + return self.get("isBase64Encoded") + + @property + def path(self) -> str: + """The VPC Lattice v2 Event path""" + return self["path"] + + @property + def request_context(self) -> vpcLatticeEventV2RequestContext: + """he VPC Lattice v2 Event request context.""" + return vpcLatticeEventV2RequestContext(self["requestContext"]) + + @property + def query_string_parameters(self) -> Optional[Dict[str, str]]: + """The request query string parameters.""" + return self.get("queryStringParameters") diff --git a/aws_lambda_powertools/utilities/parser/envelopes/__init__.py b/aws_lambda_powertools/utilities/parser/envelopes/__init__.py index cbca982adf7..affffd98174 100644 --- a/aws_lambda_powertools/utilities/parser/envelopes/__init__.py +++ b/aws_lambda_powertools/utilities/parser/envelopes/__init__.py @@ -11,6 +11,7 @@ from .sns import SnsEnvelope, SnsSqsEnvelope from .sqs import SqsEnvelope from .vpc_lattice import VpcLatticeEnvelope +from .vpc_latticev2 import VpcLatticeV2Envelope __all__ = [ "ApiGatewayEnvelope", @@ -27,4 +28,5 @@ "KafkaEnvelope", "BaseEnvelope", "VpcLatticeEnvelope", + "VpcLatticeV2Envelope", ] diff --git a/aws_lambda_powertools/utilities/parser/envelopes/vpc_latticev2.py b/aws_lambda_powertools/utilities/parser/envelopes/vpc_latticev2.py new file mode 100644 index 00000000000..77dbf2a4a24 --- /dev/null +++ b/aws_lambda_powertools/utilities/parser/envelopes/vpc_latticev2.py @@ -0,0 +1,32 @@ +import logging +from typing import Any, Dict, Optional, Type, Union + +from ..models import VpcLatticeV2Model +from ..types import Model +from .base import BaseEnvelope + +logger = logging.getLogger(__name__) + + +class VpcLatticeV2Envelope(BaseEnvelope): + """Amazon VPC Lattice 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 + ------- + Optional[Model] + Parsed detail payload with model provided + """ + logger.debug(f"Parsing incoming data with VPC Lattice V2 model {VpcLatticeV2Model}") + parsed_envelope: VpcLatticeV2Model = VpcLatticeV2Model.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 f1b2d30d9cf..3c707fda61e 100644 --- a/aws_lambda_powertools/utilities/parser/models/__init__.py +++ b/aws_lambda_powertools/utilities/parser/models/__init__.py @@ -89,6 +89,7 @@ from .sns import SnsModel, SnsNotificationModel, SnsRecordModel from .sqs import SqsAttributesModel, SqsModel, SqsMsgAttributeModel, SqsRecordModel from .vpc_lattice import VpcLatticeModel +from .vpc_latticev2 import VpcLatticeV2Model __all__ = [ "APIGatewayProxyEventV2Model", @@ -163,4 +164,5 @@ "CloudFormationCustomResourceCreateModel", "CloudFormationCustomResourceBaseModel", "VpcLatticeModel", + "VpcLatticeV2Model", ] diff --git a/aws_lambda_powertools/utilities/parser/models/vpc_latticev2.py b/aws_lambda_powertools/utilities/parser/models/vpc_latticev2.py new file mode 100644 index 00000000000..dc764684484 --- /dev/null +++ b/aws_lambda_powertools/utilities/parser/models/vpc_latticev2.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Dict, Optional, Type, Union + +from pydantic import BaseModel, Field, validator + + +class VpcLatticeV2RequestContextIdentity(BaseModel): + source_vpc_arn: Optional[str] = Field(None, alias="sourceVpcArn") + get_type: Optional[str] = Field(None, alias="type") + principal: Optional[str] = Field(None, alias="principal") + principal_org_id: Optional[str] = Field(None, alias="principalOrgID") + session_name: Optional[str] = Field(None, alias="sessionName") + x509_subject_cn: Optional[str] = Field(None, alias="X509SubjectCn") + x509_issuer_ou: Optional[str] = Field(None, alias="X509IssuerOu") + x509_san_dns: Optional[str] = Field(None, alias="x509SanDns") + x509_san_uri: Optional[str] = Field(None, alias="X509SanUri") + x509_san_name_cn: Optional[str] = Field(None, alias="X509SanNameCn") + + +class VpcLatticeV2RequestContext(BaseModel): + service_network_arn: str = Field(alias="serviceNetworkArn") + service_arn: str = Field(alias="serviceArn") + target_group_arn: str = Field(alias="targetGroupArn") + identity: VpcLatticeV2RequestContextIdentity + region: str + time_epoch: float = Field(alias="timeEpoch") + time_epoch_as_datetime: datetime = Field(alias="timeEpoch") + + @validator("time_epoch_as_datetime", pre=True, allow_reuse=True) + def time_epoch_convert_to_miliseconds(cls, value: int): + return round(int(value) / 1000) + + +class VpcLatticeV2Model(BaseModel): + version: str + path: str + method: str + headers: Dict[str, str] + query_string_parameters: Optional[Dict[str, str]] = Field(None, alias="queryStringParameters") + body: Optional[Union[str, Type[BaseModel]]] = None + is_base64_encoded: Optional[bool] = Field(None, alias="isBase64Encoded") + request_context: VpcLatticeV2RequestContext = Field(None, alias="requestContext") diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index 4e4e935f699..88d5096267a 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -122,15 +122,27 @@ When using [AWS Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/d #### VPC Lattice -When using [VPC Lattice with AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/services-vpc-lattice.html){target="_blank"}, you can use `VPCLatticeResolver`. +When using [VPC Lattice with AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/services-vpc-lattice.html){target="_blank"}, you can use `VPCLatticeV2Resolver`. -=== "getting_started_vpclattice_resolver.py" +=== "Payload v2 (Recommended)" + + ```python hl_lines="5 11" title="Using VPC Lattice resolver" + --8<-- "examples/event_handler_rest/src/getting_started_vpclatticev2_resolver.py" + ``` + +=== "Payload v2 (Recommended) - Sample Event" + + ```json hl_lines="2 3" title="Example payload delivered to the handler" + --8<-- "examples/event_handler_rest/src/getting_started_vpclatticev2_resolver.json" + ``` + +=== "Payload v1" ```python hl_lines="5 11" title="Using VPC Lattice resolver" --8<-- "examples/event_handler_rest/src/getting_started_vpclattice_resolver.py" ``` -=== "getting_started_vpclattice_resolver.json" +=== "Payload v1 - Sample Event" ```json hl_lines="2 3" title="Example payload delivered to the handler" --8<-- "examples/event_handler_rest/src/getting_started_vpclattice_resolver.json" diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index fd4a176f631..7cc966313fb 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -103,7 +103,8 @@ Log Data Event for Troubleshooting | [SES](#ses) | `SESEvent` | | [SNS](#sns) | `SNSEvent` | | [SQS](#sqs) | `SQSEvent` | -| [VPC Lattice](#vpc-lattice) | `VPCLatticeEvent` | +| [VPC Lattice V2](#vpc-lattice-v2) | `VPCLatticeV2Event` | +| [VPC Lattice V1](#vpc-lattice-v1) | `VPCLatticeEvent` | ???+ info The examples provided below are far from exhaustive - the data classes themselves are designed to provide a form of @@ -1180,7 +1181,25 @@ AWS Secrets Manager rotation uses an AWS Lambda function to update the secret. [ do_something_with(record.body) ``` -### VPC Lattice +### VPC Lattice V2 + +You can register your Lambda functions as targets within an Amazon VPC Lattice service network. By doing this, your Lambda function becomes a service within the network, and clients that have access to the VPC Lattice service network can call your service using [Payload V2](https://docs.aws.amazon.com/lambda/latest/dg/services-vpc-lattice.html#vpc-lattice-receiving-events){target="_blank"}. + +[Click here](https://docs.aws.amazon.com/lambda/latest/dg/services-vpc-lattice.html){target="_blank"} for more information about using AWS Lambda with Amazon VPC Lattice. + +=== "app.py" + + ```python hl_lines="2 8" + --8<-- "examples/event_sources/src/vpc_lattice_v2.py" + ``` + +=== "Lattice Example Event" + + ```json + --8<-- "examples/event_sources/src/vpc_lattice_v2_payload.json" + ``` + +### VPC Lattice V1 You can register your Lambda functions as targets within an Amazon VPC Lattice service network. By doing this, your Lambda function becomes a service within the network, and clients that have access to the VPC Lattice service network can call your service. diff --git a/docs/utilities/parser.md b/docs/utilities/parser.md index 846460e43d2..2fdba30586c 100644 --- a/docs/utilities/parser.md +++ b/docs/utilities/parser.md @@ -192,6 +192,7 @@ Parser comes with the following built-in models: | **SnsModel** | Lambda Event Source payload for Amazon Simple Notification Service | | **SqsModel** | Lambda Event Source payload for Amazon SQS | | **VpcLatticeModel** | Lambda Event Source payload for Amazon VPC Lattice | +| **VpcLatticeV2Model** | Lambda Event Source payload for Amazon VPC Lattice v2 payload | #### Extending built-in models diff --git a/examples/event_handler_rest/src/getting_started_vpclatticev2_resolver.json b/examples/event_handler_rest/src/getting_started_vpclatticev2_resolver.json new file mode 100644 index 00000000000..38c94683432 --- /dev/null +++ b/examples/event_handler_rest/src/getting_started_vpclatticev2_resolver.json @@ -0,0 +1,29 @@ +{ + "version": "2.0", + "path": "/todos", + "method": "GET", + "headers": { + "user_agent": "curl/7.64.1", + "x-forwarded-for": "10.213.229.10", + "host": "test-lambda-service-3908sdf9u3u.dkfjd93.vpc-lattice-svcs.us-east-2.on.aws", + "accept": "*/*" + }, + "queryStringParameters": { + "order-id": "1" + }, + "body": "{\"message\": \"Hello from Lambda!\"}", + "requestContext": { + "serviceNetworkArn": "arn:aws:vpc-lattice:us-east-2:123456789012:servicenetwork/sn-0bf3f2882e9cc805a", + "serviceArn": "arn:aws:vpc-lattice:us-east-2:123456789012:service/svc-0a40eebed65f8d69c", + "targetGroupArn": "arn:aws:vpc-lattice:us-east-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", + "identity": { + "sourceVpcArn": "arn:aws:ec2:region:123456789012:vpc/vpc-0b8276c84697e7339", + "type" : "AWS_IAM", + "principal": "arn:aws:sts::123456789012:assumed-role/example-role/057d00f8b51257ba3c853a0f248943cf", + "sessionName": "057d00f8b51257ba3c853a0f248943cf", + "x509SanDns": "example.com" + }, + "region": "us-east-2", + "timeEpoch": "1696331543569073" + } +} diff --git a/examples/event_handler_rest/src/getting_started_vpclatticev2_resolver.py b/examples/event_handler_rest/src/getting_started_vpclatticev2_resolver.py new file mode 100644 index 00000000000..4cf61caecaf --- /dev/null +++ b/examples/event_handler_rest/src/getting_started_vpclatticev2_resolver.py @@ -0,0 +1,28 @@ +import requests +from requests import Response + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import VPCLatticeV2Resolver +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.utilities.typing import LambdaContext + +tracer = Tracer() +logger = Logger() +app = VPCLatticeV2Resolver() + + +@app.get("/todos") +@tracer.capture_method +def get_todos(): + todos: Response = requests.get("https://jsonplaceholder.typicode.com/todos") + todos.raise_for_status() + + # for brevity, we'll limit to the first 10 only + return {"todos": todos.json()[:10]} + + +# You can continue to use other utilities just as before +@logger.inject_lambda_context(correlation_id_path=correlation_paths.APPLICATION_LOAD_BALANCER) +@tracer.capture_lambda_handler +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_sources/src/vpc_lattice_v2.py b/examples/event_sources/src/vpc_lattice_v2.py new file mode 100644 index 00000000000..0d11328bd76 --- /dev/null +++ b/examples/event_sources/src/vpc_lattice_v2.py @@ -0,0 +1,20 @@ +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.data_classes import VPCLatticeEventV2, event_source +from aws_lambda_powertools.utilities.typing import LambdaContext + +logger = Logger() + + +@event_source(data_class=VPCLatticeEventV2) +def lambda_handler(event: VPCLatticeEventV2, context: LambdaContext): + logger.info(event.body) + + response = { + "isBase64Encoded": False, + "statusCode": 200, + "statusDescription": "200 OK", + "headers": {"Content-Type": "application/text"}, + "body": "VPC Lattice V2 Event ✨🎉✨", + } + + return response diff --git a/examples/event_sources/src/vpc_lattice_v2_payload.json b/examples/event_sources/src/vpc_lattice_v2_payload.json new file mode 100644 index 00000000000..38c94683432 --- /dev/null +++ b/examples/event_sources/src/vpc_lattice_v2_payload.json @@ -0,0 +1,29 @@ +{ + "version": "2.0", + "path": "/todos", + "method": "GET", + "headers": { + "user_agent": "curl/7.64.1", + "x-forwarded-for": "10.213.229.10", + "host": "test-lambda-service-3908sdf9u3u.dkfjd93.vpc-lattice-svcs.us-east-2.on.aws", + "accept": "*/*" + }, + "queryStringParameters": { + "order-id": "1" + }, + "body": "{\"message\": \"Hello from Lambda!\"}", + "requestContext": { + "serviceNetworkArn": "arn:aws:vpc-lattice:us-east-2:123456789012:servicenetwork/sn-0bf3f2882e9cc805a", + "serviceArn": "arn:aws:vpc-lattice:us-east-2:123456789012:service/svc-0a40eebed65f8d69c", + "targetGroupArn": "arn:aws:vpc-lattice:us-east-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", + "identity": { + "sourceVpcArn": "arn:aws:ec2:region:123456789012:vpc/vpc-0b8276c84697e7339", + "type" : "AWS_IAM", + "principal": "arn:aws:sts::123456789012:assumed-role/example-role/057d00f8b51257ba3c853a0f248943cf", + "sessionName": "057d00f8b51257ba3c853a0f248943cf", + "x509SanDns": "example.com" + }, + "region": "us-east-2", + "timeEpoch": "1696331543569073" + } +} diff --git a/tests/events/vpcLatticeEventV2PathTrailingSlash.json b/tests/events/vpcLatticeEventV2PathTrailingSlash.json new file mode 100644 index 00000000000..5f5fa7edd72 --- /dev/null +++ b/tests/events/vpcLatticeEventV2PathTrailingSlash.json @@ -0,0 +1,30 @@ +{ + "version": "2.0", + "path": "/newpath/", + "method": "GET", + "headers": { + "user_agent": "curl/7.64.1", + "x-forwarded-for": "10.213.229.10", + "host": "test-lambda-service-3908sdf9u3u.dkfjd93.vpc-lattice-svcs.us-east-2.on.aws", + "accept": "*/*" + }, + "query_string_parameters": { + "order-id": "1" + }, + "body": "{\"message\": \"Hello from Lambda!\"}", + "isBase64Encoded": false, + "requestContext": { + "serviceNetworkArn": "arn:aws:vpc-lattice:us-east-2:123456789012:servicenetwork/sn-0bf3f2882e9cc805a", + "serviceArn": "arn:aws:vpc-lattice:us-east-2:123456789012:service/svc-0a40eebed65f8d69c", + "targetGroupArn": "arn:aws:vpc-lattice:us-east-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", + "identity": { + "sourceVpcArn": "arn:aws:ec2:region:123456789012:vpc/vpc-0b8276c84697e7339", + "type" : "AWS_IAM", + "principal": "arn:aws:sts::123456789012:assumed-role/example-role/057d00f8b51257ba3c853a0f248943cf", + "sessionName": "057d00f8b51257ba3c853a0f248943cf", + "x509SanDns": "example.com" + }, + "region": "us-east-2", + "timeEpoch": "1696331543569073" + } +} diff --git a/tests/events/vpcLatticeV2Event.json b/tests/events/vpcLatticeV2Event.json new file mode 100644 index 00000000000..fe10d83a3af --- /dev/null +++ b/tests/events/vpcLatticeV2Event.json @@ -0,0 +1,30 @@ +{ + "version": "2.0", + "path": "/newpath", + "method": "GET", + "headers": { + "user_agent": "curl/7.64.1", + "x-forwarded-for": "10.213.229.10", + "host": "test-lambda-service-3908sdf9u3u.dkfjd93.vpc-lattice-svcs.us-east-2.on.aws", + "accept": "*/*" + }, + "queryStringParameters": { + "order-id": "1" + }, + "body": "{\"message\": \"Hello from Lambda!\"}", + "isBase64Encoded": false, + "requestContext": { + "serviceNetworkArn": "arn:aws:vpc-lattice:us-east-2:123456789012:servicenetwork/sn-0bf3f2882e9cc805a", + "serviceArn": "arn:aws:vpc-lattice:us-east-2:123456789012:service/svc-0a40eebed65f8d69c", + "targetGroupArn": "arn:aws:vpc-lattice:us-east-2:123456789012:targetgroup/tg-6d0ecf831eec9f09", + "identity": { + "sourceVpcArn": "arn:aws:ec2:region:123456789012:vpc/vpc-0b8276c84697e7339", + "type" : "AWS_IAM", + "principal": "arn:aws:sts::123456789012:assumed-role/example-role/057d00f8b51257ba3c853a0f248943cf", + "sessionName": "057d00f8b51257ba3c853a0f248943cf", + "x509SanDns": "example.com" + }, + "region": "us-east-2", + "timeEpoch": "1696331543569073" + } +} diff --git a/tests/functional/event_handler/test_vpc_latticev2.py b/tests/functional/event_handler/test_vpc_latticev2.py new file mode 100644 index 00000000000..e249b7d2ba1 --- /dev/null +++ b/tests/functional/event_handler/test_vpc_latticev2.py @@ -0,0 +1,77 @@ +from aws_lambda_powertools.event_handler import ( + Response, + VPCLatticeV2Resolver, + content_types, +) +from aws_lambda_powertools.event_handler.api_gateway import CORSConfig +from aws_lambda_powertools.utilities.data_classes import VPCLatticeEventV2 +from tests.functional.utils import load_event + + +def test_vpclatticev2_event(): + # GIVEN a VPC Lattice event + app = VPCLatticeV2Resolver() + + @app.get("/newpath") + def foo(): + assert isinstance(app.current_event, VPCLatticeEventV2) + assert app.lambda_context == {} + return Response(200, content_types.TEXT_HTML, "foo") + + # WHEN calling the event handler + result = app(load_event("vpcLatticeV2Event.json"), {}) + + # THEN process event correctly + # AND set the current_event type as VPCLatticeEvent + assert result["statusCode"] == 200 + assert result["headers"]["Content-Type"] == content_types.TEXT_HTML + assert result["body"] == "foo" + + +def test_vpclatticev2_event_path_trailing_slash(json_dump): + # GIVEN a VPC Lattice event + app = VPCLatticeV2Resolver() + + @app.get("/newpath") + def foo(): + assert isinstance(app.current_event, VPCLatticeEventV2) + assert app.lambda_context == {} + return Response(200, content_types.TEXT_HTML, "foo") + + # WHEN calling the event handler using path with trailing "/" + result = app(load_event("vpcLatticeEventV2PathTrailingSlash.json"), {}) + + # THEN + assert result["statusCode"] == 404 + assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + expected = {"statusCode": 404, "message": "Not found"} + assert result["body"] == json_dump(expected) + + +def test_cors_preflight_body_is_empty_not_null(): + # GIVEN CORS is configured + app = VPCLatticeV2Resolver(cors=CORSConfig()) + + event = {"path": "/my/request", "method": "OPTIONS", "headers": {}} + + # WHEN calling the event handler + result = app(event, {}) + + # THEN there body should be empty strings + assert result["body"] == "" + + +def test_vpclatticev2_url_no_matches(): + # GIVEN a VPC Lattice event + app = VPCLatticeV2Resolver() + + @app.post("/no_match") + def foo(): + raise RuntimeError() + + # WHEN calling the event handler + result = app(load_event("vpcLatticeV2Event.json"), {}) + + # THEN process event correctly + # AND return 404 because the event doesn't match any known route + assert result["statusCode"] == 404 diff --git a/tests/unit/data_classes/test_vpc_lattice_eventv2.py b/tests/unit/data_classes/test_vpc_lattice_eventv2.py new file mode 100644 index 00000000000..3726831445f --- /dev/null +++ b/tests/unit/data_classes/test_vpc_lattice_eventv2.py @@ -0,0 +1,35 @@ +from aws_lambda_powertools.utilities.data_classes.vpc_lattice import VPCLatticeEventV2 +from tests.functional.utils import load_event + + +def test_vpc_lattice_v2_event(): + raw_event = load_event("vpcLatticeV2Event.json") + parsed_event = VPCLatticeEventV2(raw_event) + + assert parsed_event.path == raw_event["path"] + assert parsed_event.get_query_string_value("order-id") == "1" + assert parsed_event.get_header_value("user_agent") == "curl/7.64.1" + assert parsed_event.decoded_body == '{"message": "Hello from Lambda!"}' + assert parsed_event.json_body == {"message": "Hello from Lambda!"} + assert parsed_event.method == raw_event["method"] + assert parsed_event.headers == raw_event["headers"] + assert parsed_event.query_string_parameters == raw_event["queryStringParameters"] + assert parsed_event.body == raw_event["body"] + assert parsed_event.is_base64_encoded == raw_event["isBase64Encoded"] + assert parsed_event.request_context.region == raw_event["requestContext"]["region"] + assert parsed_event.request_context.service_network_arn == raw_event["requestContext"]["serviceNetworkArn"] + assert parsed_event.request_context.service_arn == raw_event["requestContext"]["serviceArn"] + assert parsed_event.request_context.target_group_arn == raw_event["requestContext"]["targetGroupArn"] + assert parsed_event.request_context.time_epoch == raw_event["requestContext"]["timeEpoch"] + assert ( + parsed_event.request_context.identity.source_vpc_arn == raw_event["requestContext"]["identity"]["sourceVpcArn"] + ) + assert parsed_event.request_context.identity.get_type == raw_event["requestContext"]["identity"]["type"] + assert parsed_event.request_context.identity.principal == raw_event["requestContext"]["identity"]["principal"] + assert parsed_event.request_context.identity.session_name == raw_event["requestContext"]["identity"]["sessionName"] + assert parsed_event.request_context.identity.x509_san_dns == raw_event["requestContext"]["identity"]["x509SanDns"] + assert parsed_event.request_context.identity.x509_issuer_ou is None + assert parsed_event.request_context.identity.x509_san_name_cn is None + assert parsed_event.request_context.identity.x509_san_uri is None + assert parsed_event.request_context.identity.x509_subject_cn is None + assert parsed_event.request_context.identity.principal_org_id is None diff --git a/tests/unit/parser/test_vpc_latticev2.py b/tests/unit/parser/test_vpc_latticev2.py new file mode 100644 index 00000000000..78d93fde041 --- /dev/null +++ b/tests/unit/parser/test_vpc_latticev2.py @@ -0,0 +1,67 @@ +import pytest + +from aws_lambda_powertools.utilities.parser import ValidationError, envelopes, parse +from aws_lambda_powertools.utilities.parser.models import VpcLatticeV2Model +from tests.functional.utils import load_event +from tests.unit.parser.schemas import MyVpcLatticeBusiness + + +def test_vpc_lattice_v2_event_with_envelope(): + raw_event = load_event("vpcLatticeV2Event.json") + raw_event["body"] = '{"username": "Stephen", "name": "Bawks"}' + parsed_event: MyVpcLatticeBusiness = parse( + event=raw_event, + model=MyVpcLatticeBusiness, + envelope=envelopes.VpcLatticeV2Envelope, + ) + + assert parsed_event.username == "Stephen" + assert parsed_event.name == "Bawks" + + +def test_vpc_lattice_v2_event(): + raw_event = load_event("vpcLatticeV2Event.json") + model = VpcLatticeV2Model(**raw_event) + + assert model.body == raw_event["body"] + assert model.method == raw_event["method"] + assert model.path == raw_event["path"] + assert model.is_base64_encoded == raw_event["isBase64Encoded"] + assert model.headers == raw_event["headers"] + assert model.query_string_parameters == raw_event["queryStringParameters"] + assert model.request_context.region == raw_event["requestContext"]["region"] + assert model.request_context.service_network_arn == raw_event["requestContext"]["serviceNetworkArn"] + assert model.request_context.service_arn == raw_event["requestContext"]["serviceArn"] + assert model.request_context.target_group_arn == raw_event["requestContext"]["targetGroupArn"] + assert model.request_context.time_epoch == float(raw_event["requestContext"]["timeEpoch"]) + convert_time = int((model.request_context.time_epoch_as_datetime.timestamp() * 1000)) + event_converted_time = round(int(raw_event["requestContext"]["timeEpoch"]) / 1000) + assert convert_time == event_converted_time + assert model.request_context.identity.source_vpc_arn == raw_event["requestContext"]["identity"]["sourceVpcArn"] + assert model.request_context.identity.get_type == raw_event["requestContext"]["identity"]["type"] + assert model.request_context.identity.principal == raw_event["requestContext"]["identity"]["principal"] + assert model.request_context.identity.session_name == raw_event["requestContext"]["identity"]["sessionName"] + assert model.request_context.identity.x509_san_dns == raw_event["requestContext"]["identity"]["x509SanDns"] + assert model.request_context.identity.x509_issuer_ou is None + assert model.request_context.identity.x509_san_name_cn is None + assert model.request_context.identity.x509_san_uri is None + assert model.request_context.identity.x509_subject_cn is None + assert model.request_context.identity.principal_org_id is None + + +def test_vpc_lattice_v2_event_custom_model(): + class MyCustomResource(VpcLatticeV2Model): + body: str + + raw_event = load_event("vpcLatticeV2Event.json") + model = MyCustomResource(**raw_event) + + assert model.body == raw_event["body"] + + +def test_vpc_lattice_v2_event_invalid(): + raw_event = load_event("vpcLatticeV2Event.json") + raw_event["body"] = ["some_more_data"] + + with pytest.raises(ValidationError): + VpcLatticeV2Model(**raw_event)