diff --git a/aws_lambda_powertools/utilities/data_classes/__init__.py b/aws_lambda_powertools/utilities/data_classes/__init__.py new file mode 100644 index 00000000000..47ca29c2148 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/__init__.py @@ -0,0 +1,24 @@ +from .alb_event import ALBEvent +from .api_gateway_proxy_event import APIGatewayProxyEvent, APIGatewayProxyEventV2 +from .cloud_watch_logs_event import CloudWatchLogsEvent +from .dynamo_db_stream_event import DynamoDBStreamEvent +from .event_bridge_event import EventBridgeEvent +from .kinesis_stream_event import KinesisStreamEvent +from .s3_event import S3Event +from .ses_event import SESEvent +from .sns_event import SNSEvent +from .sqs_event import SQSEvent + +__all__ = [ + "APIGatewayProxyEvent", + "APIGatewayProxyEventV2", + "ALBEvent", + "CloudWatchLogsEvent", + "DynamoDBStreamEvent", + "EventBridgeEvent", + "KinesisStreamEvent", + "S3Event", + "SESEvent", + "SNSEvent", + "SQSEvent", +] diff --git a/aws_lambda_powertools/utilities/data_classes/alb_event.py b/aws_lambda_powertools/utilities/data_classes/alb_event.py new file mode 100644 index 00000000000..5de23dc3ab0 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/alb_event.py @@ -0,0 +1,38 @@ +from typing import Dict, List, Optional + +from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent, DictWrapper + + +class ALBEventRequestContext(DictWrapper): + @property + def elb_target_group_arn(self) -> str: + return self["requestContext"]["elb"]["targetGroupArn"] + + +class ALBEvent(BaseProxyEvent): + """Application load balancer event + + Documentation: + -------------- + - https://docs.aws.amazon.com/lambda/latest/dg/services-alb.html + """ + + @property + def request_context(self) -> ALBEventRequestContext: + return ALBEventRequestContext(self) + + @property + def http_method(self) -> str: + return self["httpMethod"] + + @property + def path(self) -> str: + return self["path"] + + @property + def multi_value_query_string_parameters(self) -> Optional[Dict[str, List[str]]]: + return self.get("multiValueQueryStringParameters") + + @property + def multi_value_headers(self) -> Optional[Dict[str, List[str]]]: + return self.get("multiValueHeaders") diff --git a/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py b/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py new file mode 100644 index 00000000000..a253348fac4 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py @@ -0,0 +1,382 @@ +from typing import Any, Dict, List, Optional + +from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent, DictWrapper + + +class APIGatewayEventIdentity(DictWrapper): + @property + def access_key(self) -> Optional[str]: + return self["requestContext"]["identity"].get("accessKey") + + @property + def account_id(self) -> Optional[str]: + """The AWS account ID associated with the request.""" + return self["requestContext"]["identity"].get("accountId") + + @property + def api_key(self) -> Optional[str]: + """For API methods that require an API key, this variable is the API key associated with the method request. + For methods that don't require an API key, this variable is null. """ + return self["requestContext"]["identity"].get("apiKey") + + @property + def api_key_id(self) -> Optional[str]: + """The API key ID associated with an API request that requires an API key.""" + return self["requestContext"]["identity"].get("apiKeyId") + + @property + def caller(self) -> Optional[str]: + """The principal identifier of the caller making the request.""" + return self["requestContext"]["identity"].get("caller") + + @property + def cognito_authentication_provider(self) -> Optional[str]: + """A comma-separated list of the Amazon Cognito authentication providers used by the caller + making the request. Available only if the request was signed with Amazon Cognito credentials.""" + return self["requestContext"]["identity"].get("cognitoAuthenticationProvider") + + @property + def cognito_authentication_type(self) -> Optional[str]: + """The Amazon Cognito authentication type of the caller making the request. + Available only if the request was signed with Amazon Cognito credentials.""" + return self["requestContext"]["identity"].get("cognitoAuthenticationType") + + @property + def cognito_identity_id(self) -> Optional[str]: + """The Amazon Cognito identity ID of the caller making the request. + Available only if the request was signed with Amazon Cognito credentials.""" + return self["requestContext"]["identity"].get("cognitoIdentityId") + + @property + def cognito_identity_pool_id(self) -> Optional[str]: + """The Amazon Cognito identity pool ID of the caller making the request. + Available only if the request was signed with Amazon Cognito credentials.""" + return self["requestContext"]["identity"].get("cognitoIdentityPoolId") + + @property + def principal_org_id(self) -> Optional[str]: + """The AWS organization ID.""" + return self["requestContext"]["identity"].get("principalOrgId") + + @property + def source_ip(self) -> str: + """The source IP address of the TCP connection making the request to API Gateway.""" + return self["requestContext"]["identity"]["sourceIp"] + + @property + def user(self) -> Optional[str]: + """The principal identifier of the user making the request.""" + return self["requestContext"]["identity"].get("user") + + @property + def user_agent(self) -> Optional[str]: + """The User Agent of the API caller.""" + return self["requestContext"]["identity"].get("userAgent") + + @property + def user_arn(self) -> Optional[str]: + """The Amazon Resource Name (ARN) of the effective user identified after authentication.""" + return self["requestContext"]["identity"].get("userArn") + + +class APIGatewayEventAuthorizer(DictWrapper): + @property + def claims(self) -> Optional[Dict[str, Any]]: + return self["requestContext"]["authorizer"].get("claims") + + @property + def scopes(self) -> Optional[List[str]]: + return self["requestContext"]["authorizer"].get("scopes") + + +class APIGatewayEventRequestContext(DictWrapper): + @property + def account_id(self) -> str: + """The AWS account ID associated with the request.""" + return self["requestContext"]["accountId"] + + @property + def api_id(self) -> str: + """The identifier API Gateway assigns to your API.""" + return self["requestContext"]["apiId"] + + @property + def authorizer(self) -> APIGatewayEventAuthorizer: + return APIGatewayEventAuthorizer(self._data) + + @property + def connected_at(self) -> Optional[int]: + """The Epoch-formatted connection time. (WebSocket API)""" + return self["requestContext"].get("connectedAt") + + @property + def connection_id(self) -> Optional[str]: + """A unique ID for the connection that can be used to make a callback to the client. (WebSocket API)""" + return self["requestContext"].get("connectionId") + + @property + def domain_name(self) -> Optional[str]: + """A domain name""" + return self["requestContext"].get("domainName") + + @property + def domain_prefix(self) -> Optional[str]: + return self["requestContext"].get("domainPrefix") + + @property + def event_type(self) -> Optional[str]: + """The event type: `CONNECT`, `MESSAGE`, or `DISCONNECT`. (WebSocket API)""" + return self["requestContext"].get("eventType") + + @property + def extended_request_id(self) -> Optional[str]: + """An automatically generated ID for the API call, which contains more useful information + for debugging/troubleshooting.""" + return self["requestContext"].get("extendedRequestId") + + @property + def protocol(self) -> str: + """The request protocol, for example, HTTP/1.1.""" + return self["requestContext"]["protocol"] + + @property + def http_method(self) -> str: + """The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT.""" + return self["requestContext"]["httpMethod"] + + @property + def identity(self) -> APIGatewayEventIdentity: + return APIGatewayEventIdentity(self._data) + + @property + def message_direction(self) -> Optional[str]: + """Message direction (WebSocket API)""" + return self["requestContext"].get("messageDirection") + + @property + def message_id(self) -> Optional[str]: + """A unique server-side ID for a message. Available only when the `eventType` is `MESSAGE`.""" + return self["requestContext"].get("messageId") + + @property + def path(self) -> str: + return self["requestContext"]["path"] + + @property + def stage(self) -> str: + """The deployment stage of the API request """ + return self["requestContext"]["stage"] + + @property + def request_id(self) -> str: + """The ID that API Gateway assigns to the API request.""" + return self["requestContext"]["requestId"] + + @property + def request_time(self) -> Optional[str]: + """The CLF-formatted request time (dd/MMM/yyyy:HH:mm:ss +-hhmm)""" + return self["requestContext"].get("requestTime") + + @property + def request_time_epoch(self) -> int: + """The Epoch-formatted request time.""" + return self["requestContext"]["requestTimeEpoch"] + + @property + def resource_id(self) -> str: + return self["requestContext"]["resourceId"] + + @property + def resource_path(self) -> str: + return self["requestContext"]["resourcePath"] + + @property + def route_key(self) -> Optional[str]: + """The selected route key.""" + return self["requestContext"].get("routeKey") + + +class APIGatewayProxyEvent(BaseProxyEvent): + """AWS Lambda proxy V1 + + Documentation: + -------------- + - https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html + """ + + @property + def version(self) -> str: + return self["version"] + + @property + def resource(self) -> str: + return self["resource"] + + @property + def path(self) -> str: + return self["path"] + + @property + def http_method(self) -> str: + """The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT.""" + return self["httpMethod"] + + @property + def multi_value_headers(self) -> Dict[str, List[str]]: + return self["multiValueHeaders"] + + @property + def multi_value_query_string_parameters(self) -> Optional[Dict[str, List[str]]]: + return self.get("multiValueQueryStringParameters") + + @property + def request_context(self) -> APIGatewayEventRequestContext: + return APIGatewayEventRequestContext(self) + + @property + def path_parameters(self) -> Optional[Dict[str, str]]: + return self.get("pathParameters") + + @property + def stage_variables(self) -> Optional[Dict[str, str]]: + return self.get("stageVariables") + + +class RequestContextV2Http(DictWrapper): + @property + def method(self) -> str: + return self["requestContext"]["http"]["method"] + + @property + def path(self) -> str: + return self["requestContext"]["http"]["path"] + + @property + def protocol(self) -> str: + """The request protocol, for example, HTTP/1.1.""" + return self["requestContext"]["http"]["protocol"] + + @property + def source_ip(self) -> str: + """The source IP address of the TCP connection making the request to API Gateway.""" + return self["requestContext"]["http"]["sourceIp"] + + @property + def user_agent(self) -> str: + """The User Agent of the API caller.""" + return self["requestContext"]["http"]["userAgent"] + + +class RequestContextV2Authorizer(DictWrapper): + @property + def jwt_claim(self) -> Dict[str, Any]: + return self["jwt"]["claims"] + + @property + def jwt_scopes(self) -> List[str]: + return self["jwt"]["scopes"] + + +class RequestContextV2(DictWrapper): + @property + def account_id(self) -> str: + """The AWS account ID associated with the request.""" + return self["requestContext"]["accountId"] + + @property + def api_id(self) -> str: + """The identifier API Gateway assigns to your API.""" + return self["requestContext"]["apiId"] + + @property + def authorizer(self) -> Optional[RequestContextV2Authorizer]: + authorizer = self["requestContext"].get("authorizer") + return None if authorizer is None else RequestContextV2Authorizer(authorizer) + + @property + def domain_name(self) -> str: + """A domain name """ + return self["requestContext"]["domainName"] + + @property + def domain_prefix(self) -> str: + return self["requestContext"]["domainPrefix"] + + @property + def http(self) -> RequestContextV2Http: + return RequestContextV2Http(self._data) + + @property + def request_id(self) -> str: + """The ID that API Gateway assigns to the API request.""" + return self["requestContext"]["requestId"] + + @property + def route_key(self) -> str: + """The selected route key.""" + return self["requestContext"]["routeKey"] + + @property + def stage(self) -> str: + """The deployment stage of the API request """ + return self["requestContext"]["stage"] + + @property + def time(self) -> str: + """The CLF-formatted request time (dd/MMM/yyyy:HH:mm:ss +-hhmm).""" + return self["requestContext"]["time"] + + @property + def time_epoch(self) -> int: + """The Epoch-formatted request time.""" + return self["requestContext"]["timeEpoch"] + + +class APIGatewayProxyEventV2(BaseProxyEvent): + """AWS Lambda proxy V2 event + + Notes: + ----- + Format 2.0 doesn't have multiValueHeaders or multiValueQueryStringParameters fields. Duplicate headers + are combined with commas and included in the headers field. Duplicate query strings are combined with + commas and included in the queryStringParameters field. + + Format 2.0 includes a new cookies field. All cookie headers in the request are combined with commas and + added to the cookies field. In the response to the client, each cookie becomes a set-cookie header. + + Documentation: + -------------- + - https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html + """ + + @property + def version(self) -> str: + return self["version"] + + @property + def route_key(self) -> str: + return self["routeKey"] + + @property + def raw_path(self) -> str: + return self["rawPath"] + + @property + def raw_query_string(self) -> str: + return self["rawQueryString"] + + @property + def cookies(self) -> Optional[List[str]]: + return self.get("cookies") + + @property + def request_context(self) -> RequestContextV2: + return RequestContextV2(self) + + @property + def path_parameters(self) -> Optional[Dict[str, str]]: + return self.get("pathParameters") + + @property + def stage_variables(self) -> Optional[Dict[str, str]]: + return self.get("stageVariables") diff --git a/aws_lambda_powertools/utilities/data_classes/cloud_watch_logs_event.py b/aws_lambda_powertools/utilities/data_classes/cloud_watch_logs_event.py new file mode 100644 index 00000000000..978f6956fc2 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/cloud_watch_logs_event.py @@ -0,0 +1,101 @@ +import base64 +import json +import zlib +from typing import Dict, List, Optional + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class CloudWatchLogsLogEvent(DictWrapper): + @property + def get_id(self) -> str: + """The ID property is a unique identifier for every log event.""" + # Note: this name conflicts with existing python builtins + return self["id"] + + @property + def timestamp(self) -> int: + """Get the `timestamp` property""" + return self["timestamp"] + + @property + def message(self) -> str: + """Get the `message` property""" + return self["message"] + + @property + def extracted_fields(self) -> Optional[Dict[str, str]]: + """Get the `extractedFields` property""" + return self.get("extractedFields") + + +class CloudWatchLogsDecodedData(DictWrapper): + @property + def owner(self) -> str: + """The AWS Account ID of the originating log data.""" + return self["owner"] + + @property + def log_group(self) -> str: + """The log group name of the originating log data.""" + return self["logGroup"] + + @property + def log_stream(self) -> str: + """The log stream name of the originating log data.""" + return self["logStream"] + + @property + def subscription_filters(self) -> List[str]: + """The list of subscription filter names that matched with the originating log data.""" + return self["subscriptionFilters"] + + @property + def message_type(self) -> str: + """Data messages will use the "DATA_MESSAGE" type. + + Sometimes CloudWatch Logs may emit Kinesis records with a "CONTROL_MESSAGE" type, + mainly for checking if the destination is reachable. + """ + return self["messageType"] + + @property + def log_events(self) -> List[CloudWatchLogsLogEvent]: + """The actual log data, represented as an array of log event records. + + The ID property is a unique identifier for every log event. + """ + return [CloudWatchLogsLogEvent(i) for i in self["logEvents"]] + + +class CloudWatchLogsEvent(DictWrapper): + """CloudWatch Logs log stream event + + You can use a Lambda function to monitor and analyze logs from an Amazon CloudWatch Logs log stream. + + Documentation: + -------------- + - https://docs.aws.amazon.com/lambda/latest/dg/services-cloudwatchlogs.html + """ + + _decompressed_logs_data = None + _json_logs_data = None + + @property + def raw_logs_data(self) -> str: + """The value of the `data` field is a Base64 encoded ZIP archive.""" + return self["awslogs"]["data"] + + @property + def decompress_logs_data(self) -> bytes: + """Decode and decompress log data""" + if self._decompressed_logs_data is None: + payload = base64.b64decode(self.raw_logs_data) + self._decompressed_logs_data = zlib.decompress(payload, zlib.MAX_WBITS | 32) + return self._decompressed_logs_data + + def parse_logs_data(self) -> CloudWatchLogsDecodedData: + """Decode, decompress and parse json data as CloudWatchLogsDecodedData""" + if self._json_logs_data is None: + self._json_logs_data = json.loads(self.decompress_logs_data.decode("UTF-8")) + return CloudWatchLogsDecodedData(self._json_logs_data) diff --git a/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py b/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py new file mode 100644 index 00000000000..7bf38715006 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py @@ -0,0 +1,560 @@ +from typing import Any, Dict, List, Optional + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class CallerContext(DictWrapper): + @property + def aws_sdk_version(self) -> str: + """The AWS SDK version number.""" + return self["callerContext"]["awsSdkVersion"] + + @property + def client_id(self) -> str: + """The ID of the client associated with the user pool.""" + return self["callerContext"]["clientId"] + + +class BaseTriggerEvent(DictWrapper): + """Common attributes shared by all User Pool Lambda Trigger Events + + Documentation: + ------------- + https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html + """ + + @property + def version(self) -> str: + """The version number of your Lambda function.""" + return self["version"] + + @property + def region(self) -> str: + """The AWS Region, as an AWSRegion instance.""" + return self["region"] + + @property + def user_pool_id(self) -> str: + """The user pool ID for the user pool.""" + return self["userPoolId"] + + @property + def trigger_source(self) -> str: + """The name of the event that triggered the Lambda function.""" + return self["triggerSource"] + + @property + def user_name(self) -> str: + """The username of the current user.""" + return self["userName"] + + @property + def caller_context(self) -> CallerContext: + """The caller context""" + return CallerContext(self) + + +class PreSignUpTriggerEventRequest(DictWrapper): + @property + def user_attributes(self) -> Dict[str, str]: + """One or more name-value pairs representing user attributes. The attribute names are the keys.""" + return self["request"]["userAttributes"] + + @property + def validation_data(self) -> Optional[Dict[str, str]]: + """One or more name-value pairs containing the validation data in the request to register a user.""" + return self["request"].get("validationData") + + @property + def client_metadata(self) -> Optional[Dict[str, str]]: + """One or more key-value pairs that you can provide as custom input to the Lambda function + that you specify for the pre sign-up data_classes.""" + return self["request"].get("clientMetadata") + + +class PreSignUpTriggerEventResponse(DictWrapper): + @property + def auto_confirm_user(self) -> bool: + return bool(self["response"]["autoConfirmUser"]) + + @auto_confirm_user.setter + def auto_confirm_user(self, value: bool): + """Set to true to auto-confirm the user, or false otherwise.""" + self["response"]["autoConfirmUser"] = value + + @property + def auto_verify_email(self) -> bool: + return bool(self["response"]["autoVerifyEmail"]) + + @auto_verify_email.setter + def auto_verify_email(self, value: bool): + """Set to true to set as verified the email of a user who is signing up, or false otherwise.""" + self["response"]["autoVerifyEmail"] = value + + @property + def auto_verify_phone(self) -> bool: + return bool(self["response"]["autoVerifyPhone"]) + + @auto_verify_phone.setter + def auto_verify_phone(self, value: bool): + """Set to true to set as verified the phone number of a user who is signing up, or false otherwise.""" + self["response"]["autoVerifyPhone"] = value + + +class PreSignUpTriggerEvent(BaseTriggerEvent): + """Pre Sign-up Lambda Trigger + + Notes: + ---- + `triggerSource` can be one of the following: + + - `PreSignUp_SignUp` Pre sign-up. + - `PreSignUp_AdminCreateUser` Pre sign-up when an admin creates a new user. + - `PreSignUp_ExternalProvider` Pre sign-up with external provider + + Documentation: + ------------- + - https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-sign-up.html + """ + + @property + def request(self) -> PreSignUpTriggerEventRequest: + return PreSignUpTriggerEventRequest(self) + + @property + def response(self) -> PreSignUpTriggerEventResponse: + return PreSignUpTriggerEventResponse(self) + + +class PostConfirmationTriggerEventRequest(DictWrapper): + @property + def user_attributes(self) -> Dict[str, str]: + """One or more name-value pairs representing user attributes. The attribute names are the keys.""" + return self["request"]["userAttributes"] + + @property + def client_metadata(self) -> Optional[Dict[str, str]]: + """One or more key-value pairs that you can provide as custom input to the Lambda function + that you specify for the post confirmation data_classes.""" + return self["request"].get("clientMetadata") + + +class PostConfirmationTriggerEvent(BaseTriggerEvent): + """Post Confirmation Lambda Trigger + + Notes: + ---- + `triggerSource` can be one of the following: + + - `PostConfirmation_ConfirmSignUp` Post sign-up confirmation. + - `PostConfirmation_ConfirmForgotPassword` Post Forgot Password confirmation. + + Documentation: + ------------- + - https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-post-confirmation.html + """ + + @property + def request(self) -> PostConfirmationTriggerEventRequest: + return PostConfirmationTriggerEventRequest(self) + + +class UserMigrationTriggerEventRequest(DictWrapper): + @property + def password(self) -> str: + return self["request"]["password"] + + @property + def validation_data(self) -> Optional[Dict[str, str]]: + """One or more name-value pairs containing the validation data in the request to register a user.""" + return self["request"].get("validationData") + + @property + def client_metadata(self) -> Optional[Dict[str, str]]: + """One or more key-value pairs that you can provide as custom input to the Lambda function + that you specify for the pre sign-up data_classes.""" + return self["request"].get("clientMetadata") + + +class UserMigrationTriggerEventResponse(DictWrapper): + @property + def user_attributes(self) -> Dict[str, str]: + return self["response"]["userAttributes"] + + @user_attributes.setter + def user_attributes(self, value: Dict[str, str]): + """It must contain one or more name-value pairs representing user attributes to be stored in the + user profile in your user pool. You can include both standard and custom user attributes. + Custom attributes require the custom: prefix to distinguish them from standard attributes.""" + self["response"]["userAttributes"] = value + + @property + def final_user_status(self) -> Optional[str]: + return self["response"].get("finalUserStatus") + + @final_user_status.setter + def final_user_status(self, value: str): + """During sign-in, this attribute can be set to CONFIRMED, or not set, to auto-confirm your users and + allow them to sign-in with their previous passwords. This is the simplest experience for the user. + + If this attribute is set to RESET_REQUIRED, the user is required to change his or her password immediately + after migration at the time of sign-in, and your client app needs to handle the PasswordResetRequiredException + during the authentication flow.""" + self["response"]["finalUserStatus"] = value + + @property + def message_action(self) -> Optional[str]: + return self["response"].get("messageAction") + + @message_action.setter + def message_action(self, value: str): + """This attribute can be set to "SUPPRESS" to suppress the welcome message usually sent by + Amazon Cognito to new users. If this attribute is not returned, the welcome message will be sent.""" + self["response"]["messageAction"] = value + + @property + def desired_delivery_mediums(self) -> Optional[List[str]]: + return self["response"].get("desiredDeliveryMediums") + + @desired_delivery_mediums.setter + def desired_delivery_mediums(self, value: List[str]): + """This attribute can be set to "EMAIL" to send the welcome message by email, or "SMS" to send the + welcome message by SMS. If this attribute is not returned, the welcome message will be sent by SMS.""" + self["response"]["desiredDeliveryMediums"] = value + + @property + def force_alias_creation(self) -> Optional[bool]: + return self["response"].get("forceAliasCreation") + + @force_alias_creation.setter + def force_alias_creation(self, value: bool): + """If this parameter is set to "true" and the phone number or email address specified in the UserAttributes + parameter already exists as an alias with a different user, the API call will migrate the alias from the + previous user to the newly created user. The previous user will no longer be able to log in using that alias. + + If this attribute is set to "false" and the alias exists, the user will not be migrated, and an error is + returned to the client app. + + If this attribute is not returned, it is assumed to be "false". + """ + self["response"]["forceAliasCreation"] = value + + +class UserMigrationTriggerEvent(BaseTriggerEvent): + """Migrate User Lambda Trigger + + Notes: + ---- + `triggerSource` can be one of the following: + + - `UserMigration_Authentication` User migration at the time of sign in. + - `UserMigration_ForgotPassword` User migration during forgot-password flow. + + Documentation: + ------------- + - https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-migrate-user.html + """ + + @property + def request(self) -> UserMigrationTriggerEventRequest: + return UserMigrationTriggerEventRequest(self) + + @property + def response(self) -> UserMigrationTriggerEventResponse: + return UserMigrationTriggerEventResponse(self) + + +class CustomMessageTriggerEventRequest(DictWrapper): + @property + def code_parameter(self) -> str: + """A string for you to use as the placeholder for the verification code in the custom message.""" + return self["request"]["codeParameter"] + + @property + def username_parameter(self) -> str: + """The username parameter. It is a required request parameter for the admin create user flow.""" + return self["request"]["usernameParameter"] + + @property + def user_attributes(self) -> Dict[str, str]: + """One or more name-value pairs representing user attributes. The attribute names are the keys.""" + return self["request"]["userAttributes"] + + @property + def client_metadata(self) -> Optional[Dict[str, str]]: + """One or more key-value pairs that you can provide as custom input to the Lambda function + that you specify for the pre sign-up data_classes.""" + return self["request"].get("clientMetadata") + + +class CustomMessageTriggerEventResponse(DictWrapper): + @property + def sms_message(self) -> str: + return self["response"]["smsMessage"] + + @property + def email_message(self) -> str: + return self["response"]["emailMessage"] + + @property + def email_subject(self) -> str: + return self["response"]["emailSubject"] + + @sms_message.setter + def sms_message(self, value: str): + """The custom SMS message to be sent to your users. + Must include the codeParameter value received in the request.""" + self["response"]["smsMessage"] = value + + @email_message.setter + def email_message(self, value: str): + """The custom email message to be sent to your users. + Must include the codeParameter value received in the request.""" + self["response"]["emailMessage"] = value + + @email_subject.setter + def email_subject(self, value: str): + """The subject line for the custom message.""" + self["response"]["emailSubject"] = value + + +class CustomMessageTriggerEvent(BaseTriggerEvent): + """Custom Message Lambda Trigger + + Notes: + ---- + `triggerSource` can be one of the following: + + - `CustomMessage_SignUp` To send the confirmation code post sign-up. + - `CustomMessage_AdminCreateUser` To send the temporary password to a new user. + - `CustomMessage_ResendCode` To resend the confirmation code to an existing user. + - `CustomMessage_ForgotPassword` To send the confirmation code for Forgot Password request. + - `CustomMessage_UpdateUserAttribute` When a user's email or phone number is changed, this data_classes sends a + verification code automatically to the user. Cannot be used for other attributes. + - `CustomMessage_VerifyUserAttribute` This data_classes sends a verification code to the user when they manually + request it for a new email or phone number. + - `CustomMessage_Authentication` To send MFA code during authentication. + + Documentation: + -------------- + - https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-message.html + """ + + @property + def request(self) -> CustomMessageTriggerEventRequest: + return CustomMessageTriggerEventRequest(self) + + @property + def response(self) -> CustomMessageTriggerEventResponse: + return CustomMessageTriggerEventResponse(self) + + +class PreAuthenticationTriggerEventRequest(DictWrapper): + @property + def user_not_found(self) -> Optional[bool]: + """This boolean is populated when PreventUserExistenceErrors is set to ENABLED for your User Pool client.""" + return self["request"].get("userNotFound") + + @property + def user_attributes(self) -> Dict[str, str]: + """One or more name-value pairs representing user attributes.""" + return self["request"]["userAttributes"] + + @property + def validation_data(self) -> Optional[Dict[str, str]]: + """One or more key-value pairs containing the validation data in the user's sign-in request.""" + return self["request"].get("validationData") + + +class PreAuthenticationTriggerEvent(BaseTriggerEvent): + """Pre Authentication Lambda Trigger + + Amazon Cognito invokes this data_classes when a user attempts to sign in, allowing custom validation + to accept or deny the authentication request. + + Notes: + ---- + `triggerSource` can be one of the following: + + - `PreAuthentication_Authentication` Pre authentication. + + Documentation: + -------------- + - https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-authentication.html + """ + + @property + def request(self) -> PreAuthenticationTriggerEventRequest: + """Pre Authentication Request Parameters""" + return PreAuthenticationTriggerEventRequest(self) + + +class PostAuthenticationTriggerEventRequest(DictWrapper): + @property + def new_device_used(self) -> bool: + """This flag indicates if the user has signed in on a new device. + It is set only if the remembered devices value of the user pool is set to `Always` or User `Opt-In`.""" + return self["request"]["newDeviceUsed"] + + @property + def user_attributes(self) -> Dict[str, str]: + """One or more name-value pairs representing user attributes.""" + return self["request"]["userAttributes"] + + @property + def client_metadata(self) -> Optional[Dict[str, str]]: + """One or more key-value pairs that you can provide as custom input to the Lambda function + that you specify for the post authentication data_classes.""" + return self["request"].get("clientMetadata") + + +class PostAuthenticationTriggerEvent(BaseTriggerEvent): + """Post Authentication Lambda Trigger + + Amazon Cognito invokes this data_classes after signing in a user, allowing you to add custom logic + after authentication. + + Notes: + ---- + `triggerSource` can be one of the following: + + - `PostAuthentication_Authentication` Post authentication. + + Documentation: + -------------- + - https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-post-authentication.html + """ + + @property + def request(self) -> PostAuthenticationTriggerEventRequest: + """Post Authentication Request Parameters""" + return PostAuthenticationTriggerEventRequest(self) + + +class GroupOverrideDetails(DictWrapper): + @property + def groups_to_override(self) -> Optional[List[str]]: + """A list of the group names that are associated with the user that the identity token is issued for.""" + return self.get("groupsToOverride") + + @property + def iam_roles_to_override(self) -> Optional[List[str]]: + """A list of the current IAM roles associated with these groups.""" + return self.get("iamRolesToOverride") + + @property + def preferred_role(self) -> Optional[str]: + """A string indicating the preferred IAM role.""" + return self.get("preferredRole") + + +class PreTokenGenerationTriggerEventRequest(DictWrapper): + @property + def group_configuration(self) -> GroupOverrideDetails: + """The input object containing the current group configuration""" + return GroupOverrideDetails(self["request"]["groupConfiguration"]) + + @property + def user_attributes(self) -> Dict[str, str]: + """One or more name-value pairs representing user attributes.""" + return self["request"]["userAttributes"] + + @property + def client_metadata(self) -> Optional[Dict[str, str]]: + """One or more key-value pairs that you can provide as custom input to the Lambda function + that you specify for the pre token generation data_classes.""" + return self["request"].get("clientMetadata") + + +class ClaimsOverrideDetails(DictWrapper): + @property + def claims_to_add_or_override(self) -> Optional[Dict[str, str]]: + return self.get("claimsToAddOrOverride") + + @property + def claims_to_suppress(self) -> Optional[List[str]]: + return self.get("claimsToSuppress") + + @property + def group_configuration(self) -> Optional[GroupOverrideDetails]: + group_override_details = self.get("groupOverrideDetails") + return None if group_override_details is None else GroupOverrideDetails(group_override_details) + + @claims_to_add_or_override.setter + def claims_to_add_or_override(self, value: Dict[str, str]): + """A map of one or more key-value pairs of claims to add or override. + For group related claims, use groupOverrideDetails instead.""" + self._data["claimsToAddOrOverride"] = value + + @claims_to_suppress.setter + def claims_to_suppress(self, value: List[str]): + """A list that contains claims to be suppressed from the identity token.""" + self._data["claimsToSuppress"] = value + + @group_configuration.setter + def group_configuration(self, value: Dict[str, Any]): + """The output object containing the current group configuration. + + It includes groupsToOverride, iamRolesToOverride, and preferredRole. + + The groupOverrideDetails object is replaced with the one you provide. If you provide an empty or null + object in the response, then the groups are suppressed. To leave the existing group configuration + as is, copy the value of the request's groupConfiguration object to the groupOverrideDetails object + in the response, and pass it back to the service. + """ + self._data["groupOverrideDetails"] = value + + def set_group_configuration_groups_to_override(self, value: List[str]): + """A list of the group names that are associated with the user that the identity token is issued for.""" + self._data.setdefault("groupOverrideDetails", {}) + self["groupOverrideDetails"]["groupsToOverride"] = value + + def set_group_configuration_iam_roles_to_override(self, value: List[str]): + """A list of the current IAM roles associated with these groups.""" + self._data.setdefault("groupOverrideDetails", {}) + self["groupOverrideDetails"]["iamRolesToOverride"] = value + + def set_group_configuration_preferred_role(self, value: str): + """A string indicating the preferred IAM role.""" + self._data.setdefault("groupOverrideDetails", {}) + self["groupOverrideDetails"]["preferredRole"] = value + + +class PreTokenGenerationTriggerEventResponse(DictWrapper): + @property + def claims_override_details(self) -> ClaimsOverrideDetails: + # Ensure we have a `claimsOverrideDetails` element + self._data["response"].setdefault("claimsOverrideDetails", {}) + return ClaimsOverrideDetails(self._data["response"]["claimsOverrideDetails"]) + + +class PreTokenGenerationTriggerEvent(BaseTriggerEvent): + """Pre Token Generation Lambda Trigger + + Amazon Cognito invokes this data_classes before token generation allowing you to customize identity token claims. + + Notes: + ---- + `triggerSource` can be one of the following: + + - `TokenGeneration_HostedAuth` Called during authentication from the Amazon Cognito hosted UI sign-in page. + - `TokenGeneration_Authentication` Called after user authentication flows have completed. + - `TokenGeneration_NewPasswordChallenge` Called after the user is created by an admin. This flow is invoked + when the user has to change a temporary password. + - `TokenGeneration_AuthenticateDevice` Called at the end of the authentication of a user device. + - `TokenGeneration_RefreshTokens` Called when a user tries to refresh the identity and access tokens. + + Documentation: + -------------- + - https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html + """ + + @property + def request(self) -> PreTokenGenerationTriggerEventRequest: + """Pre Token Generation Request Parameters""" + return PreTokenGenerationTriggerEventRequest(self) + + @property + def response(self) -> PreTokenGenerationTriggerEventResponse: + """Pre Token Generation Response Parameters""" + return PreTokenGenerationTriggerEventResponse(self) diff --git a/aws_lambda_powertools/utilities/data_classes/common.py b/aws_lambda_powertools/utilities/data_classes/common.py new file mode 100644 index 00000000000..73cf1b339ff --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/common.py @@ -0,0 +1,65 @@ +from typing import Any, Dict, Optional + + +class DictWrapper: + """Provides a single read only access to a wrapper dict""" + + def __init__(self, data: Dict[str, Any]): + self._data = data + + def __getitem__(self, key: str) -> Any: + return self._data[key] + + def get(self, key: str) -> Optional[Any]: + return self._data.get(key) + + +class BaseProxyEvent(DictWrapper): + @property + def headers(self) -> Dict[str, str]: + return self["headers"] + + @property + def query_string_parameters(self) -> Optional[Dict[str, str]]: + return self.get("queryStringParameters") + + @property + def is_base64_encoded(self) -> bool: + return self.get("isBase64Encoded") + + @property + def body(self) -> Optional[str]: + return self.get("body") + + def get_query_string_value(self, name: str, default_value: Optional[str] = None) -> Optional[str]: + """Get query string value by name + + Parameters + ---------- + name: str + Query string parameter name + default_value: str, optional + Default value if no value was found by name + Returns + ------- + str, optional + Query string parameter value + """ + params = self.query_string_parameters + return default_value if params is None else params.get(name, default_value) + + def get_header_value(self, name: str, default_value: Optional[str] = None) -> Optional[str]: + """Get header value by name + + Parameters + ---------- + name: str + Header name + default_value: str, optional + Default value if no value was found by name + Returns + ------- + str, optional + Header value + """ + return self.headers.get(name, default_value) diff --git a/aws_lambda_powertools/utilities/data_classes/dynamo_db_stream_event.py b/aws_lambda_powertools/utilities/data_classes/dynamo_db_stream_event.py new file mode 100644 index 00000000000..db581ceaf7d --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/dynamo_db_stream_event.py @@ -0,0 +1,232 @@ +from enum import Enum +from typing import Dict, Iterator, List, Optional + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class AttributeValue(DictWrapper): + """Represents the data for an attribute + + Documentation: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_streams_AttributeValue.html + """ + + @property + def b_value(self) -> Optional[str]: + """An attribute of type Base64-encoded binary data object + + Example: + >>> {"B": "dGhpcyB0ZXh0IGlzIGJhc2U2NC1lbmNvZGVk"} + """ + return self.get("B") + + @property + def bs_value(self) -> Optional[List[str]]: + """An attribute of type Array of Base64-encoded binary data objects + + Example: + >>> {"BS": ["U3Vubnk=", "UmFpbnk=", "U25vd3k="]} + """ + return self.get("BS") + + @property + def bool_value(self) -> Optional[bool]: + """An attribute of type Boolean + + Example: + >>> {"BOOL": True} + """ + item = self.get("bool") + return None if item is None else bool(item) + + @property + def list_value(self) -> Optional[List["AttributeValue"]]: + """An attribute of type Array of AttributeValue objects + + Example: + >>> {"L": [ {"S": "Cookies"} , {"S": "Coffee"}, {"N": "3.14159"}]} + """ + item = self.get("L") + return None if item is None else [AttributeValue(v) for v in item] + + @property + def map_value(self) -> Optional[Dict[str, "AttributeValue"]]: + """An attribute of type String to AttributeValue object map + + Example: + >>> {"M": {"Name": {"S": "Joe"}, "Age": {"N": "35"}}} + """ + return _attribute_value_dict(self._data, "M") + + @property + def n_value(self) -> Optional[str]: + """An attribute of type Number + + Numbers are sent across the network to DynamoDB as strings, to maximize compatibility across languages + and libraries. However, DynamoDB treats them as number type attributes for mathematical operations. + + Example: + >>> {"N": "123.45"} + """ + return self.get("N") + + @property + def ns_value(self) -> Optional[List[str]]: + """An attribute of type Number Set + + Example: + >>> {"NS": ["42.2", "-19", "7.5", "3.14"]} + """ + return self.get("NS") + + @property + def null_value(self) -> Optional[bool]: + """An attribute of type Null. + + Example: + >>> {"NULL": True} + """ + item = self.get("NULL") + return None if item is None else bool(item) + + @property + def s_value(self) -> Optional[str]: + """An attribute of type String + + Example: + >>> {"S": "Hello"} + """ + return self.get("S") + + @property + def ss_value(self) -> Optional[List[str]]: + """An attribute of type Array of strings + + Example: + >>> {"SS": ["Giraffe", "Hippo" ,"Zebra"]} + """ + return self.get("SS") + + +def _attribute_value_dict(attr_values: Dict[str, dict], key: str) -> Optional[Dict[str, AttributeValue]]: + """A dict of type String to AttributeValue object map + + Example: + >>> {"NewImage": {"Id": {"S": "xxx-xxx"}, "Value": {"N": "35"}}} + """ + attr_values_dict = attr_values.get(key) + return None if attr_values_dict is None else {k: AttributeValue(v) for k, v in attr_values_dict.items()} + + +class StreamViewType(Enum): + """The type of data from the modified DynamoDB item that was captured in this stream record""" + + KEYS_ONLY = 0 # only the key attributes of the modified item + NEW_IMAGE = 1 # the entire item, as it appeared after it was modified. + OLD_IMAGE = 2 # the entire item, as it appeared before it was modified. + NEW_AND_OLD_IMAGES = 3 # both the new and the old item images of the item. + + +class StreamRecord(DictWrapper): + @property + def approximate_creation_date_time(self) -> Optional[int]: + """The approximate date and time when the stream record was created, in UNIX epoch time format.""" + item = self.get("ApproximateCreationDateTime") + return None if item is None else int(item) + + @property + def keys(self) -> Optional[Dict[str, AttributeValue]]: + """The primary key attribute(s) for the DynamoDB item that was modified.""" + return _attribute_value_dict(self._data, "Keys") + + @property + def new_image(self) -> Optional[Dict[str, AttributeValue]]: + """The item in the DynamoDB table as it appeared after it was modified.""" + return _attribute_value_dict(self._data, "NewImage") + + @property + def old_image(self) -> Optional[Dict[str, AttributeValue]]: + """The item in the DynamoDB table as it appeared before it was modified.""" + return _attribute_value_dict(self._data, "OldImage") + + @property + def sequence_number(self) -> Optional[str]: + """The sequence number of the stream record.""" + return self.get("SequenceNumber") + + @property + def size_bytes(self) -> Optional[int]: + """The size of the stream record, in bytes.""" + item = self.get("SizeBytes") + return None if item is None else int(item) + + @property + def stream_view_type(self) -> Optional[StreamViewType]: + """The type of data from the modified DynamoDB item that was captured in this stream record""" + item = self.get("StreamViewType") + return None if item is None else StreamViewType[str(item)] + + +class DynamoDBRecordEventName(Enum): + INSERT = 0 # a new item was added to the table + MODIFY = 1 # one or more of an existing item's attributes were modified + REMOVE = 2 # the item was deleted from the table + + +class DynamoDBRecord(DictWrapper): + """A description of a unique event within a stream""" + + @property + def aws_region(self) -> Optional[str]: + """The region in which the GetRecords request was received""" + return self.get("awsRegion") + + @property + def dynamodb(self) -> Optional[StreamRecord]: + """The main body of the stream record, containing all of the DynamoDB-specific fields.""" + stream_record = self.get("dynamodb") + return None if stream_record is None else StreamRecord(stream_record) + + @property + def event_id(self) -> Optional[str]: + """A globally unique identifier for the event that was recorded in this stream record.""" + return self.get("eventID") + + @property + def event_name(self) -> Optional[DynamoDBRecordEventName]: + """The type of data modification that was performed on the DynamoDB table""" + item = self.get("eventName") + return None if item is None else DynamoDBRecordEventName[item] + + @property + def event_source(self) -> Optional[str]: + """The AWS service from which the stream record originated. For DynamoDB Streams, this is aws:dynamodb.""" + return self.get("eventSource") + + @property + def event_source_arn(self) -> Optional[str]: + """The Amazon Resource Name (ARN) of the event source""" + return self.get("eventSourceARN") + + @property + def event_version(self) -> Optional[str]: + """The version number of the stream record format.""" + return self.get("eventVersion") + + @property + def user_identity(self) -> Optional[dict]: + """Contains details about the type of identity that made the request""" + return self.get("userIdentity") + + +class DynamoDBStreamEvent(DictWrapper): + """Dynamo DB Stream Event + + Documentation: + ------------- + - https://docs.aws.amazon.com/lambda/latest/dg/with-ddb.html + """ + + @property + def records(self) -> Iterator[DynamoDBRecord]: + for record in self["Records"]: + yield DynamoDBRecord(record) diff --git a/aws_lambda_powertools/utilities/data_classes/event_bridge_event.py b/aws_lambda_powertools/utilities/data_classes/event_bridge_event.py new file mode 100644 index 00000000000..cb299309a69 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/event_bridge_event.py @@ -0,0 +1,64 @@ +from typing import Any, Dict, List + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class EventBridgeEvent(DictWrapper): + """Amazon EventBridge Event + + Documentation: + -------------- + - https://docs.aws.amazon.com/eventbridge/latest/userguide/aws-events.html + """ + + @property + def get_id(self) -> str: + """A unique value is generated for every event. This can be helpful in tracing events as + they move through rules to targets, and are processed.""" + # Note: this name conflicts with existing python builtins + return self["id"] + + @property + def version(self) -> str: + """By default, this is set to 0 (zero) in all events.""" + return self["version"] + + @property + def account(self) -> str: + """The 12-digit number identifying an AWS account.""" + return self["account"] + + @property + def time(self) -> str: + """The event timestamp, which can be specified by the service originating the event. + + If the event spans a time interval, the service might choose to report the start time, so + this value can be noticeably before the time the event is actually received. + """ + return self["time"] + + @property + def region(self) -> str: + """Identifies the AWS region where the event originated.""" + return self["region"] + + @property + def resources(self) -> List[str]: + """This JSON array contains ARNs that identify resources that are involved in the event. + Inclusion of these ARNs is at the discretion of the service.""" + return self["resources"] + + @property + def source(self) -> str: + """Identifies the service that sourced the event. All events sourced from within AWS begin with "aws." """ + return self["source"] + + @property + def detail_type(self) -> str: + """Identifies, in combination with the source field, the fields and values that appear in the detail field.""" + return self["detail-type"] + + @property + def detail(self) -> Dict[str, Any]: + """A JSON object, whose content is at the discretion of the service originating the event. """ + return self["detail"] diff --git a/aws_lambda_powertools/utilities/data_classes/kinesis_stream_event.py b/aws_lambda_powertools/utilities/data_classes/kinesis_stream_event.py new file mode 100644 index 00000000000..6af1484f155 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/kinesis_stream_event.py @@ -0,0 +1,96 @@ +import base64 +import json +from typing import Iterator + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class KinesisStreamRecordPayload(DictWrapper): + @property + def approximate_arrival_timestamp(self) -> float: + """The approximate time that the record was inserted into the stream""" + return float(self["kinesis"]["approximateArrivalTimestamp"]) + + @property + def data(self) -> str: + """The data blob""" + return self["kinesis"]["data"] + + @property + def kinesis_schema_version(self) -> str: + """Schema version for the record""" + return self["kinesis"]["kinesisSchemaVersion"] + + @property + def partition_key(self) -> str: + """Identifies which shard in the stream the data record is assigned to""" + return self["kinesis"]["partitionKey"] + + @property + def sequence_number(self) -> str: + """The unique identifier of the record within its shard""" + return self["kinesis"]["sequenceNumber"] + + def data_as_text(self) -> str: + """Decode binary encoded data as text""" + return base64.b64decode(self.data).decode("utf-8") + + def data_as_json(self) -> dict: + """Decode binary encoded data as json""" + return json.loads(self.data_as_text()) + + +class KinesisStreamRecord(DictWrapper): + @property + def aws_region(self) -> str: + """AWS region where the event originated eg: us-east-1""" + return self["awsRegion"] + + @property + def event_id(self) -> str: + """A globally unique identifier for the event that was recorded in this stream record.""" + return self["eventID"] + + @property + def event_name(self) -> str: + """Event type eg: aws:kinesis:record""" + return self["eventName"] + + @property + def event_source(self) -> str: + """The AWS service from which the Kinesis event originated. For Kinesis, this is aws:kinesis""" + return self["eventSource"] + + @property + def event_source_arn(self) -> str: + """The Amazon Resource Name (ARN) of the event source""" + return self["eventSourceARN"] + + @property + def event_version(self) -> str: + """The eventVersion key value contains a major and minor version in the form ..""" + return self["eventVersion"] + + @property + def invoke_identity_arn(self) -> str: + """The ARN for the identity used to invoke the Lambda Function""" + return self["invokeIdentityArn"] + + @property + def kinesis(self) -> KinesisStreamRecordPayload: + """Underlying Kinesis record associated with the event""" + return KinesisStreamRecordPayload(self._data) + + +class KinesisStreamEvent(DictWrapper): + """Kinesis stream event + + Documentation: + -------------- + - https://docs.aws.amazon.com/lambda/latest/dg/with-kinesis.html + """ + + @property + def records(self) -> Iterator[KinesisStreamRecord]: + for record in self["Records"]: + yield KinesisStreamRecord(record) diff --git a/aws_lambda_powertools/utilities/data_classes/s3_event.py b/aws_lambda_powertools/utilities/data_classes/s3_event.py new file mode 100644 index 00000000000..2670142d575 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/s3_event.py @@ -0,0 +1,189 @@ +from typing import Dict, Iterator, Optional +from urllib.parse import unquote_plus + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class S3Identity(DictWrapper): + @property + def principal_id(self) -> str: + return self["principalId"] + + +class S3RequestParameters(DictWrapper): + @property + def source_ip_address(self) -> str: + return self["requestParameters"]["sourceIPAddress"] + + +class S3Bucket(DictWrapper): + @property + def name(self) -> str: + return self["s3"]["bucket"]["name"] + + @property + def owner_identity(self) -> S3Identity: + return S3Identity(self["s3"]["bucket"]["ownerIdentity"]) + + @property + def arn(self) -> str: + return self["s3"]["bucket"]["arn"] + + +class S3Object(DictWrapper): + @property + def key(self) -> str: + """Object key""" + return self["s3"]["object"]["key"] + + @property + def size(self) -> int: + """Object byte size""" + return int(self["s3"]["object"]["size"]) + + @property + def etag(self) -> str: + """object eTag""" + return self["s3"]["object"]["eTag"] + + @property + def version_id(self) -> Optional[str]: + """Object version if bucket is versioning-enabled, otherwise null""" + return self["s3"]["object"].get("versionId") + + @property + def sequencer(self) -> str: + """A string representation of a hexadecimal value used to determine event sequence, + only used with PUTs and DELETEs + """ + return self["s3"]["object"]["sequencer"] + + +class S3Message(DictWrapper): + @property + def s3_schema_version(self) -> str: + return self["s3"]["s3SchemaVersion"] + + @property + def configuration_id(self) -> str: + """ID found in the bucket notification configuration""" + return self["s3"]["configurationId"] + + @property + def bucket(self) -> S3Bucket: + return S3Bucket(self._data) + + @property + def get_object(self) -> S3Object: + """Get the `object` property as an S3Object""" + # Note: this name conflicts with existing python builtins + return S3Object(self._data) + + +class S3EventRecordGlacierRestoreEventData(DictWrapper): + @property + def lifecycle_restoration_expiry_time(self) -> str: + """Time when the object restoration will be expired.""" + return self["restoreEventData"]["lifecycleRestorationExpiryTime"] + + @property + def lifecycle_restore_storage_class(self) -> str: + """Source storage class for restore""" + return self["restoreEventData"]["lifecycleRestoreStorageClass"] + + +class S3EventRecordGlacierEventData(DictWrapper): + @property + def restore_event_data(self) -> S3EventRecordGlacierRestoreEventData: + """The restoreEventData key contains attributes related to your restore request. + + The glacierEventData key is only visible for s3:ObjectRestore:Completed events + """ + return S3EventRecordGlacierRestoreEventData(self._data) + + +class S3EventRecord(DictWrapper): + @property + def event_version(self) -> str: + """The eventVersion key value contains a major and minor version in the form ..""" + return self["eventVersion"] + + @property + def event_source(self) -> str: + """The AWS service from which the S3 event originated. For S3, this is aws:s3""" + return self["eventSource"] + + @property + def aws_region(self) -> str: + """aws region eg: us-east-1""" + return self["awsRegion"] + + @property + def event_time(self) -> str: + """The time, in ISO-8601 format, for example, 1970-01-01T00:00:00.000Z, when S3 finished + processing the request""" + return self["eventTime"] + + @property + def event_name(self) -> str: + """Event type""" + return self["eventName"] + + @property + def user_identity(self) -> S3Identity: + return S3Identity(self["userIdentity"]) + + @property + def request_parameters(self) -> S3RequestParameters: + return S3RequestParameters(self._data) + + @property + def response_elements(self) -> Dict[str, str]: + """The responseElements key value is useful if you want to trace a request by following up with AWS Support. + + Both x-amz-request-id and x-amz-id-2 help Amazon S3 trace an individual request. These values are the same + as those that Amazon S3 returns in the response to the request that initiates the events, so they can be + used to match the event to the request. + """ + return self["responseElements"] + + @property + def s3(self) -> S3Message: + return S3Message(self._data) + + @property + def glacier_event_data(self) -> Optional[S3EventRecordGlacierEventData]: + """The glacierEventData key is only visible for s3:ObjectRestore:Completed events.""" + item = self.get("glacierEventData") + return None if item is None else S3EventRecordGlacierEventData(item) + + +class S3Event(DictWrapper): + """S3 event notification + + Documentation: + ------------- + - https://docs.aws.amazon.com/lambda/latest/dg/with-s3.html + - https://docs.aws.amazon.com/AmazonS3/latest/dev/NotificationHowTo.html + - https://docs.aws.amazon.com/AmazonS3/latest/dev/notification-content-structure.html + """ + + @property + def records(self) -> Iterator[S3EventRecord]: + for record in self["Records"]: + yield S3EventRecord(record) + + @property + def record(self) -> S3EventRecord: + """Get the first s3 event record""" + return next(self.records) + + @property + def bucket_name(self) -> str: + """Get the bucket name for the first s3 event record""" + return self["Records"][0]["s3"]["bucket"]["name"] + + @property + def object_key(self) -> str: + """Get the object key for the first s3 event record and unquote plus""" + return unquote_plus(self["Records"][0]["s3"]["object"]["key"]) diff --git a/aws_lambda_powertools/utilities/data_classes/ses_event.py b/aws_lambda_powertools/utilities/data_classes/ses_event.py new file mode 100644 index 00000000000..518981618dc --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/ses_event.py @@ -0,0 +1,221 @@ +from typing import Iterator, List + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class SESMailHeader(DictWrapper): + @property + def name(self) -> str: + return self["name"] + + @property + def value(self) -> str: + return self["value"] + + +class SESMailCommonHeaders(DictWrapper): + @property + def return_path(self) -> str: + """The values in the Return-Path header of the email.""" + return self["returnPath"] + + @property + def get_from(self) -> List[str]: + """The values in the From header of the email.""" + # Note: this name conflicts with existing python builtins + return self["from"] + + @property + def date(self) -> List[str]: + """The date and time when Amazon SES received the message.""" + return self["date"] + + @property + def to(self) -> List[str]: + """The values in the To header of the email.""" + return self["to"] + + @property + def message_id(self) -> str: + """The ID of the original message.""" + return str(self["messageId"]) + + @property + def subject(self) -> str: + """The value of the Subject header for the email.""" + return str(self["subject"]) + + +class SESMail(DictWrapper): + @property + def timestamp(self) -> str: + """String that contains the time at which the email was received, in ISO8601 format.""" + return self["timestamp"] + + @property + def source(self) -> str: + """String that contains the email address (specifically, the envelope MAIL FROM address) + that the email was sent from.""" + return self["source"] + + @property + def message_id(self) -> str: + """String that contains the unique ID assigned to the email by Amazon SES. + + If the email was delivered to Amazon S3, the message ID is also the Amazon S3 object key that was + used to write the message to your Amazon S3 bucket.""" + return self["messageId"] + + @property + def destination(self) -> List[str]: + """A complete list of all recipient addresses (including To: and CC: recipients) + from the MIME headers of the incoming email.""" + return self["destination"] + + @property + def headers_truncated(self) -> bool: + """String that specifies whether the headers were truncated in the notification, which will happen + if the headers are larger than 10 KB. Possible values are true and false.""" + return bool(self["headersTruncated"]) + + @property + def headers(self) -> Iterator[SESMailHeader]: + """A list of Amazon SES headers and your custom headers. + Each header in the list has a name field and a value field""" + for header in self["headers"]: + yield SESMailHeader(header) + + @property + def common_headers(self) -> SESMailCommonHeaders: + """A list of headers common to all emails. Each header in the list is composed of a name and a value.""" + return SESMailCommonHeaders(self["commonHeaders"]) + + +class SESReceiptStatus(DictWrapper): + @property + def status(self) -> str: + return str(self["status"]) + + +class SESReceiptAction(DictWrapper): + @property + def get_type(self) -> str: + """String that indicates the type of action that was executed. + + Possible values are S3, SNS, Bounce, Lambda, Stop, and WorkMail + """ + # Note: this name conflicts with existing python builtins + return self["type"] + + @property + def function_arn(self) -> str: + """String that contains the ARN of the Lambda function that was triggered. + Present only for the Lambda action type.""" + return self["functionArn"] + + @property + def invocation_type(self) -> str: + """String that contains the invocation type of the Lambda function. Possible values are RequestResponse + and Event. Present only for the Lambda action type.""" + return self["invocationType"] + + +class SESReceipt(DictWrapper): + @property + def timestamp(self) -> str: + """String that specifies the date and time at which the action was triggered, in ISO 8601 format.""" + return self["timestamp"] + + @property + def processing_time_millis(self) -> int: + """String that specifies the period, in milliseconds, from the time Amazon SES received the message + to the time it triggered the action.""" + return int(self["processingTimeMillis"]) + + @property + def recipients(self) -> List[str]: + """A list of recipients (specifically, the envelope RCPT TO addresses) that were matched by the + active receipt rule. The addresses listed here may differ from those listed by the destination + field in the mail object.""" + return self["recipients"] + + @property + def spam_verdict(self) -> SESReceiptStatus: + """Object that indicates whether the message is spam.""" + return SESReceiptStatus(self["spamVerdict"]) + + @property + def virus_verdict(self) -> SESReceiptStatus: + """Object that indicates whether the message contains a virus.""" + return SESReceiptStatus(self["virusVerdict"]) + + @property + def spf_verdict(self) -> SESReceiptStatus: + """Object that indicates whether the Sender Policy Framework (SPF) check passed.""" + return SESReceiptStatus(self["spfVerdict"]) + + @property + def dmarc_verdict(self) -> SESReceiptStatus: + """Object that indicates whether the Domain-based Message Authentication, + Reporting & Conformance (DMARC) check passed.""" + return SESReceiptStatus(self["dmarcVerdict"]) + + @property + def action(self) -> SESReceiptAction: + """Object that encapsulates information about the action that was executed.""" + return SESReceiptAction(self["action"]) + + +class SESMessage(DictWrapper): + @property + def mail(self) -> SESMail: + return SESMail(self["ses"]["mail"]) + + @property + def receipt(self) -> SESReceipt: + return SESReceipt(self["ses"]["receipt"]) + + +class SESEventRecord(DictWrapper): + @property + def event_source(self) -> str: + """The AWS service from which the SES event record originated. For SES, this is aws:ses""" + return self["eventSource"] + + @property + def event_version(self) -> str: + """The eventVersion key value contains a major and minor version in the form ..""" + return self["eventVersion"] + + @property + def ses(self) -> SESMessage: + return SESMessage(self._data) + + +class SESEvent(DictWrapper): + """Amazon SES to receive message event data_classes + + NOTE: There is a 30-second timeout on RequestResponse invocations. + + Documentation: + -------------- + - https://docs.aws.amazon.com/lambda/latest/dg/services-ses.html + - https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-action-lambda.html + """ + + @property + def records(self) -> Iterator[SESEventRecord]: + for record in self["Records"]: + yield SESEventRecord(record) + + @property + def record(self) -> SESEventRecord: + return next(self.records) + + @property + def mail(self) -> SESMail: + return self.record.ses.mail + + @property + def receipt(self) -> SESReceipt: + return self.record.ses.receipt diff --git a/aws_lambda_powertools/utilities/data_classes/sns_event.py b/aws_lambda_powertools/utilities/data_classes/sns_event.py new file mode 100644 index 00000000000..e96b096fe6b --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/sns_event.py @@ -0,0 +1,123 @@ +from typing import Dict, Iterator + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class SNSMessageAttribute(DictWrapper): + @property + def get_type(self) -> str: + """The supported message attribute data types are String, String.Array, Number, and Binary.""" + # Note: this name conflicts with existing python builtins + return self["Type"] + + @property + def value(self) -> str: + """The user-specified message attribute value.""" + return self["Value"] + + +class SNSMessage(DictWrapper): + @property + def signature_version(self) -> str: + """Version of the Amazon SNS signature used.""" + return self["Sns"]["SignatureVersion"] + + @property + def timestamp(self) -> str: + """The time (GMT) when the subscription confirmation was sent.""" + return self["Sns"]["Timestamp"] + + @property + def signature(self) -> str: + """Base64-encoded "SHA1withRSA" signature of the Message, MessageId, Type, Timestamp, and TopicArn values.""" + return self["Sns"]["Signature"] + + @property + def signing_cert_url(self) -> str: + """The URL to the certificate that was used to sign the message.""" + return self["Sns"]["SigningCertUrl"] + + @property + def message_id(self) -> str: + """A Universally Unique Identifier, unique for each message published. + + For a message that Amazon SNS resends during a retry, the message ID of the original message is used.""" + return self["Sns"]["MessageId"] + + @property + def message(self) -> str: + """A string that describes the message. """ + return self["Sns"]["Message"] + + @property + def message_attributes(self) -> Dict[str, SNSMessageAttribute]: + return {k: SNSMessageAttribute(v) for (k, v) in self["Sns"]["MessageAttributes"].items()} + + @property + def get_type(self) -> str: + """The type of message. + + For a subscription confirmation, the type is SubscriptionConfirmation.""" + # Note: this name conflicts with existing python builtins + return self["Sns"]["Type"] + + @property + def unsubscribe_url(self) -> str: + """A URL that you can use to unsubscribe the endpoint from this topic. + + If you visit this URL, Amazon SNS unsubscribes the endpoint and stops sending notifications to this endpoint.""" + return self["Sns"]["UnsubscribeUrl"] + + @property + def topic_arn(self) -> str: + """The Amazon Resource Name (ARN) for the topic that this endpoint is subscribed to.""" + return self["Sns"]["TopicArn"] + + @property + def subject(self) -> str: + """The Subject parameter specified when the notification was published to the topic.""" + return self["Sns"]["Subject"] + + +class SNSEventRecord(DictWrapper): + @property + def event_version(self) -> str: + """Event version""" + return self["EventVersion"] + + @property + def event_subscription_arn(self) -> str: + return self["EventSubscriptionArn"] + + @property + def event_source(self) -> str: + """The AWS service from which the SNS event record originated. For SNS, this is aws:sns""" + return self["EventSource"] + + @property + def sns(self) -> SNSMessage: + return SNSMessage(self._data) + + +class SNSEvent(DictWrapper): + """SNS Event + + Documentation: + ------------- + - https://docs.aws.amazon.com/lambda/latest/dg/with-sns.html + """ + + @property + def records(self) -> Iterator[SNSEventRecord]: + for record in self["Records"]: + yield SNSEventRecord(record) + + @property + def record(self) -> SNSEventRecord: + """Return the first SNS event record""" + return next(self.records) + + @property + def sns_message(self) -> str: + """Return the message for the first sns event record""" + return self.record.sns.message diff --git a/aws_lambda_powertools/utilities/data_classes/sqs_event.py b/aws_lambda_powertools/utilities/data_classes/sqs_event.py new file mode 100644 index 00000000000..778b8f56f36 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/sqs_event.py @@ -0,0 +1,148 @@ +from typing import Dict, Iterator, Optional + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class SQSRecordAttributes(DictWrapper): + @property + def aws_trace_header(self) -> Optional[str]: + """Returns the AWS X-Ray trace header string.""" + return self.get("AWSTraceHeader") + + @property + def approximate_receive_count(self) -> str: + """Returns the number of times a message has been received across all queues but not deleted.""" + return self["ApproximateReceiveCount"] + + @property + def sent_timestamp(self) -> str: + """Returns the time the message was sent to the queue (epoch time in milliseconds).""" + return self["SentTimestamp"] + + @property + def sender_id(self) -> str: + """For an IAM user, returns the IAM user ID, For an IAM role, returns the IAM role ID""" + return self["SenderId"] + + @property + def approximate_first_receive_timestamp(self) -> str: + """Returns the time the message was first received from the queue (epoch time in milliseconds).""" + return self["ApproximateFirstReceiveTimestamp"] + + @property + def sequence_number(self) -> Optional[str]: + """The large, non-consecutive number that Amazon SQS assigns to each message.""" + return self.get("SequenceNumber") + + @property + def message_group_id(self) -> Optional[str]: + """The tag that specifies that a message belongs to a specific message group. + + Messages that belong to the same message group are always processed one by one, in a + strict order relative to the message group (however, messages that belong to different + message groups might be processed out of order).""" + return self.get("MessageGroupId") + + @property + def message_deduplication_id(self) -> Optional[str]: + """The token used for deduplication of sent messages. + + If a message with a particular message deduplication ID is sent successfully, any messages sent + with the same message deduplication ID are accepted successfully but aren't delivered during + the 5-minute deduplication interval.""" + return self.get("MessageDeduplicationId") + + +class SQSMessageAttribute(DictWrapper): + """The user-specified message attribute value.""" + + @property + def string_value(self) -> Optional[str]: + """Strings are Unicode with UTF-8 binary encoding.""" + return self["stringValue"] + + @property + def binary_value(self) -> Optional[str]: + """Binary type attributes can store any binary data, such as compressed data, encrypted data, or images. + + Base64-encoded binary data object""" + return self["binaryValue"] + + @property + def data_type(self) -> str: + """ The message attribute data type. Supported types include `String`, `Number`, and `Binary`.""" + return self["dataType"] + + +class SQSMessageAttributes(Dict[str, SQSMessageAttribute]): + def __getitem__(self, key: str) -> Optional[SQSMessageAttribute]: + item = super(SQSMessageAttributes, self).get(key) + return None if item is None else SQSMessageAttribute(item) + + +class SQSRecord(DictWrapper): + """An Amazon SQS message""" + + @property + def message_id(self) -> str: + """A unique identifier for the message. + + A messageId is considered unique across all AWS accounts for an extended period of time.""" + return self["messageId"] + + @property + def receipt_handle(self) -> str: + """An identifier associated with the act of receiving the message. + + A new receipt handle is returned every time you receive a message. When deleting a message, + you provide the last received receipt handle to delete the message.""" + return self["receiptHandle"] + + @property + def body(self) -> str: + """The message's contents (not URL-encoded).""" + return self["body"] + + @property + def attributes(self) -> SQSRecordAttributes: + """A map of the attributes requested in ReceiveMessage to their respective values.""" + return SQSRecordAttributes(self["attributes"]) + + @property + def message_attributes(self) -> SQSMessageAttributes: + """Each message attribute consists of a Name, Type, and Value.""" + return SQSMessageAttributes(self["messageAttributes"]) + + @property + def md5_of_body(self) -> str: + """An MD5 digest of the non-URL-encoded message body string.""" + return self["md5OfBody"] + + @property + def event_source(self) -> str: + """The AWS service from which the SQS record originated. For SQS, this is `aws:sqs` """ + return self["eventSource"] + + @property + def event_source_arn(self) -> str: + """The Amazon Resource Name (ARN) of the event source""" + return self["eventSourceARN"] + + @property + def aws_region(self) -> str: + """aws region eg: us-east-1""" + return self["awsRegion"] + + +class SQSEvent(DictWrapper): + """SQS Event + + Documentation: + -------------- + - https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html + """ + + @property + def records(self) -> Iterator[SQSRecord]: + for record in self["Records"]: + yield SQSRecord(record) diff --git a/tests/events/albEvent.json b/tests/events/albEvent.json new file mode 100644 index 00000000000..9328cb39e12 --- /dev/null +++ b/tests/events/albEvent.json @@ -0,0 +1,28 @@ +{ + "requestContext": { + "elb": { + "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a" + } + }, + "httpMethod": "GET", + "path": "/lambda", + "queryStringParameters": { + "query": "1234ABCD" + }, + "headers": { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", + "accept-encoding": "gzip", + "accept-language": "en-US,en;q=0.9", + "connection": "keep-alive", + "host": "lambda-alb-123578498.us-east-2.elb.amazonaws.com", + "upgrade-insecure-requests": "1", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36", + "x-amzn-trace-id": "Root=1-5c536348-3d683b8b04734faae651f476", + "x-forwarded-for": "72.12.164.125", + "x-forwarded-port": "80", + "x-forwarded-proto": "http", + "x-imforwards": "20" + }, + "body": "Test", + "isBase64Encoded": false +} diff --git a/tests/events/apiGatewayProxyEvent.json b/tests/events/apiGatewayProxyEvent.json new file mode 100644 index 00000000000..1fed04a25bf --- /dev/null +++ b/tests/events/apiGatewayProxyEvent.json @@ -0,0 +1,70 @@ +{ + "version": "1.0", + "resource": "/my/path", + "path": "/my/path", + "httpMethod": "GET", + "headers": { + "Header1": "value1", + "Header2": "value2" + }, + "multiValueHeaders": { + "Header1": [ + "value1" + ], + "Header2": [ + "value1", + "value2" + ] + }, + "queryStringParameters": { + "parameter1": "value1", + "parameter2": "value" + }, + "multiValueQueryStringParameters": { + "parameter1": [ + "value1", + "value2" + ], + "parameter2": [ + "value" + ] + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "id", + "authorizer": { + "claims": null, + "scopes": null + }, + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "extendedRequestId": "request-id", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "IP", + "user": null, + "userAgent": "user-agent", + "userArn": null + }, + "path": "/my/path", + "protocol": "HTTP/1.1", + "requestId": "id=", + "requestTime": "04/Mar/2020:19:15:17 +0000", + "requestTimeEpoch": 1583349317135, + "resourceId": null, + "resourcePath": "/my/path", + "stage": "$default" + }, + "pathParameters": null, + "stageVariables": null, + "body": "Hello from Lambda!", + "isBase64Encoded": true +} diff --git a/tests/events/apiGatewayProxyV2Event.json b/tests/events/apiGatewayProxyV2Event.json new file mode 100644 index 00000000000..9c310e6d52f --- /dev/null +++ b/tests/events/apiGatewayProxyV2Event.json @@ -0,0 +1,57 @@ +{ + "version": "2.0", + "routeKey": "$default", + "rawPath": "/my/path", + "rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value", + "cookies": [ + "cookie1", + "cookie2" + ], + "headers": { + "Header1": "value1", + "Header2": "value1,value2" + }, + "queryStringParameters": { + "parameter1": "value1,value2", + "parameter2": "value" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "api-id", + "authorizer": { + "jwt": { + "claims": { + "claim1": "value1", + "claim2": "value2" + }, + "scopes": [ + "scope1", + "scope2" + ] + } + }, + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "http": { + "method": "POST", + "path": "/my/path", + "protocol": "HTTP/1.1", + "sourceIp": "IP", + "userAgent": "agent" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": "Hello from Lambda", + "pathParameters": { + "parameter1": "value1" + }, + "isBase64Encoded": false, + "stageVariables": { + "stageVariable1": "value1", + "stageVariable2": "value2" + } +} diff --git a/tests/events/cloudWatchLogEvent.json b/tests/events/cloudWatchLogEvent.json new file mode 100644 index 00000000000..aa184c1d013 --- /dev/null +++ b/tests/events/cloudWatchLogEvent.json @@ -0,0 +1,5 @@ +{ + "awslogs": { + "data": "H4sIAAAAAAAAAHWPwQqCQBCGX0Xm7EFtK+smZBEUgXoLCdMhFtKV3akI8d0bLYmibvPPN3wz00CJxmQnTO41whwWQRIctmEcB6sQbFC3CjW3XW8kxpOpP+OC22d1Wml1qZkQGtoMsScxaczKN3plG8zlaHIta5KqWsozoTYw3/djzwhpLwivWFGHGpAFe7DL68JlBUk+l7KSN7tCOEJ4M3/qOI49vMHj+zCKdlFqLaU2ZHV2a4Ct/an0/ivdX8oYc1UVX860fQDQiMdxRQEAAA==" + } +} diff --git a/tests/events/cognitoCustomMessageEvent.json b/tests/events/cognitoCustomMessageEvent.json new file mode 100644 index 00000000000..8652c3bff40 --- /dev/null +++ b/tests/events/cognitoCustomMessageEvent.json @@ -0,0 +1,20 @@ +{ + "version": "1", + "triggerSource": "CustomMessage_AdminCreateUser", + "region": "region", + "userPoolId": "userPoolId", + "userName": "userName", + "callerContext": { + "awsSdk": "awsSdkVersion", + "clientId": "clientId" + }, + "request": { + "userAttributes": { + "phone_number_verified": false, + "email_verified": true + }, + "codeParameter": "####", + "usernameParameter": "username" + }, + "response": {} +} diff --git a/tests/events/cognitoPostAuthenticationEvent.json b/tests/events/cognitoPostAuthenticationEvent.json new file mode 100644 index 00000000000..3b1faa81bf9 --- /dev/null +++ b/tests/events/cognitoPostAuthenticationEvent.json @@ -0,0 +1,18 @@ +{ + "version": "1", + "region": "us-east-1", + "userPoolId": "us-east-1_example", + "userName": "UserName", + "callerContext": { + "awsSdkVersion": "awsSdkVersion", + "clientId": "clientId" + }, + "triggerSource": "PostAuthentication_Authentication", + "request": { + "newDeviceUsed": true, + "userAttributes": { + "email": "test@mail.com" + } + }, + "response": {} +} diff --git a/tests/events/cognitoPostConfirmationEvent.json b/tests/events/cognitoPostConfirmationEvent.json new file mode 100644 index 00000000000..e88f98150ca --- /dev/null +++ b/tests/events/cognitoPostConfirmationEvent.json @@ -0,0 +1,18 @@ +{ + "version": "string", + "triggerSource": "PostConfirmation_ConfirmSignUp", + "region": "us-east-1", + "userPoolId": "string", + "userName": "userName", + "callerContext": { + "awsSdkVersion": "awsSdkVersion", + "clientId": "clientId" + }, + "request": { + "userAttributes": { + "email": "user@example.com", + "email_verified": true + } + }, + "response": {} +} diff --git a/tests/events/cognitoPreAuthenticationEvent.json b/tests/events/cognitoPreAuthenticationEvent.json new file mode 100644 index 00000000000..75ff9ce34b3 --- /dev/null +++ b/tests/events/cognitoPreAuthenticationEvent.json @@ -0,0 +1,20 @@ +{ + "version": "1", + "region": "us-east-1", + "userPoolId": "us-east-1_example", + "userName": "UserName", + "callerContext": { + "awsSdkVersion": "awsSdkVersion", + "clientId": "clientId" + }, + "triggerSource": "PreAuthentication_Authentication", + "request": { + "userAttributes": { + "sub": "4A709A36-7D63-4785-829D-4198EF10EBDA", + "email_verified": "true", + "name": "First Last", + "email": "test@mail.com" + } + }, + "response": {} +} diff --git a/tests/events/cognitoPreSignUpEvent.json b/tests/events/cognitoPreSignUpEvent.json new file mode 100644 index 00000000000..feb4eba25dd --- /dev/null +++ b/tests/events/cognitoPreSignUpEvent.json @@ -0,0 +1,18 @@ +{ + "version": "string", + "triggerSource": "PreSignUp_SignUp", + "region": "us-east-1", + "userPoolId": "string", + "userName": "userName", + "callerContext": { + "awsSdkVersion": "awsSdkVersion", + "clientId": "clientId" + }, + "request": { + "userAttributes": { + "email": "user@example.com", + "phone_number": "+12065550100" + } + }, + "response": {} +} diff --git a/tests/events/cognitoPreTokenGenerationEvent.json b/tests/events/cognitoPreTokenGenerationEvent.json new file mode 100644 index 00000000000..f5ee69e0d2d --- /dev/null +++ b/tests/events/cognitoPreTokenGenerationEvent.json @@ -0,0 +1,25 @@ +{ + "version": "1", + "triggerSource": "TokenGeneration_Authentication", + "region": "us-west-2", + "userPoolId": "us-west-2_example", + "userName": "testqq", + "callerContext": { + "awsSdkVersion": "aws-sdk-unknown-unknown", + "clientId": "71ghuul37mresr7h373b704tua" + }, + "request": { + "userAttributes": { + "sub": "0b0a57c5-f013-426a-81a1-f8ffbfba21f0", + "email_verified": "true", + "cognito:user_status": "CONFIRMED", + "email": "test@mail.com" + }, + "groupConfiguration": { + "groupsToOverride": [], + "iamRolesToOverride": [], + "preferredRole": null + } + }, + "response": {} +} diff --git a/tests/events/cognitoUserMigrationEvent.json b/tests/events/cognitoUserMigrationEvent.json new file mode 100644 index 00000000000..2eae4e66189 --- /dev/null +++ b/tests/events/cognitoUserMigrationEvent.json @@ -0,0 +1,15 @@ +{ + "version": "string", + "triggerSource": "UserMigration_Authentication", + "region": "us-east-1", + "userPoolId": "string", + "userName": "userName", + "callerContext": { + "awsSdkVersion": "awsSdkVersion", + "clientId": "clientId" + }, + "request": { + "password": "password" + }, + "response": {} +} diff --git a/tests/events/dynamoStreamEvent.json b/tests/events/dynamoStreamEvent.json new file mode 100644 index 00000000000..12c535b005e --- /dev/null +++ b/tests/events/dynamoStreamEvent.json @@ -0,0 +1,64 @@ +{ + "Records": [ + { + "eventID": "1", + "eventVersion": "1.0", + "dynamodb": { + "Keys": { + "Id": { + "N": "101" + } + }, + "NewImage": { + "Message": { + "S": "New item!" + }, + "Id": { + "N": "101" + } + }, + "StreamViewType": "NEW_AND_OLD_IMAGES", + "SequenceNumber": "111", + "SizeBytes": 26 + }, + "awsRegion": "us-west-2", + "eventName": "INSERT", + "eventSourceARN": "eventsource_arn", + "eventSource": "aws:dynamodb" + }, + { + "eventID": "2", + "eventVersion": "1.0", + "dynamodb": { + "OldImage": { + "Message": { + "S": "New item!" + }, + "Id": { + "N": "101" + } + }, + "SequenceNumber": "222", + "Keys": { + "Id": { + "N": "101" + } + }, + "SizeBytes": 59, + "NewImage": { + "Message": { + "S": "This item has changed" + }, + "Id": { + "N": "101" + } + }, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "awsRegion": "us-west-2", + "eventName": "MODIFY", + "eventSourceARN": "source_arn", + "eventSource": "aws:dynamodb" + } + ] +} diff --git a/tests/events/eventBridgeEvent.json b/tests/events/eventBridgeEvent.json new file mode 100644 index 00000000000..e8d949001c9 --- /dev/null +++ b/tests/events/eventBridgeEvent.json @@ -0,0 +1,16 @@ +{ + "version": "0", + "id": "6a7e8feb-b491-4cf7-a9f1-bf3703467718", + "detail-type": "EC2 Instance State-change Notification", + "source": "aws.ec2", + "account": "111122223333", + "time": "2017-12-22T18:43:48Z", + "region": "us-west-1", + "resources": [ + "arn:aws:ec2:us-west-1:123456789012:instance/ i-1234567890abcdef0" + ], + "detail": { + "instance-id": " i-1234567890abcdef0", + "state": "terminated" + } +} diff --git a/tests/events/kinesisStreamEvent.json b/tests/events/kinesisStreamEvent.json new file mode 100644 index 00000000000..ef8e2096388 --- /dev/null +++ b/tests/events/kinesisStreamEvent.json @@ -0,0 +1,36 @@ +{ + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "1", + "sequenceNumber": "49590338271490256608559692538361571095921575989136588898", + "data": "SGVsbG8sIHRoaXMgaXMgYSB0ZXN0Lg==", + "approximateArrivalTimestamp": 1545084650.987 + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000006:49590338271490256608559692538361571095921575989136588898", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn:aws:iam::123456789012:role/lambda-role", + "awsRegion": "us-east-2", + "eventSourceARN": "arn:aws:kinesis:us-east-2:123456789012:stream/lambda-stream" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "1", + "sequenceNumber": "49590338271490256608559692540925702759324208523137515618", + "data": "VGhpcyBpcyBvbmx5IGEgdGVzdC4=", + "approximateArrivalTimestamp": 1545084711.166 + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000006:49590338271490256608559692540925702759324208523137515618", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn:aws:iam::123456789012:role/lambda-role", + "awsRegion": "us-east-2", + "eventSourceARN": "arn:aws:kinesis:us-east-2:123456789012:stream/lambda-stream" + } + ] +} diff --git a/tests/events/s3Event.json b/tests/events/s3Event.json new file mode 100644 index 00000000000..4558dc3c9e1 --- /dev/null +++ b/tests/events/s3Event.json @@ -0,0 +1,38 @@ +{ + "Records": [ + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "us-east-2", + "eventTime": "2019-09-03T19:37:27.192Z", + "eventName": "ObjectCreated:Put", + "userIdentity": { + "principalId": "AWS:AIDAINPONIXQXHT3IKHL2" + }, + "requestParameters": { + "sourceIPAddress": "205.255.255.255" + }, + "responseElements": { + "x-amz-request-id": "D82B88E5F771F645", + "x-amz-id-2": "vlR7PnpV2Ce81l0PRw6jlUpck7Jo5ZsQjryTjKlc5aLWGVHPZLj5NeC6qMa0emYBDXOo6QBU0Wo=" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "828aa6fc-f7b5-4305-8584-487c791949c1", + "bucket": { + "name": "lambda-artifacts-deafc19498e3f2df", + "ownerIdentity": { + "principalId": "A3I5XTEXAMAI3E" + }, + "arn": "arn:aws:s3:::lambda-artifacts-deafc19498e3f2df" + }, + "object": { + "key": "b21b84d653bb07b05b1e6b33684dc11b", + "size": 1305107, + "eTag": "b21b84d653bb07b05b1e6b33684dc11b", + "sequencer": "0C0F6F405D6ED209E1" + } + } + } + ] +} diff --git a/tests/events/sesEvent.json b/tests/events/sesEvent.json new file mode 100644 index 00000000000..5a5afd5bab7 --- /dev/null +++ b/tests/events/sesEvent.json @@ -0,0 +1,100 @@ +{ + "Records": [ + { + "eventVersion": "1.0", + "ses": { + "mail": { + "commonHeaders": { + "from": [ + "Jane Doe " + ], + "to": [ + "johndoe@example.com" + ], + "returnPath": "janedoe@example.com", + "messageId": "<0123456789example.com>", + "date": "Wed, 7 Oct 2015 12:34:56 -0700", + "subject": "Test Subject" + }, + "source": "janedoe@example.com", + "timestamp": "1970-01-01T00:00:00.000Z", + "destination": [ + "johndoe@example.com" + ], + "headers": [ + { + "name": "Return-Path", + "value": "" + }, + { + "name": "Received", + "value": "from mailer.example.com (mailer.example.com [203.0.113.1]) by ..." + }, + { + "name": "DKIM-Signature", + "value": "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=example; ..." + }, + { + "name": "MIME-Version", + "value": "1.0" + }, + { + "name": "From", + "value": "Jane Doe " + }, + { + "name": "Date", + "value": "Wed, 7 Oct 2015 12:34:56 -0700" + }, + { + "name": "Message-ID", + "value": "<0123456789example.com>" + }, + { + "name": "Subject", + "value": "Test Subject" + }, + { + "name": "To", + "value": "johndoe@example.com" + }, + { + "name": "Content-Type", + "value": "text/plain; charset=UTF-8" + } + ], + "headersTruncated": false, + "messageId": "o3vrnil0e2ic28tr" + }, + "receipt": { + "recipients": [ + "johndoe@example.com" + ], + "timestamp": "1970-01-01T00:00:00.000Z", + "spamVerdict": { + "status": "PASS" + }, + "dkimVerdict": { + "status": "PASS" + }, + "processingTimeMillis": 574, + "action": { + "type": "Lambda", + "invocationType": "Event", + "functionArn": "arn:aws:lambda:us-west-2:012345678912:function:Example" + }, + "dmarcVerdict": { + "status": "PASS" + }, + "spfVerdict": { + "status": "PASS" + }, + "virusVerdict": { + "status": "PASS" + } + } + }, + "eventSource": "aws:ses" + } + ] +} diff --git a/tests/events/snsEvent.json b/tests/events/snsEvent.json new file mode 100644 index 00000000000..b351dfd1418 --- /dev/null +++ b/tests/events/snsEvent.json @@ -0,0 +1,31 @@ +{ + "Records": [ + { + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:us-east-2:123456789012:sns-la ...", + "EventSource": "aws:sns", + "Sns": { + "SignatureVersion": "1", + "Timestamp": "2019-01-02T12:45:07.000Z", + "Signature": "tcc6faL2yUC6dgZdmrwh1Y4cGa/ebXEkAi6RibDsvpi+tE/1+82j...65r==", + "SigningCertUrl": "https://sns.us-east-2.amazonaws.com/SimpleNotificat ...", + "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", + "Message": "Hello from SNS!", + "MessageAttributes": { + "Test": { + "Type": "String", + "Value": "TestString" + }, + "TestBinary": { + "Type": "Binary", + "Value": "TestBinary" + } + }, + "Type": "Notification", + "UnsubscribeUrl": "https://sns.us-east-2.amazonaws.com/?Action=Unsubscri ...", + "TopicArn": "arn:aws:sns:us-east-2:123456789012:sns-lambda", + "Subject": "TestInvoke" + } + } + ] +} diff --git a/tests/events/sqsEvent.json b/tests/events/sqsEvent.json new file mode 100644 index 00000000000..7201068d60c --- /dev/null +++ b/tests/events/sqsEvent.json @@ -0,0 +1,42 @@ +{ + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": "Test message.", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185" + }, + "messageAttributes": { + "testAttr": { + "stringValue": "100", + "binaryValue": "base64Str", + "dataType": "Number" + } + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2" + }, + { + "messageId": "2e1424d4-f796-459a-8184-9c92662be6da", + "receiptHandle": "AQEBzWwaftRI0KuVm4tP+/7q1rGgNqicHq...", + "body": "Test message.", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082650636", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082650649" + }, + "messageAttributes": {}, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2" + } + ] +} diff --git a/tests/functional/test_lambda_trigger_events.py b/tests/functional/test_lambda_trigger_events.py new file mode 100644 index 00000000000..21e775b7a5f --- /dev/null +++ b/tests/functional/test_lambda_trigger_events.py @@ -0,0 +1,633 @@ +import base64 +import json +import os +from secrets import compare_digest +from urllib.parse import quote_plus + +from aws_lambda_powertools.utilities.data_classes import ( + ALBEvent, + APIGatewayProxyEvent, + APIGatewayProxyEventV2, + CloudWatchLogsEvent, + EventBridgeEvent, + KinesisStreamEvent, + S3Event, + SESEvent, + SNSEvent, + SQSEvent, +) +from aws_lambda_powertools.utilities.data_classes.cognito_user_pool_event import ( + CustomMessageTriggerEvent, + PostAuthenticationTriggerEvent, + PostConfirmationTriggerEvent, + PreAuthenticationTriggerEvent, + PreSignUpTriggerEvent, + PreTokenGenerationTriggerEvent, + UserMigrationTriggerEvent, +) +from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent +from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import ( + AttributeValue, + DynamoDBRecordEventName, + DynamoDBStreamEvent, + StreamViewType, +) + + +def load_event(file_name: str) -> dict: + full_file_name = os.path.dirname(os.path.realpath(__file__)) + "/../events/" + file_name + with open(full_file_name) as fp: + return json.load(fp) + + +def test_cloud_watch_trigger_event(): + event = CloudWatchLogsEvent(load_event("cloudWatchLogEvent.json")) + + decompressed_logs_data = event.decompress_logs_data + assert event.decompress_logs_data == decompressed_logs_data + + json_logs_data = event.parse_logs_data() + assert event.parse_logs_data()._data == json_logs_data._data + log_events = json_logs_data.log_events + log_event = log_events[0] + + assert json_logs_data.owner == "123456789123" + assert json_logs_data.log_group == "testLogGroup" + assert json_logs_data.log_stream == "testLogStream" + assert json_logs_data.subscription_filters == ["testFilter"] + assert json_logs_data.message_type == "DATA_MESSAGE" + + assert log_event.get_id == "eventId1" + assert log_event.timestamp == 1440442987000 + assert log_event.message == "[ERROR] First test message" + assert log_event.extracted_fields is None + + event2 = CloudWatchLogsEvent(load_event("cloudWatchLogEvent.json")) + assert event._data == event2._data + + +def test_cognito_pre_signup_trigger_event(): + event = PreSignUpTriggerEvent(load_event("cognitoPreSignUpEvent.json")) + + assert event.version == "string" + assert event.trigger_source == "PreSignUp_SignUp" + assert event.region == "us-east-1" + assert event.user_pool_id == "string" + assert event.user_name == "userName" + caller_context = event.caller_context + assert caller_context.aws_sdk_version == "awsSdkVersion" + assert caller_context.client_id == "clientId" + + user_attributes = event.request.user_attributes + assert user_attributes["email"] == "user@example.com" + + assert event.request.validation_data is None + assert event.request.client_metadata is None + + event.response.auto_confirm_user = True + assert event.response.auto_confirm_user is True + event.response.auto_verify_phone = True + assert event.response.auto_verify_phone is True + event.response.auto_verify_email = True + assert event.response.auto_verify_email is True + assert event["response"]["autoVerifyEmail"] is True + + +def test_cognito_post_confirmation_trigger_event(): + event = PostConfirmationTriggerEvent(load_event("cognitoPostConfirmationEvent.json")) + + user_attributes = event.request.user_attributes + assert user_attributes["email"] == "user@example.com" + assert event.request.client_metadata is None + + +def test_cognito_user_migration_trigger_event(): + event = UserMigrationTriggerEvent(load_event("cognitoUserMigrationEvent.json")) + + assert compare_digest(event.request.password, event["request"]["password"]) + assert event.request.validation_data is None + assert event.request.client_metadata is None + + event.response.user_attributes = {"username": "username"} + assert event.response.user_attributes == event["response"]["userAttributes"] + assert event.response.user_attributes == {"username": "username"} + assert event.response.final_user_status is None + assert event.response.message_action is None + assert event.response.force_alias_creation is None + assert event.response.desired_delivery_mediums is None + + event.response.final_user_status = "CONFIRMED" + assert event.response.final_user_status == "CONFIRMED" + event.response.message_action = "SUPPRESS" + assert event.response.message_action == "SUPPRESS" + event.response.force_alias_creation = True + assert event.response.force_alias_creation is True + event.response.desired_delivery_mediums = ["EMAIL"] + assert event.response.desired_delivery_mediums == ["EMAIL"] + + +def test_cognito_custom_message_trigger_event(): + event = CustomMessageTriggerEvent(load_event("cognitoCustomMessageEvent.json")) + + assert event.request.code_parameter == "####" + assert event.request.username_parameter == "username" + assert event.request.user_attributes["phone_number_verified"] is False + assert event.request.client_metadata is None + + event.response.sms_message = "sms" + assert event.response.sms_message == event["response"]["smsMessage"] + event.response.email_message = "email" + assert event.response.email_message == event["response"]["emailMessage"] + event.response.email_subject = "subject" + assert event.response.email_subject == event["response"]["emailSubject"] + + +def test_cognito_pre_authentication_trigger_event(): + event = PreAuthenticationTriggerEvent(load_event("cognitoPreAuthenticationEvent.json")) + + assert event.request.user_not_found is None + event["request"]["userNotFound"] = True + assert event.request.user_not_found is True + assert event.request.user_attributes["email"] == "test@mail.com" + assert event.request.validation_data is None + + +def test_cognito_post_authentication_trigger_event(): + event = PostAuthenticationTriggerEvent(load_event("cognitoPostAuthenticationEvent.json")) + + assert event.request.new_device_used is True + assert event.request.user_attributes["email"] == "test@mail.com" + assert event.request.client_metadata is None + + +def test_cognito_pre_token_generation_trigger_event(): + event = PreTokenGenerationTriggerEvent(load_event("cognitoPreTokenGenerationEvent.json")) + + group_configuration = event.request.group_configuration + assert group_configuration.groups_to_override == [] + assert group_configuration.iam_roles_to_override == [] + assert group_configuration.preferred_role is None + assert event.request.user_attributes["email"] == "test@mail.com" + assert event.request.client_metadata is None + + event["request"]["groupConfiguration"]["preferredRole"] = "temp" + group_configuration = event.request.group_configuration + assert group_configuration.preferred_role == "temp" + + assert event["response"].get("claimsOverrideDetails") is None + claims_override_details = event.response.claims_override_details + assert event["response"]["claimsOverrideDetails"] == {} + + assert claims_override_details.claims_to_add_or_override is None + assert claims_override_details.claims_to_suppress is None + assert claims_override_details.group_configuration is None + + claims_override_details.group_configuration = {} + assert claims_override_details.group_configuration._data == {} + assert event["response"]["claimsOverrideDetails"]["groupOverrideDetails"] == {} + + expected_claims = {"test": "value"} + claims_override_details.claims_to_add_or_override = expected_claims + assert claims_override_details.claims_to_add_or_override["test"] == "value" + assert event["response"]["claimsOverrideDetails"]["claimsToAddOrOverride"] == expected_claims + + claims_override_details.claims_to_suppress = ["email"] + assert claims_override_details.claims_to_suppress[0] == "email" + assert event["response"]["claimsOverrideDetails"]["claimsToSuppress"] == ["email"] + + expected_groups = ["group-A", "group-B"] + claims_override_details.set_group_configuration_groups_to_override(expected_groups) + assert claims_override_details.group_configuration.groups_to_override == expected_groups + assert event["response"]["claimsOverrideDetails"]["groupOverrideDetails"]["groupsToOverride"] == expected_groups + + claims_override_details.set_group_configuration_iam_roles_to_override(["role"]) + assert claims_override_details.group_configuration.iam_roles_to_override == ["role"] + assert event["response"]["claimsOverrideDetails"]["groupOverrideDetails"]["iamRolesToOverride"] == ["role"] + + claims_override_details.set_group_configuration_preferred_role("role_name") + assert claims_override_details.group_configuration.preferred_role == "role_name" + assert event["response"]["claimsOverrideDetails"]["groupOverrideDetails"]["preferredRole"] == "role_name" + + +def test_dynamo_db_stream_trigger_event(): + event = DynamoDBStreamEvent(load_event("dynamoStreamEvent.json")) + + records = list(event.records) + + record = records[0] + assert record.aws_region == "us-west-2" + dynamodb = record.dynamodb + assert dynamodb is not None + assert dynamodb.approximate_creation_date_time is None + keys = dynamodb.keys + assert keys is not None + id_key = keys["Id"] + assert id_key.b_value is None + assert id_key.bs_value is None + assert id_key.bool_value is None + assert id_key.list_value is None + assert id_key.map_value is None + assert id_key.n_value == "101" + assert id_key.ns_value is None + assert id_key.null_value is None + assert id_key.s_value is None + assert id_key.ss_value is None + message_key = dynamodb.new_image["Message"] + assert message_key is not None + assert message_key.s_value == "New item!" + assert dynamodb.old_image is None + assert dynamodb.sequence_number == "111" + assert dynamodb.size_bytes == 26 + assert dynamodb.stream_view_type == StreamViewType.NEW_AND_OLD_IMAGES + assert record.event_id == "1" + assert record.event_name is DynamoDBRecordEventName.INSERT + assert record.event_source == "aws:dynamodb" + assert record.event_source_arn == "eventsource_arn" + assert record.event_version == "1.0" + assert record.user_identity is None + + +def test_dynamo_attribute_value_list_value(): + example_attribute_value = {"L": [{"S": "Cookies"}, {"S": "Coffee"}, {"N": "3.14159"}]} + attribute_value = AttributeValue(example_attribute_value) + list_value = attribute_value.list_value + assert list_value is not None + item = list_value[0] + assert item.s_value == "Cookies" + + +def test_dynamo_attribute_value_map_value(): + example_attribute_value = {"M": {"Name": {"S": "Joe"}, "Age": {"N": "35"}}} + + attribute_value = AttributeValue(example_attribute_value) + + map_value = attribute_value.map_value + assert map_value is not None + item = map_value["Name"] + assert item.s_value == "Joe" + + +def test_event_bridge_event(): + event = EventBridgeEvent(load_event("eventBridgeEvent.json")) + + assert event.get_id == event["id"] + assert event.version == event["version"] + assert event.account == event["account"] + assert event.time == event["time"] + assert event.region == event["region"] + assert event.resources == event["resources"] + assert event.source == event["source"] + assert event.detail_type == event["detail-type"] + assert event.detail == event["detail"] + + +def test_s3_trigger_event(): + event = S3Event(load_event("s3Event.json")) + records = list(event.records) + assert len(records) == 1 + record = records[0] + assert record.event_version == "2.1" + assert record.event_source == "aws:s3" + assert record.aws_region == "us-east-2" + assert record.event_time == "2019-09-03T19:37:27.192Z" + assert record.event_name == "ObjectCreated:Put" + user_identity = record.user_identity + assert user_identity.principal_id == "AWS:AIDAINPONIXQXHT3IKHL2" + request_parameters = record.request_parameters + assert request_parameters.source_ip_address == "205.255.255.255" + assert record.response_elements["x-amz-request-id"] == "D82B88E5F771F645" + s3 = record.s3 + assert s3.s3_schema_version == "1.0" + assert s3.configuration_id == "828aa6fc-f7b5-4305-8584-487c791949c1" + bucket = s3.bucket + assert bucket.name == "lambda-artifacts-deafc19498e3f2df" + assert bucket.owner_identity.principal_id == "A3I5XTEXAMAI3E" + assert bucket.arn == "arn:aws:s3:::lambda-artifacts-deafc19498e3f2df" + assert s3.get_object.key == "b21b84d653bb07b05b1e6b33684dc11b" + assert s3.get_object.size == 1305107 + assert s3.get_object.etag == "b21b84d653bb07b05b1e6b33684dc11b" + assert s3.get_object.version_id is None + assert s3.get_object.sequencer == "0C0F6F405D6ED209E1" + assert record.glacier_event_data is None + assert event.record._data == event["Records"][0] + assert event.bucket_name == "lambda-artifacts-deafc19498e3f2df" + assert event.object_key == "b21b84d653bb07b05b1e6b33684dc11b" + + +def test_s3_key_unquote_plus(): + tricky_name = "foo name+value" + event_dict = {"Records": [{"s3": {"object": {"key": quote_plus(tricky_name)}}}]} + event = S3Event(event_dict) + assert event.object_key == tricky_name + + +def test_s3_glacier_event(): + example_event = { + "Records": [ + { + "glacierEventData": { + "restoreEventData": { + "lifecycleRestorationExpiryTime": "1970-01-01T00:01:00.000Z", + "lifecycleRestoreStorageClass": "standard", + } + } + } + ] + } + event = S3Event(example_event) + record = next(event.records) + glacier_event_data = record.glacier_event_data + assert glacier_event_data is not None + assert glacier_event_data.restore_event_data.lifecycle_restoration_expiry_time == "1970-01-01T00:01:00.000Z" + assert glacier_event_data.restore_event_data.lifecycle_restore_storage_class == "standard" + + +def test_ses_trigger_event(): + event = SESEvent(load_event("sesEvent.json")) + + expected_address = "johndoe@example.com" + records = list(event.records) + record = records[0] + assert record.event_source == "aws:ses" + assert record.event_version == "1.0" + mail = record.ses.mail + assert mail.timestamp == "1970-01-01T00:00:00.000Z" + assert mail.source == "janedoe@example.com" + assert mail.message_id == "o3vrnil0e2ic28tr" + assert mail.destination == [expected_address] + assert mail.headers_truncated is False + headers = list(mail.headers) + assert len(headers) == 10 + assert headers[0].name == "Return-Path" + assert headers[0].value == "" + common_headers = mail.common_headers + assert common_headers.return_path == "janedoe@example.com" + assert common_headers.get_from == common_headers._data["from"] + assert common_headers.date == "Wed, 7 Oct 2015 12:34:56 -0700" + assert common_headers.to == [expected_address] + assert common_headers.message_id == "<0123456789example.com>" + assert common_headers.subject == "Test Subject" + receipt = record.ses.receipt + assert receipt.timestamp == "1970-01-01T00:00:00.000Z" + assert receipt.processing_time_millis == 574 + assert receipt.recipients == [expected_address] + assert receipt.spam_verdict.status == "PASS" + assert receipt.virus_verdict.status == "PASS" + assert receipt.spf_verdict.status == "PASS" + assert receipt.dmarc_verdict.status == "PASS" + action = receipt.action + assert action.get_type == action._data["type"] + assert action.function_arn == action._data["functionArn"] + assert action.invocation_type == action._data["invocationType"] + assert event.record._data == event["Records"][0] + assert event.mail._data == event["Records"][0]["ses"]["mail"] + assert event.receipt._data == event["Records"][0]["ses"]["receipt"] + + +def test_sns_trigger_event(): + event = SNSEvent(load_event("snsEvent.json")) + records = list(event.records) + assert len(records) == 1 + record = records[0] + assert record.event_version == "1.0" + assert record.event_subscription_arn == "arn:aws:sns:us-east-2:123456789012:sns-la ..." + assert record.event_source == "aws:sns" + sns = record.sns + assert sns.signature_version == "1" + assert sns.timestamp == "2019-01-02T12:45:07.000Z" + assert sns.signature == "tcc6faL2yUC6dgZdmrwh1Y4cGa/ebXEkAi6RibDsvpi+tE/1+82j...65r==" + assert sns.signing_cert_url == "https://sns.us-east-2.amazonaws.com/SimpleNotificat ..." + assert sns.message_id == "95df01b4-ee98-5cb9-9903-4c221d41eb5e" + assert sns.message == "Hello from SNS!" + message_attributes = sns.message_attributes + test_message_attribute = message_attributes["Test"] + assert test_message_attribute.get_type == "String" + assert test_message_attribute.value == "TestString" + assert sns.get_type == "Notification" + assert sns.unsubscribe_url == "https://sns.us-east-2.amazonaws.com/?Action=Unsubscri ..." + assert sns.topic_arn == "arn:aws:sns:us-east-2:123456789012:sns-lambda" + assert sns.subject == "TestInvoke" + assert event.record._data == event["Records"][0] + assert event.sns_message == "Hello from SNS!" + + +def test_seq_trigger_event(): + event = SQSEvent(load_event("sqsEvent.json")) + + records = list(event.records) + record = records[0] + attributes = record.attributes + message_attributes = record.message_attributes + test_attr = message_attributes["testAttr"] + + assert len(records) == 2 + assert record.message_id == "059f36b4-87a3-44ab-83d2-661975830a7d" + assert record.receipt_handle == "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a..." + assert record.body == "Test message." + assert attributes.aws_trace_header is None + assert attributes.approximate_receive_count == "1" + assert attributes.sent_timestamp == "1545082649183" + assert attributes.sender_id == "AIDAIENQZJOLO23YVJ4VO" + assert attributes.approximate_first_receive_timestamp == "1545082649185" + assert attributes.sequence_number is None + assert attributes.message_group_id is None + assert attributes.message_deduplication_id is None + assert message_attributes["NotFound"] is None + assert message_attributes.get("NotFound") is None + assert test_attr.string_value == "100" + assert test_attr.binary_value == "base64Str" + assert test_attr.data_type == "Number" + assert record.md5_of_body == "e4e68fb7bd0e697a0ae8f1bb342846b3" + assert record.event_source == "aws:sqs" + assert record.event_source_arn == "arn:aws:sqs:us-east-2:123456789012:my-queue" + assert record.aws_region == "us-east-2" + + +def test_api_gateway_proxy_event(): + event = APIGatewayProxyEvent(load_event("apiGatewayProxyEvent.json")) + + assert event.version == event["version"] + assert event.resource == event["resource"] + assert event.path == event["path"] + assert event.http_method == event["httpMethod"] + assert event.headers == event["headers"] + assert event.multi_value_headers == event["multiValueHeaders"] + assert event.query_string_parameters == event["queryStringParameters"] + assert event.multi_value_query_string_parameters == event["multiValueQueryStringParameters"] + + request_context = event.request_context + assert request_context.account_id == event["requestContext"]["accountId"] + assert request_context.api_id == event["requestContext"]["apiId"] + + authorizer = request_context.authorizer + assert authorizer.claims is None + assert authorizer.scopes is None + + assert request_context.domain_name == event["requestContext"]["domainName"] + assert request_context.domain_prefix == event["requestContext"]["domainPrefix"] + assert request_context.extended_request_id == event["requestContext"]["extendedRequestId"] + assert request_context.http_method == event["requestContext"]["httpMethod"] + + identity = request_context.identity + assert identity.access_key == event["requestContext"]["identity"]["accessKey"] + assert identity.account_id == event["requestContext"]["identity"]["accountId"] + assert identity.caller == event["requestContext"]["identity"]["caller"] + assert ( + identity.cognito_authentication_provider == event["requestContext"]["identity"]["cognitoAuthenticationProvider"] + ) + assert identity.cognito_authentication_type == event["requestContext"]["identity"]["cognitoAuthenticationType"] + assert identity.cognito_identity_id == event["requestContext"]["identity"]["cognitoIdentityId"] + assert identity.cognito_identity_pool_id == event["requestContext"]["identity"]["cognitoIdentityPoolId"] + assert identity.principal_org_id == event["requestContext"]["identity"]["principalOrgId"] + assert identity.source_ip == event["requestContext"]["identity"]["sourceIp"] + assert identity.user == event["requestContext"]["identity"]["user"] + assert identity.user_agent == event["requestContext"]["identity"]["userAgent"] + assert identity.user_arn == event["requestContext"]["identity"]["userArn"] + + assert request_context.path == event["requestContext"]["path"] + assert request_context.protocol == event["requestContext"]["protocol"] + assert request_context.request_id == event["requestContext"]["requestId"] + assert request_context.request_time == event["requestContext"]["requestTime"] + assert request_context.request_time_epoch == event["requestContext"]["requestTimeEpoch"] + assert request_context.resource_id == event["requestContext"]["resourceId"] + assert request_context.resource_path == event["requestContext"]["resourcePath"] + assert request_context.stage == event["requestContext"]["stage"] + + assert event.path_parameters == event["pathParameters"] + assert event.stage_variables == event["stageVariables"] + assert event.body == event["body"] + assert event.is_base64_encoded == event["isBase64Encoded"] + + assert request_context.connected_at is None + assert request_context.connection_id is None + assert request_context.event_type is None + assert request_context.message_direction is None + assert request_context.message_id is None + assert request_context.route_key is None + assert identity.api_key is None + assert identity.api_key_id is None + + +def test_api_gateway_proxy_v2_event(): + event = APIGatewayProxyEventV2(load_event("apiGatewayProxyV2Event.json")) + + assert event.version == event["version"] + assert event.route_key == event["routeKey"] + assert event.raw_path == event["rawPath"] + assert event.raw_query_string == event["rawQueryString"] + assert event.cookies == event["cookies"] + assert event.cookies[0] == "cookie1" + assert event.headers == event["headers"] + assert event.query_string_parameters == event["queryStringParameters"] + assert event.query_string_parameters["parameter2"] == "value" + + request_context = event.request_context + assert request_context.account_id == event["requestContext"]["accountId"] + assert request_context.api_id == event["requestContext"]["apiId"] + assert request_context.authorizer.jwt_claim == event["requestContext"]["authorizer"]["jwt"]["claims"] + assert request_context.authorizer.jwt_scopes == event["requestContext"]["authorizer"]["jwt"]["scopes"] + assert request_context.domain_name == event["requestContext"]["domainName"] + assert request_context.domain_prefix == event["requestContext"]["domainPrefix"] + + http = request_context.http + assert http.method == "POST" + assert http.path == "/my/path" + assert http.protocol == "HTTP/1.1" + assert http.source_ip == "IP" + assert http.user_agent == "agent" + + assert request_context.request_id == event["requestContext"]["requestId"] + assert request_context.route_key == event["requestContext"]["routeKey"] + assert request_context.stage == event["requestContext"]["stage"] + assert request_context.time == event["requestContext"]["time"] + assert request_context.time_epoch == event["requestContext"]["timeEpoch"] + + assert event.body == event["body"] + assert event.path_parameters == event["pathParameters"] + assert event.is_base64_encoded == event["isBase64Encoded"] + assert event.stage_variables == event["stageVariables"] + + +def test_base_proxy_event_get_query_string_value(): + default_value = "default" + set_value = "value" + + event = BaseProxyEvent({}) + value = event.get_query_string_value("test", default_value) + assert value == default_value + + event._data["queryStringParameters"] = {"test": set_value} + value = event.get_query_string_value("test", default_value) + assert value == set_value + + value = event.get_query_string_value("unknown", default_value) + assert value == default_value + + value = event.get_query_string_value("unknown") + assert value is None + + +def test_base_proxy_event_get_header_value(): + default_value = "default" + set_value = "value" + + event = BaseProxyEvent({"headers": {}}) + value = event.get_header_value("test", default_value) + assert value == default_value + + event._data["headers"] = {"test": set_value} + value = event.get_header_value("test", default_value) + assert value == set_value + + value = event.get_header_value("unknown", default_value) + assert value == default_value + + value = event.get_header_value("unknown") + assert value is None + + +def test_kinesis_stream_event(): + event = KinesisStreamEvent(load_event("kinesisStreamEvent.json")) + + records = list(event.records) + assert len(records) == 2 + record = records[0] + + assert record.aws_region == "us-east-2" + assert record.event_id == "shardId-000000000006:49590338271490256608559692538361571095921575989136588898" + assert record.event_name == "aws:kinesis:record" + assert record.event_source == "aws:kinesis" + assert record.event_source_arn == "arn:aws:kinesis:us-east-2:123456789012:stream/lambda-stream" + assert record.event_version == "1.0" + assert record.invoke_identity_arn == "arn:aws:iam::123456789012:role/lambda-role" + + kinesis = record.kinesis + assert kinesis._data["kinesis"] == event["Records"][0]["kinesis"] + + assert kinesis.approximate_arrival_timestamp == 1545084650.987 + assert kinesis.data == event["Records"][0]["kinesis"]["data"] + assert kinesis.kinesis_schema_version == "1.0" + assert kinesis.partition_key == "1" + assert kinesis.sequence_number == "49590338271490256608559692538361571095921575989136588898" + + assert kinesis.data_as_text() == "Hello, this is a test." + + +def test_kinesis_stream_event_json_data(): + json_value = {"test": "value"} + data = base64.b64encode(bytes(json.dumps(json_value), "utf-8")).decode("utf-8") + event = KinesisStreamEvent({"Records": [{"kinesis": {"data": data}}]}) + assert next(event.records).kinesis.data_as_json() == json_value + + +def test_alb_event(): + event = ALBEvent(load_event("albEvent.json")) + assert event.request_context.elb_target_group_arn == event["requestContext"]["elb"]["targetGroupArn"] + assert event.http_method == event["httpMethod"] + assert event.path == event["path"] + assert event.query_string_parameters == event["queryStringParameters"] + assert event.headers == event["headers"] + assert event.multi_value_query_string_parameters == event.get("multiValueQueryStringParameters") + assert event.multi_value_headers == event.get("multiValueHeaders") + assert event.body == event["body"] + assert event.is_base64_encoded == event["isBase64Encoded"]