GeezerVison is showing github.com — fix(event_handler): allow fine grained Response with data validation … · dastra/powertools-lambda-python@270060d
File treeExpand file treeCollapse file tree Open diff view settings aws_lambda_powertools/event_handler tests/functional/event_handler
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: aws_lambda_powertools/event_handler/api_gateway.py +3-2Lines changed: 3 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 66 | 66 | _ROUTE_REGEX = "^{}$" | 67 | 67 |
| 68 | 68 | ResponseEventT = TypeVar("ResponseEventT", bound=BaseProxyEvent) | | 69 | +ResponseT = TypeVar("ResponseT") | 69 | 70 |
| 70 | 71 | if TYPE_CHECKING: | 71 | 72 | from aws_lambda_powertools.event_handler.openapi.compat import ( | @@ -207,14 +208,14 @@ def to_dict(self, origin: Optional[str]) -> Dict[str, str]: | 207 | 208 | return headers | 208 | 209 |
| 209 | 210 |
| 210 | | -class Response: | | 211 | +class Response(Generic[ResponseT]): | 211 | 212 | """Response data class that provides greater control over what is returned from the proxy event""" | 212 | 213 |
| 213 | 214 | def __init__( | 214 | 215 | self, | 215 | 216 | status_code: int, | 216 | 217 | content_type: Optional[str] = None, | 217 | | - body: Any = None, | | 218 | + body: Optional[ResponseT] = None, | 218 | 219 | headers: Optional[Dict[str, Union[str, List[str]]]] = None, | 219 | 220 | cookies: Optional[List[Cookie]] = None, | 220 | 221 | compress: Optional[bool] = None, | |
Collapse file Copy file name to clipboardExpand all lines: aws_lambda_powertools/event_handler/openapi/params.py +12Lines changed: 12 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 5 | 5 | from pydantic import BaseConfig | 6 | 6 | from pydantic.fields import FieldInfo | 7 | 7 |
| | 8 | +from aws_lambda_powertools.event_handler import Response | 8 | 9 | from aws_lambda_powertools.event_handler.openapi.compat import ( | 9 | 10 | ModelField, | 10 | 11 | Required, | @@ -724,13 +725,24 @@ def get_field_info_and_type_annotation(annotation, value, is_path_param: bool) - | 724 | 725 | # If the annotation is an Annotated type, we need to extract the type annotation and the FieldInfo | 725 | 726 | if get_origin(annotation) is Annotated: | 726 | 727 | field_info, type_annotation = get_field_info_annotated_type(annotation, value, is_path_param) | | 728 | + # If the annotation is a Response type, we recursively call this function with the inner type | | 729 | + elif get_origin(annotation) is Response: | | 730 | + field_info, type_annotation = get_field_info_response_type(annotation, value) | 727 | 731 | # If the annotation is not an Annotated type, we use it as the type annotation | 728 | 732 | else: | 729 | 733 | type_annotation = annotation | 730 | 734 |
| 731 | 735 | return field_info, type_annotation | 732 | 736 |
| 733 | 737 |
| | 738 | +def get_field_info_response_type(annotation, value) -> Tuple[Optional[FieldInfo], Any]: | | 739 | + # Example: get_args(Response[inner_type]) == (inner_type,) # noqa: ERA001 | | 740 | + (inner_type,) = get_args(annotation) | | 741 | + | | 742 | + # Recursively resolve the inner type | | 743 | + return get_field_info_and_type_annotation(inner_type, value, False) | | 744 | + | | 745 | + | 734 | 746 | def get_field_info_annotated_type(annotation, value, is_path_param: bool) -> Tuple[Optional[FieldInfo], Any]: | 735 | 747 | """ | 736 | 748 | Get the FieldInfo and type annotation from an Annotated type. | |
Collapse file Copy file name to clipboardExpand all lines: tests/functional/event_handler/test_openapi_params.py +19-1Lines changed: 19 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 4 | 4 |
| 5 | 5 | from pydantic import BaseModel | 6 | 6 |
| 7 | | -from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver | | 7 | +from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver, Response | 8 | 8 | from aws_lambda_powertools.event_handler.openapi.models import ( | 9 | 9 | Example, | 10 | 10 | Parameter, | @@ -153,6 +153,24 @@ def handler() -> str: | 153 | 153 | assert response.schema_.type == "string" | 154 | 154 |
| 155 | 155 |
| | 156 | +def test_openapi_with_response_returns(): | | 157 | + app = APIGatewayRestResolver() | | 158 | + | | 159 | + @app.get("/") | | 160 | + def handler() -> Response[Annotated[str, Body(title="Response title")]]: | | 161 | + return Response(body="Hello, world", status_code=200) | | 162 | + | | 163 | + schema = app.get_openapi_schema() | | 164 | + assert len(schema.paths.keys()) == 1 | | 165 | + | | 166 | + get = schema.paths["/"].get | | 167 | + assert get.parameters is None | | 168 | + | | 169 | + response = get.responses[200].content[JSON_CONTENT_TYPE] | | 170 | + assert response.schema_.title == "Response title" | | 171 | + assert response.schema_.type == "string" | | 172 | + | | 173 | + | 156 | 174 | def test_openapi_with_omitted_param(): | 157 | 175 | app = APIGatewayRestResolver() | 158 | 176 |
| |
Collapse file Copy file name to clipboardExpand all lines: tests/functional/event_handler/test_openapi_validation_middleware.py +49-1Lines changed: 49 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 6 | 6 |
| 7 | 7 | from pydantic import BaseModel | 8 | 8 |
| 9 | | -from aws_lambda_powertools.event_handler import APIGatewayRestResolver | | 9 | +from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response | 10 | 10 | from aws_lambda_powertools.event_handler.openapi.params import Body | 11 | 11 | from aws_lambda_powertools.shared.types import Annotated | 12 | 12 | from tests.functional.utils import load_event | @@ -330,3 +330,51 @@ def handler(user: Annotated[Model, Body(embed=True)]) -> Model: | 330 | 330 | LOAD_GW_EVENT["body"] = json.dumps({"user": {"name": "John", "age": 30}}) | 331 | 331 | result = app(LOAD_GW_EVENT, {}) | 332 | 332 | assert result["statusCode"] == 200 | | 333 | + | | 334 | + | | 335 | +def test_validate_response_return(): | | 336 | + # GIVEN an APIGatewayRestResolver with validation enabled | | 337 | + app = APIGatewayRestResolver(enable_validation=True) | | 338 | + | | 339 | + class Model(BaseModel): | | 340 | + name: str | | 341 | + age: int | | 342 | + | | 343 | + # WHEN a handler is defined with a body parameter | | 344 | + @app.post("/") | | 345 | + def handler(user: Model) -> Response[Model]: | | 346 | + return Response(body=user, status_code=200) | | 347 | + | | 348 | + LOAD_GW_EVENT["httpMethod"] = "POST" | | 349 | + LOAD_GW_EVENT["path"] = "/" | | 350 | + LOAD_GW_EVENT["body"] = json.dumps({"name": "John", "age": 30}) | | 351 | + | | 352 | + # THEN the handler should be invoked and return 200 | | 353 | + # THEN the body must be a dict | | 354 | + result = app(LOAD_GW_EVENT, {}) | | 355 | + assert result["statusCode"] == 200 | | 356 | + assert result["body"] == {"name": "John", "age": 30} | | 357 | + | | 358 | + | | 359 | +def test_validate_response_invalid_return(): | | 360 | + # GIVEN an APIGatewayRestResolver with validation enabled | | 361 | + app = APIGatewayRestResolver(enable_validation=True) | | 362 | + | | 363 | + class Model(BaseModel): | | 364 | + name: str | | 365 | + age: int | | 366 | + | | 367 | + # WHEN a handler is defined with a body parameter | | 368 | + @app.post("/") | | 369 | + def handler(user: Model) -> Response[Model]: | | 370 | + return Response(body=user, status_code=200) | | 371 | + | | 372 | + LOAD_GW_EVENT["httpMethod"] = "POST" | | 373 | + LOAD_GW_EVENT["path"] = "/" | | 374 | + LOAD_GW_EVENT["body"] = json.dumps({}) | | 375 | + | | 376 | + # THEN the handler should be invoked and return 422 | | 377 | + # THEN the body should have the word missing | | 378 | + result = app(LOAD_GW_EVENT, {}) | | 379 | + assert result["statusCode"] == 422 | | 380 | + assert "missing" in result["body"] |
|