Skip to content

Commit eb005d8

Browse files
Merge branch 'develop' into dependabot/pip/develop/boto3-stubs-1.39.14
2 parents c120a52 + a928277 commit eb005d8

File tree

9 files changed

+391
-143
lines changed

9 files changed

+391
-143
lines changed

.github/workflows/on_closed_issues.yml

Lines changed: 0 additions & 33 deletions
This file was deleted.

.github/workflows/on_opened_pr.yml

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -41,41 +41,3 @@ jobs:
4141
workflow_origin: ${{ github.event.repository.full_name }}
4242
secrets:
4343
token: ${{ secrets.GITHUB_TOKEN }}
44-
check_related_issue:
45-
permissions:
46-
pull-requests: write # label and comment on PR if missing related issue (requirement)
47-
needs: get_pr_details
48-
runs-on: ubuntu-latest
49-
steps:
50-
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
51-
- name: "Ensure related issue is present"
52-
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
53-
env:
54-
PR_BODY: ${{ needs.get_pr_details.outputs.prBody }}
55-
PR_NUMBER: ${{ needs.get_pr_details.outputs.prNumber }}
56-
PR_ACTION: ${{ needs.get_pr_details.outputs.prAction }}
57-
PR_AUTHOR: ${{ needs.get_pr_details.outputs.prAuthor }}
58-
with:
59-
github-token: ${{ secrets.GITHUB_TOKEN }}
60-
script: |
61-
const script = require('.github/scripts/label_missing_related_issue.js')
62-
await script({github, context, core})
63-
check_acknowledge_section:
64-
needs: get_pr_details
65-
runs-on: ubuntu-latest
66-
permissions:
67-
pull-requests: write # label and comment on PR if missing acknowledge section (requirement)
68-
steps:
69-
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
70-
- name: "Ensure acknowledgement section is present"
71-
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
72-
env:
73-
PR_BODY: ${{ needs.get_pr_details.outputs.prBody }}
74-
PR_NUMBER: ${{ needs.get_pr_details.outputs.prNumber }}
75-
PR_ACTION: ${{ needs.get_pr_details.outputs.prAction }}
76-
PR_AUTHOR: ${{ needs.get_pr_details.outputs.prAuthor }}
77-
with:
78-
github-token: ${{ secrets.GITHUB_TOKEN }}
79-
script: |
80-
const script = require('.github/scripts/label_missing_acknowledgement_section.js')
81-
await script({github, context, core})

aws_lambda_powertools/event_handler/middlewares/openapi_validation.py

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
from copy import deepcopy
77
from typing import TYPE_CHECKING, Any, Callable, Mapping, MutableMapping, Sequence
8+
from urllib.parse import parse_qs
89

910
from pydantic import BaseModel
1011

@@ -30,6 +31,11 @@
3031

3132
logger = logging.getLogger(__name__)
3233

34+
# Constants
35+
CONTENT_DISPOSITION_NAME_PARAM = "name="
36+
APPLICATION_JSON_CONTENT_TYPE = "application/json"
37+
APPLICATION_FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
38+
3339

3440
class OpenAPIValidationMiddleware(BaseMiddlewareHandler):
3541
"""
@@ -246,28 +252,61 @@ def _prepare_response_content(
246252

247253
def _get_body(self, app: EventHandlerInstance) -> dict[str, Any]:
248254
"""
249-
Get the request body from the event, and parse it as JSON.
255+
Get the request body from the event, and parse it according to content type.
250256
"""
257+
content_type = app.current_event.headers.get("content-type", "").strip()
258+
259+
# Handle JSON content
260+
if not content_type or content_type.startswith(APPLICATION_JSON_CONTENT_TYPE):
261+
return self._parse_json_data(app)
262+
263+
# Handle URL-encoded form data
264+
elif content_type.startswith(APPLICATION_FORM_CONTENT_TYPE):
265+
return self._parse_form_data(app)
251266

252-
content_type = app.current_event.headers.get("content-type")
253-
if not content_type or content_type.strip().startswith("application/json"):
254-
try:
255-
return app.current_event.json_body
256-
except json.JSONDecodeError as e:
257-
raise RequestValidationError(
258-
[
259-
{
260-
"type": "json_invalid",
261-
"loc": ("body", e.pos),
262-
"msg": "JSON decode error",
263-
"input": {},
264-
"ctx": {"error": e.msg},
265-
},
266-
],
267-
body=e.doc,
268-
) from e
269267
else:
270-
raise NotImplementedError("Only JSON body is supported")
268+
raise NotImplementedError("Only JSON body or Form() are supported")
269+
270+
def _parse_json_data(self, app: EventHandlerInstance) -> dict[str, Any]:
271+
"""Parse JSON data from the request body."""
272+
try:
273+
return app.current_event.json_body
274+
except json.JSONDecodeError as e:
275+
raise RequestValidationError(
276+
[
277+
{
278+
"type": "json_invalid",
279+
"loc": ("body", e.pos),
280+
"msg": "JSON decode error",
281+
"input": {},
282+
"ctx": {"error": e.msg},
283+
},
284+
],
285+
body=e.doc,
286+
) from e
287+
288+
def _parse_form_data(self, app: EventHandlerInstance) -> dict[str, Any]:
289+
"""Parse URL-encoded form data from the request body."""
290+
try:
291+
body = app.current_event.decoded_body or ""
292+
# parse_qs returns dict[str, list[str]], but we want dict[str, str] for single values
293+
parsed = parse_qs(body, keep_blank_values=True)
294+
295+
result: dict[str, Any] = {key: values[0] if len(values) == 1 else values for key, values in parsed.items()}
296+
return result
297+
298+
except Exception as e: # pragma: no cover
299+
raise RequestValidationError( # pragma: no cover
300+
[
301+
{
302+
"type": "form_invalid",
303+
"loc": ("body",),
304+
"msg": "Form data parsing error",
305+
"input": {},
306+
"ctx": {"error": str(e)},
307+
},
308+
],
309+
) from e
271310

272311

273312
def _request_params_to_args(

aws_lambda_powertools/event_handler/openapi/dependant.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
from aws_lambda_powertools.event_handler.openapi.params import (
1515
Body,
1616
Dependant,
17+
Form,
1718
Header,
1819
Param,
1920
ParamTypes,
2021
Query,
2122
_File,
22-
_Form,
2323
analyze_param,
2424
create_response_field,
2525
get_flat_dependant,
@@ -348,6 +348,7 @@ def get_body_field(*, dependant: Dependant, name: str) -> ModelField | None:
348348
alias="body",
349349
field_info=body_field_info(**body_field_info_kwargs),
350350
)
351+
351352
return final_field
352353

353354

@@ -369,9 +370,9 @@ def get_body_field_info(
369370
if any(isinstance(f.field_info, _File) for f in flat_dependant.body_params):
370371
# MAINTENANCE: body_field_info: type[Body] = _File
371372
raise NotImplementedError("_File fields are not supported in request bodies")
372-
elif any(isinstance(f.field_info, _Form) for f in flat_dependant.body_params):
373-
# MAINTENANCE: body_field_info: type[Body] = _Form
374-
raise NotImplementedError("_Form fields are not supported in request bodies")
373+
elif any(isinstance(f.field_info, Form) for f in flat_dependant.body_params):
374+
body_field_info = Body
375+
body_field_info_kwargs["media_type"] = "application/x-www-form-urlencoded"
375376
else:
376377
body_field_info = Body
377378

aws_lambda_powertools/event_handler/openapi/params.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -737,9 +737,9 @@ def __repr__(self) -> str:
737737
return f"{self.__class__.__name__}({self.default})"
738738

739739

740-
class _Form(Body):
740+
class Form(Body):
741741
"""
742-
A class used internally to represent a form parameter in a path operation.
742+
A class used to represent a form parameter in a path operation.
743743
"""
744744

745745
def __init__(
@@ -809,9 +809,9 @@ def __init__(
809809
)
810810

811811

812-
class _File(_Form):
812+
class _File(Form):
813813
"""
814-
A class used internally to represent a file parameter in a path operation.
814+
A class used to represent a file parameter in a path operation.
815815
"""
816816

817817
def __init__(
@@ -848,6 +848,14 @@ def __init__(
848848
json_schema_extra: dict[str, Any] | None = None,
849849
**extra: Any,
850850
):
851+
# For file uploads, ensure the OpenAPI schema has the correct format
852+
# Also we can't test it
853+
file_schema_extra = {"format": "binary"} # pragma: no cover
854+
if json_schema_extra: # pragma: no cover
855+
json_schema_extra.update(file_schema_extra) # pragma: no cover
856+
else: # pragma: no cover
857+
json_schema_extra = file_schema_extra # pragma: no cover
858+
851859
super().__init__(
852860
default=default,
853861
default_factory=default_factory,

docs/core/event_handler/api_gateway.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,18 @@ In the following example, we use a new `Header` OpenAPI type to add [one out of
523523

524524
1. `cloudfront_viewer_country` is a list that must contain values from the `CountriesAllowed` enumeration.
525525

526+
#### Handling form data
527+
528+
!!! info "You must set `enable_validation=True` to handle file uploads and form data via type annotation."
529+
530+
You can use the `Form` type to tell the Event Handler that a parameter expects file upload or form data. This automatically sets the correct OpenAPI schema for `application/x-www-form-urlencoded` requests.
531+
532+
=== "working_with_form_data.py"
533+
534+
```python hl_lines="4 11 12"
535+
--8<-- "examples/event_handler_rest/src/working_with_form_data.py"
536+
```
537+
526538
#### Supported types for response serialization
527539

528540
With data validation enabled, we natively support serializing the following data types to JSON:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from typing import Annotated
2+
3+
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
4+
from aws_lambda_powertools.event_handler.openapi.params import Form
5+
6+
app = APIGatewayRestResolver(enable_validation=True)
7+
8+
9+
@app.post("/submit_form")
10+
def upload_file(
11+
name: Annotated[str, Form(description="Your name")],
12+
age: Annotated[str, Form(description="Your age")],
13+
):
14+
# You can access form data
15+
return {"message": f"Your name is {name} and age is {age}"}
16+
17+
18+
def lambda_handler(event, context):
19+
return app.resolve(event, context)

0 commit comments

Comments
 (0)