Skip to content

Commit 3a68baa

Browse files
author
Ran Isenberg
committed
feat: RFC: Validate incoming and outgoing events utility #95
1 parent 81539a0 commit 3a68baa

File tree

7 files changed

+495
-78
lines changed

7 files changed

+495
-78
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ A suite of utilities for AWS Lambda Functions that makes tracing with AWS X-Ray,
1313
* **[Logging](https://awslabs.github.io/aws-lambda-powertools-python/core/logger/)** - Structured logging made easier, and decorator to enrich structured logging with key Lambda context details
1414
* **[Metrics](https://awslabs.github.io/aws-lambda-powertools-python/core/metrics/)** - Custom Metrics created asynchronously via CloudWatch Embedded Metric Format (EMF)
1515
* **[Bring your own middleware](https://awslabs.github.io/aws-lambda-powertools-python/utilities/middleware_factory/)** - Decorator factory to create your own middleware to run logic before, and after each Lambda invocation
16+
* **[Validation](https://)** -
1617

1718
### Installation
1819

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Validation utility
2+
"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from datetime import date, datetime
2+
from typing import Any, Dict, List, Optional
3+
4+
from pydantic import BaseModel, root_validator
5+
from typing_extensions import Literal
6+
7+
8+
class DynamoScheme(BaseModel):
9+
ApproximateCreationDateTime: date
10+
Keys: Dict[Literal["id"], Dict[Literal["S"], str]]
11+
NewImage: Optional[Dict[str, Any]] = {}
12+
OldImage: Optional[Dict[str, Any]] = {}
13+
SequenceNumber: str
14+
SizeBytes: int
15+
StreamViewType: Literal["NEW_AND_OLD_IMAGES", "KEYS_ONLY", "NEW_IMAGE", "OLD_IMAGE"]
16+
17+
@root_validator
18+
def check_one_image_exists(cls, values):
19+
newimg, oldimg = values.get("NewImage"), values.get("OldImage")
20+
stream_type = values.get("StreamViewType")
21+
if stream_type == "NEW_AND_OLD_IMAGES" and not newimg and not oldimg:
22+
raise TypeError("DynamoDB streams schema failed validation, missing both new & old stream images")
23+
return values
24+
25+
26+
class DynamoRecordSchema(BaseModel):
27+
eventID: str
28+
eventName: Literal["INSERT", "MODIFY", "REMOVE"]
29+
eventVersion: float
30+
eventSource: Literal["aws:dynamodb"]
31+
awsRegion: str
32+
eventSourceARN: str
33+
dynamodb: DynamoScheme
34+
35+
36+
class DynamoStreamSchema(BaseModel):
37+
Records: List[DynamoRecordSchema]
38+
39+
40+
class EventBridgeSchema(BaseModel):
41+
version: str
42+
id: str # noqa: A003,VNE003
43+
source: str
44+
account: int
45+
time: datetime
46+
region: str
47+
resources: List[str]
48+
detail: Dict[str, Any]
49+
50+
51+
class SqsSchema(BaseModel):
52+
todo: str
53+
54+
55+
class SnsSchema(BaseModel):
56+
todo: str
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import logging
2+
from abc import ABC, abstractmethod
3+
from typing import Any, Callable, Dict
4+
5+
from pydantic import BaseModel, ValidationError
6+
7+
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
8+
from aws_lambda_powertools.validation.schemas import DynamoStreamSchema, EventBridgeSchema
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class Envelope(ABC):
14+
def _parse_user_dict_schema(self, user_event: Dict[str, Any], inbound_schema_model: BaseModel) -> Any:
15+
logger.debug("parsing user dictionary schema")
16+
try:
17+
return inbound_schema_model(**user_event)
18+
except (ValidationError, TypeError):
19+
logger.exception("Valdation exception while extracting user custom schema")
20+
raise
21+
22+
def _parse_user_json_string_schema(self, user_event: str, inbound_schema_model: BaseModel) -> Any:
23+
logger.debug("parsing user dictionary schema")
24+
try:
25+
return inbound_schema_model.parse_raw(user_event)
26+
except (ValidationError, TypeError):
27+
logger.exception("Valdation exception while extracting user custom schema")
28+
raise
29+
30+
@abstractmethod
31+
def parse(self, event: Dict[str, Any], inbound_schema_model: BaseModel) -> Any:
32+
return NotImplemented
33+
34+
35+
class UserEnvelope(Envelope):
36+
def parse(self, event: Dict[str, Any], inbound_schema_model: BaseModel) -> Any:
37+
try:
38+
return inbound_schema_model(**event)
39+
except (ValidationError, TypeError):
40+
logger.exception("Valdation exception received from input user custom envelope event")
41+
raise
42+
43+
44+
class EventBridgeEnvelope(Envelope):
45+
def parse(self, event: Dict[str, Any], inbound_schema_model: BaseModel) -> Any:
46+
try:
47+
parsed_envelope = EventBridgeSchema(**event)
48+
except (ValidationError, TypeError):
49+
logger.exception("Valdation exception received from input eventbridge event")
50+
raise
51+
return self._parse_user_dict_schema(parsed_envelope.detail, inbound_schema_model)
52+
53+
54+
class DynamoEnvelope(Envelope):
55+
def parse(self, event: Dict[str, Any], inbound_schema_model: BaseModel) -> Any:
56+
try:
57+
parsed_envelope = DynamoStreamSchema(**event)
58+
except (ValidationError, TypeError):
59+
logger.exception("Valdation exception received from input dynamodb stream event")
60+
raise
61+
output = []
62+
for record in parsed_envelope.Records:
63+
parsed_new_image = (
64+
{}
65+
if not record.dynamodb.NewImage
66+
else self._parse_user_dict_schema(record.dynamodb.NewImage, inbound_schema_model)
67+
) # noqa: E501
68+
parsed_old_image = (
69+
{}
70+
if not record.dynamodb.OldImage
71+
else self._parse_user_dict_schema(record.dynamodb.OldImage, inbound_schema_model)
72+
) # noqa: E501
73+
output.append({"new": parsed_new_image, "old": parsed_old_image})
74+
return output
75+
76+
77+
@lambda_handler_decorator
78+
def validator(
79+
handler: Callable[[Dict, Any], Any],
80+
event: Dict[str, Any],
81+
context: Dict[str, Any],
82+
inbound_schema_model: BaseModel,
83+
outbound_schema_model: BaseModel,
84+
envelope: Envelope,
85+
) -> Any:
86+
"""Decorator to create validation for lambda handlers events - both inbound and outbound
87+
88+
As Lambda follows (event, context) signature we can remove some of the boilerplate
89+
and also capture any exception any Lambda function throws or its response as metadata
90+
91+
Example
92+
-------
93+
**Lambda function using validation decorator**
94+
95+
@validator(inbound=inbound_schema_model, outbound=outbound_schema_model)
96+
def handler(parsed_event_model, context):
97+
...
98+
99+
Parameters
100+
----------
101+
todo add
102+
103+
Raises
104+
------
105+
err
106+
TypeError or pydantic.ValidationError or any exception raised by the lambda handler itself
107+
"""
108+
lambda_handler_name = handler.__name__
109+
logger.debug("Validating inbound schema")
110+
parsed_event_model = envelope.parse(event, inbound_schema_model)
111+
try:
112+
logger.debug(f"Calling handler {lambda_handler_name}")
113+
response = handler({"orig": event, "custom": parsed_event_model}, context)
114+
logger.debug("Received lambda handler response successfully")
115+
logger.debug(response)
116+
except Exception:
117+
logger.exception(f"Exception received from {lambda_handler_name}")
118+
raise
119+
120+
try:
121+
logger.debug("Validating outbound response schema")
122+
outbound_schema_model(**response)
123+
except (ValidationError, TypeError):
124+
logger.exception(f"Validation exception received from {lambda_handler_name} response event")
125+
raise
126+
return response

0 commit comments

Comments
 (0)