diff --git a/Makefile b/Makefile index 0ee0ee76fbd..4cee795ea30 100644 --- a/Makefile +++ b/Makefile @@ -90,3 +90,10 @@ changelog: mypy: poetry run mypy --pretty aws_lambda_powertools + +format-examples: + poetry run isort docs/examples + poetry run black docs/examples/*/*/*.py + +lint-examples: + poetry run python3 -m py_compile docs/examples/*/*/*.py diff --git a/docs/examples/utilities/parser/parser_envelope.py b/docs/examples/utilities/parser/parser_envelope.py new file mode 100644 index 00000000000..529dc630523 --- /dev/null +++ b/docs/examples/utilities/parser/parser_envelope.py @@ -0,0 +1,35 @@ +from aws_lambda_powertools.utilities.parser import BaseModel, envelopes, event_parser, parse +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class UserModel(BaseModel): + username: str + password1: str + password2: str + + +payload = { + "version": "0", + "id": "6a7e8feb-b491-4cf7-a9f1-bf3703467718", + "detail-type": "CustomerSignedUp", + "source": "CustomerService", + "account": "111122223333", + "time": "2020-10-22T18:43:48Z", + "region": "us-west-1", + "resources": ["some_additional_"], + "detail": { + "username": "universe", + "password1": "myp@ssword", + "password2": "repeat password", + }, +} + +ret = parse(model=UserModel, envelope=envelopes.EventBridgeEnvelope, event=payload) + +# Parsed model only contains our actual model, not the entire EventBridge + Payload parsed +assert ret.password1 == ret.password2 + +# Same behaviour but using our decorator +@event_parser(model=UserModel, envelope=envelopes.EventBridgeEnvelope) +def handler(event: UserModel, context: LambdaContext): + assert event.password1 == event.password2 diff --git a/docs/examples/utilities/parser/parser_event_bridge_envelope.py b/docs/examples/utilities/parser/parser_event_bridge_envelope.py new file mode 100644 index 00000000000..83a7067c2f5 --- /dev/null +++ b/docs/examples/utilities/parser/parser_event_bridge_envelope.py @@ -0,0 +1,26 @@ +from typing import Any, Dict, Optional, TypeVar, Union + +from aws_lambda_powertools.utilities.parser import BaseEnvelope, BaseModel +from aws_lambda_powertools.utilities.parser.models import EventBridgeModel + +Model = TypeVar("Model", bound=BaseModel) + + +class EventBridgeEnvelope(BaseEnvelope): + def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Model) -> Optional[Model]: + """Parses data found with model provided + + Parameters + ---------- + data : Dict + Lambda event to be parsed + model : Model + Data model provided to parse after extracting data using envelope + + Returns + ------- + Any + Parsed detail payload with model provided + """ + parsed_envelope = EventBridgeModel.parse_obj(data) + return self._parse(data=parsed_envelope.detail, model=model) diff --git a/docs/examples/utilities/parser/parser_event_bridge_model.py b/docs/examples/utilities/parser/parser_event_bridge_model.py new file mode 100644 index 00000000000..286d9f2dc57 --- /dev/null +++ b/docs/examples/utilities/parser/parser_event_bridge_model.py @@ -0,0 +1,16 @@ +from datetime import datetime +from typing import Any, Dict, List + +from aws_lambda_powertools.utilities.parser import BaseModel, Field + + +class EventBridgeModel(BaseModel): + version: str + id: str # noqa: A003,VNE003 + source: str + account: str + time: datetime + region: str + resources: List[str] + detail_type: str = Field(None, alias="detail-type") + detail: Dict[str, Any] diff --git a/docs/examples/utilities/parser/parser_event_parser_decorator.py b/docs/examples/utilities/parser/parser_event_parser_decorator.py new file mode 100644 index 00000000000..d0aa9340eb6 --- /dev/null +++ b/docs/examples/utilities/parser/parser_event_parser_decorator.py @@ -0,0 +1,44 @@ +import json +from typing import List, Optional + +from aws_lambda_powertools.utilities.parser import BaseModel, event_parser +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class OrderItem(BaseModel): + id: int + quantity: int + description: str + + +class Order(BaseModel): + id: int + description: str + items: List[OrderItem] # nesting models are supported + optional_field: Optional[str] # this field may or may not be available when parsing + + +@event_parser(model=Order) +def handler(event: Order, context: LambdaContext): + print(event.id) + print(event.description) + print(event.items) + + order_items = [item for item in event.items] + ... + + +payload = { + "id": 10876546789, + "description": "My order", + "items": [ + { + "id": 1015938732, + "quantity": 1, + "description": "item xpto", + }, + ], +} + +handler(event=payload, context=LambdaContext()) +handler(event=json.dumps(payload), context=LambdaContext()) # also works if event is a JSON string diff --git a/docs/examples/utilities/parser/parser_extending_builtin_models.py b/docs/examples/utilities/parser/parser_extending_builtin_models.py new file mode 100644 index 00000000000..b02e3176983 --- /dev/null +++ b/docs/examples/utilities/parser/parser_extending_builtin_models.py @@ -0,0 +1,52 @@ +from typing import List, Optional + +from aws_lambda_powertools.utilities.parser import BaseModel, parse +from aws_lambda_powertools.utilities.parser.models import EventBridgeModel + + +class OrderItem(BaseModel): + id: int + quantity: int + description: str + + +class Order(BaseModel): + id: int + description: str + items: List[OrderItem] + + +class OrderEventModel(EventBridgeModel): + detail: Order + + +payload = { + "version": "0", + "id": "6a7e8feb-b491-4cf7-a9f1-bf3703467718", + "detail-type": "OrderPurchased", + "source": "OrderService", + "account": "111122223333", + "time": "2020-10-22T18:43:48Z", + "region": "us-west-1", + "resources": ["some_additional"], + "detail": { + "id": 10876546789, + "description": "My order", + "items": [ + { + "id": 1015938732, + "quantity": 1, + "description": "item xpto", + }, + ], + }, +} + +ret = parse(model=OrderEventModel, event=payload) + +assert ret.source == "OrderService" +assert ret.detail.description == "My order" +assert ret.detail_type == "OrderPurchased" # we rename it to snake_case since detail-type is an invalid name + +for order_item in ret.detail.items: + ... diff --git a/docs/examples/utilities/parser/parser_model_export.py b/docs/examples/utilities/parser/parser_model_export.py new file mode 100644 index 00000000000..e03277820f6 --- /dev/null +++ b/docs/examples/utilities/parser/parser_model_export.py @@ -0,0 +1,32 @@ +from aws_lambda_powertools.utilities import Logger +from aws_lambda_powertools.utilities.parser import BaseModel, ValidationError, parse, validator + +logger = Logger(service="user") + + +class UserModel(BaseModel): + username: str + password1: str + password2: str + + +payload = { + "username": "universe", + "password1": "myp@ssword", + "password2": "repeat password", +} + + +def my_function(): + try: + return parse(model=UserModel, event=payload) + except ValidationError as e: + logger.exception(e.json()) + return {"status_code": 400, "message": "Invalid username"} + + +User: UserModel = my_function() +user_dict = User.dict() +user_json = User.json() +user_json_schema_as_dict = User.schema() +user_json_schema_as_json = User.schema_json(indent=2) diff --git a/docs/examples/utilities/parser/parser_models.py b/docs/examples/utilities/parser/parser_models.py new file mode 100644 index 00000000000..030e27a5135 --- /dev/null +++ b/docs/examples/utilities/parser/parser_models.py @@ -0,0 +1,16 @@ +from typing import List, Optional + +from aws_lambda_powertools.utilities.parser import BaseModel + + +class OrderItem(BaseModel): + id: int + quantity: int + description: str + + +class Order(BaseModel): + id: int + description: str + items: List[OrderItem] # nesting models are supported + optional_field: Optional[str] # this field may or may not be available when parsing diff --git a/docs/examples/utilities/parser/parser_parse_function.py b/docs/examples/utilities/parser/parser_parse_function.py new file mode 100644 index 00000000000..834fe8e1197 --- /dev/null +++ b/docs/examples/utilities/parser/parser_parse_function.py @@ -0,0 +1,39 @@ +from typing import List, Optional + +from aws_lambda_powertools.utilities.parser import BaseModel, ValidationError, parse + + +class OrderItem(BaseModel): + id: int + quantity: int + description: str + + +class Order(BaseModel): + id: int + description: str + items: List[OrderItem] # nesting models are supported + optional_field: Optional[str] # this field may or may not be available when parsing + + +payload = { + "id": 10876546789, + "description": "My order", + "items": [ + { + # this will cause a validation error + "id": [1015938732], + "quantity": 1, + "description": "item xpto", + } + ], +} + + +def my_function(): + try: + parsed_payload: Order = parse(event=payload, model=Order) + # payload dict is now parsed into our model + return parsed_payload.items + except ValidationError: + return {"status_code": 400, "message": "Invalid order"} diff --git a/docs/examples/utilities/parser/parser_validator.py b/docs/examples/utilities/parser/parser_validator.py new file mode 100644 index 00000000000..aa6f42fb9b0 --- /dev/null +++ b/docs/examples/utilities/parser/parser_validator.py @@ -0,0 +1,14 @@ +from aws_lambda_powertools.utilities.parser import BaseModel, parse, validator + + +class HelloWorldModel(BaseModel): + message: str + + @validator("message") + def is_hello_world(cls, v): + if v != "hello world": + raise ValueError("Message must be hello world!") + return v + + +parse(model=HelloWorldModel, event={"message": "hello universe"}) diff --git a/docs/examples/utilities/parser/parser_validator_all.py b/docs/examples/utilities/parser/parser_validator_all.py new file mode 100644 index 00000000000..d2905e812bb --- /dev/null +++ b/docs/examples/utilities/parser/parser_validator_all.py @@ -0,0 +1,16 @@ +from aws_lambda_powertools.utilities.parser import BaseModel, parse, validator + + +class HelloWorldModel(BaseModel): + message: str + sender: str + + @validator("*") + def has_whitespace(cls, v): + if " " not in v: + raise ValueError("Must have whitespace...") + + return v + + +parse(model=HelloWorldModel, event={"message": "hello universe", "sender": "universe"}) diff --git a/docs/examples/utilities/parser/parser_validator_root.py b/docs/examples/utilities/parser/parser_validator_root.py new file mode 100644 index 00000000000..934866d2a1a --- /dev/null +++ b/docs/examples/utilities/parser/parser_validator_root.py @@ -0,0 +1,23 @@ +from aws_lambda_powertools.utilities.parser import BaseModel, parse, root_validator + + +class UserModel(BaseModel): + username: str + password1: str + password2: str + + @root_validator + def check_passwords_match(cls, values): + pw1, pw2 = values.get("password1"), values.get("password2") + if pw1 is not None and pw2 is not None and pw1 != pw2: + raise ValueError("passwords do not match") + return values + + +payload = { + "username": "universe", + "password1": "myp@ssword", + "password2": "repeat password", +} + +parse(model=UserModel, event=payload) diff --git a/docs/utilities/parser.md b/docs/utilities/parser.md index c17e2f173c5..e31f29cfcd4 100644 --- a/docs/utilities/parser.md +++ b/docs/utilities/parser.md @@ -29,19 +29,7 @@ Install parser's extra dependencies using **`pip install aws-lambda-powertools[p You can define models to parse incoming events by inheriting from `BaseModel`. ```python title="Defining an Order data model" -from aws_lambda_powertools.utilities.parser import BaseModel -from typing import List, Optional - -class OrderItem(BaseModel): - id: int - quantity: int - description: str - -class Order(BaseModel): - id: int - description: str - items: List[OrderItem] # nesting models are supported - optional_field: Optional[str] # this field may or may not be available when parsing +--8<-- "docs/examples/utilities/parser/parser_models.py" ``` These are simply Python classes that inherit from BaseModel. **Parser** enforces type hints declared in your model at runtime. @@ -59,93 +47,16 @@ Use the decorator for fail fast scenarios where you want your Lambda function to ???+ note **This decorator will replace the `event` object with the parsed model if successful**. This means you might be careful when nesting other decorators that expect `event` to be a `dict`. -```python hl_lines="18" title="Parsing and validating upon invocation with event_parser decorator" -from aws_lambda_powertools.utilities.parser import event_parser, BaseModel -from aws_lambda_powertools.utilities.typing import LambdaContext -from typing import List, Optional - -import json - -class OrderItem(BaseModel): - id: int - quantity: int - description: str - -class Order(BaseModel): - id: int - description: str - items: List[OrderItem] # nesting models are supported - optional_field: Optional[str] # this field may or may not be available when parsing - - -@event_parser(model=Order) -def handler(event: Order, context: LambdaContext): - print(event.id) - print(event.description) - print(event.items) - - order_items = [item for item in event.items] - ... - -payload = { - "id": 10876546789, - "description": "My order", - "items": [ - { - "id": 1015938732, - "quantity": 1, - "description": "item xpto" - } - ] -} - -handler(event=payload, context=LambdaContext()) -handler(event=json.dumps(payload), context=LambdaContext()) # also works if event is a JSON string +```python hl_lines="21" title="Parsing and validating upon invocation with event_parser decorator" +--8<-- "docs/examples/utilities/parser/parser_event_parser_decorator.py" ``` ### parse function Use this standalone function when you want more control over the data validation process, for example returning a 400 error for malformed payloads. -```python hl_lines="21 30" title="Using standalone parse function for more flexibility" -from aws_lambda_powertools.utilities.parser import parse, BaseModel, ValidationError -from typing import List, Optional - -class OrderItem(BaseModel): - id: int - quantity: int - description: str - -class Order(BaseModel): - id: int - description: str - items: List[OrderItem] # nesting models are supported - optional_field: Optional[str] # this field may or may not be available when parsing - - -payload = { - "id": 10876546789, - "description": "My order", - "items": [ - { - # this will cause a validation error - "id": [1015938732], - "quantity": 1, - "description": "item xpto" - } - ] -} - -def my_function(): - try: - parsed_payload: Order = parse(event=payload, model=Order) - # payload dict is now parsed into our model - return parsed_payload.items - except ValidationError: - return { - "status_code": 400, - "message": "Invalid order" - } +```python hl_lines="24 35" title="Using standalone parse function for more flexibility" +--8<-- "docs/examples/utilities/parser/parser_parse_function.py" ``` ## Built-in models @@ -174,56 +85,8 @@ You can extend them to include your own models, and yet have all other known fie ???+ tip For Mypy users, we only allow type override for fields where payload is injected e.g. `detail`, `body`, etc. - -```python hl_lines="16-17 28 41" title="Extending EventBridge model as an example" -from aws_lambda_powertools.utilities.parser import parse, BaseModel -from aws_lambda_powertools.utilities.parser.models import EventBridgeModel - -from typing import List, Optional - -class OrderItem(BaseModel): - id: int - quantity: int - description: str - -class Order(BaseModel): - id: int - description: str - items: List[OrderItem] - -class OrderEventModel(EventBridgeModel): - detail: Order - -payload = { - "version": "0", - "id": "6a7e8feb-b491-4cf7-a9f1-bf3703467718", - "detail-type": "OrderPurchased", - "source": "OrderService", - "account": "111122223333", - "time": "2020-10-22T18:43:48Z", - "region": "us-west-1", - "resources": ["some_additional"], - "detail": { - "id": 10876546789, - "description": "My order", - "items": [ - { - "id": 1015938732, - "quantity": 1, - "description": "item xpto" - } - ] - } -} - -ret = parse(model=OrderEventModel, event=payload) - -assert ret.source == "OrderService" -assert ret.detail.description == "My order" -assert ret.detail_type == "OrderPurchased" # we rename it to snake_case since detail-type is an invalid name - -for order_item in ret.detail.items: - ... +```python hl_lines="19-20 32 45" title="Extending EventBridge model as an example" +--8<-- "docs/examples/utilities/parser/parser_extending_builtin_models.py" ``` **What's going on here, you might ask**: @@ -248,40 +111,8 @@ Envelopes can be used via `envelope` parameter available in both `parse` functio Here's an example of parsing a model found in an event coming from EventBridge, where all you want is what's inside the `detail` key. -```python hl_lines="18-22 25 31" title="Parsing payload in a given key only using envelope feature" -from aws_lambda_powertools.utilities.parser import event_parser, parse, BaseModel, envelopes -from aws_lambda_powertools.utilities.typing import LambdaContext - -class UserModel(BaseModel): - username: str - password1: str - password2: str - -payload = { - "version": "0", - "id": "6a7e8feb-b491-4cf7-a9f1-bf3703467718", - "detail-type": "CustomerSignedUp", - "source": "CustomerService", - "account": "111122223333", - "time": "2020-10-22T18:43:48Z", - "region": "us-west-1", - "resources": ["some_additional_"], - "detail": { - "username": "universe", - "password1": "myp@ssword", - "password2": "repeat password" - } -} - -ret = parse(model=UserModel, envelope=envelopes.EventBridgeEnvelope, event=payload) - -# Parsed model only contains our actual model, not the entire EventBridge + Payload parsed -assert ret.password1 == ret.password2 - -# Same behaviour but using our decorator -@event_parser(model=UserModel, envelope=envelopes.EventBridgeEnvelope) -def handler(event: UserModel, context: LambdaContext): - assert event.password1 == event.password2 +```python hl_lines="20-24 27 33" title="Parsing payload in a given key only using envelope feature" +--8<-- "docs/examples/utilities/parser/parser_envelope.py" ``` **What's going on here, you might ask**: @@ -316,53 +147,13 @@ Here's a snippet of how the EventBridge envelope we demonstrated previously is i === "EventBridge Model" ```python - from datetime import datetime - from typing import Any, Dict, List - - from aws_lambda_powertools.utilities.parser import BaseModel, Field - - - class EventBridgeModel(BaseModel): - version: str - id: str # noqa: A003,VNE003 - source: str - account: str - time: datetime - region: str - resources: List[str] - detail_type: str = Field(None, alias="detail-type") - detail: Dict[str, Any] + --8<-- "docs/examples/utilities/parser/parser_event_bridge_model.py" ``` === "EventBridge Envelope" - ```python hl_lines="8 10 25 26" - from aws_lambda_powertools.utilities.parser import BaseEnvelope, models - from aws_lambda_powertools.utilities.parser.models import EventBridgeModel - - from typing import Any, Dict, Optional, TypeVar - - Model = TypeVar("Model", bound=BaseModel) - - class EventBridgeEnvelope(BaseEnvelope): - - def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Model) -> Optional[Model]: - """Parses data found with model provided - - Parameters - ---------- - data : Dict - Lambda event to be parsed - model : Model - Data model provided to parse after extracting data using envelope - - Returns - ------- - Any - Parsed detail payload with model provided - """ - parsed_envelope = EventBridgeModel.parse_obj(data) - return self._parse(data=parsed_envelope.detail, model=model) + ```python hl_lines="9-10 25 26" + --8<-- "docs/examples/utilities/parser/parser_event_bridge_envelope.py" ``` **What's going on here, you might ask**: @@ -393,19 +184,8 @@ Keep the following in mind regardless of which decorator you end up using it: Quick validation to verify whether the field `message` has the value of `hello world`. -```python hl_lines="6" title="Data field validation with validator" -from aws_lambda_powertools.utilities.parser import parse, BaseModel, validator - -class HelloWorldModel(BaseModel): - message: str - - @validator('message') - def is_hello_world(cls, v): - if v != "hello world": - raise ValueError("Message must be hello world!") - return v - -parse(model=HelloWorldModel, event={"message": "hello universe"}) +```python hl_lines="7" title="Data field validation with validator" +--8<-- "docs/examples/utilities/parser/parser_validator.py" ``` If you run as-is, you should expect the following error with the message we provided in our exception: @@ -417,21 +197,8 @@ message Alternatively, you can pass `'*'` as an argument for the decorator so that you can validate every value available. -```python hl_lines="7" title="Validating all data fields with custom logic" -from aws_lambda_powertools.utilities.parser import parse, BaseModel, validator - -class HelloWorldModel(BaseModel): - message: str - sender: str - - @validator('*') - def has_whitespace(cls, v): - if ' ' not in v: - raise ValueError("Must have whitespace...") - - return v - -parse(model=HelloWorldModel, event={"message": "hello universe", "sender": "universe"}) +```python hl_lines="8" title="Validating all data fields with custom logic" +--8<-- "docs/examples/utilities/parser/parser_validator_all.py" ``` ### validating entire model @@ -439,27 +206,7 @@ parse(model=HelloWorldModel, event={"message": "hello universe", "sender": "univ `root_validator` can help when you have a complex validation mechanism. For example finding whether data has been omitted, comparing field values, etc. ```python title="Comparing and validating multiple fields at once with root_validator" -from aws_lambda_powertools.utilities.parser import parse, BaseModel, root_validator - -class UserModel(BaseModel): - username: str - password1: str - password2: str - - @root_validator - def check_passwords_match(cls, values): - pw1, pw2 = values.get('password1'), values.get('password2') - if pw1 is not None and pw2 is not None and pw1 != pw2: - raise ValueError('passwords do not match') - return values - -payload = { - "username": "universe", - "password1": "myp@ssword", - "password2": "repeat password" -} - -parse(model=UserModel, event=payload) +--8<-- "docs/examples/utilities/parser/parser_validator_root.py" ``` ???+ info @@ -474,38 +221,8 @@ There are number of advanced use cases well documented in Pydantic's doc such as Two possible unknown use cases are Models and exception' serialization. Models have methods to [export them](https://pydantic-docs.helpmanual.io/usage/exporting_models/) as `dict`, `JSON`, `JSON Schema`, and Validation exceptions can be exported as JSON. -```python hl_lines="21 28-31" title="Converting data models in various formats" -from aws_lambda_powertools.utilities import Logger -from aws_lambda_powertools.utilities.parser import parse, BaseModel, ValidationError, validator - -logger = Logger(service="user") - -class UserModel(BaseModel): - username: str - password1: str - password2: str - -payload = { - "username": "universe", - "password1": "myp@ssword", - "password2": "repeat password" -} - -def my_function(): - try: - return parse(model=UserModel, event=payload) - except ValidationError as e: - logger.exception(e.json()) - return { - "status_code": 400, - "message": "Invalid username" - } - -User: UserModel = my_function() -user_dict = User.dict() -user_json = User.json() -user_json_schema_as_dict = User.schema() -user_json_schema_as_json = User.schema_json(indent=2) +```python hl_lines="24 29-32" title="Converting data models in various formats" +--8<-- "docs/examples/utilities/parser/parser_model_export.py" ``` These can be quite useful when manipulating models that later need to be serialized as inputs for services like DynamoDB, EventBridge, etc.