Skip to content

Commit b5c0464

Browse files
Refactoring and removing Form
1 parent 8013594 commit b5c0464

File tree

9 files changed

+50
-1209
lines changed

9 files changed

+50
-1209
lines changed

aws_lambda_powertools/event_handler/middlewares/openapi_validation.py

Lines changed: 7 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
# Constants
3535
CONTENT_DISPOSITION_NAME_PARAM = "name="
3636
APPLICATION_JSON_CONTENT_TYPE = "application/json"
37+
APPLICATION_FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
3738

3839

3940
class OpenAPIValidationMiddleware(BaseMiddlewareHandler):
@@ -255,52 +256,16 @@ def _get_body(self, app: EventHandlerInstance) -> dict[str, Any]:
255256
"""
256257
content_type = app.current_event.headers.get("content-type", "").strip()
257258

258-
# If no content-type is provided, try to infer from route parameters
259-
if not content_type:
260-
content_type = self._infer_content_type(app)
261-
262259
# Handle JSON content
263-
if content_type.startswith(APPLICATION_JSON_CONTENT_TYPE):
260+
if not content_type or content_type.startswith(APPLICATION_JSON_CONTENT_TYPE):
264261
return self._parse_json_data(app)
265262

266263
# Handle URL-encoded form data
267-
elif content_type.startswith("application/x-www-form-urlencoded"):
264+
elif content_type.startswith(APPLICATION_FORM_CONTENT_TYPE):
268265
return self._parse_form_data(app)
269266

270-
# Handle multipart form data (for file uploads)
271-
elif content_type.startswith("multipart/form-data"):
272-
return self._parse_multipart_data(app)
273-
274267
else:
275-
raise RequestValidationError(
276-
[
277-
{
278-
"type": "content_type_invalid",
279-
"loc": ("body",),
280-
"msg": f"Unsupported content type: {content_type}",
281-
"input": {},
282-
},
283-
],
284-
)
285-
286-
def _infer_content_type(self, app: EventHandlerInstance) -> str:
287-
"""Infer content type from route parameters when not explicitly provided."""
288-
route = app.context.get("_route")
289-
if route and route.dependant.body_params:
290-
# Check if any body params are File or Form types
291-
from aws_lambda_powertools.event_handler.openapi.params import File, Form
292-
293-
has_file_params = any(
294-
isinstance(getattr(param.field_info, "__class__", None), type)
295-
and issubclass(param.field_info.__class__, (File, Form))
296-
for param in route.dependant.body_params
297-
if hasattr(param, "field_info")
298-
)
299-
300-
return "multipart/form-data" if has_file_params else APPLICATION_JSON_CONTENT_TYPE
301-
302-
# Default to JSON when no body params
303-
return APPLICATION_JSON_CONTENT_TYPE
268+
raise NotImplementedError("Only JSON body or Form() are supported")
304269

305270
def _parse_json_data(self, app: EventHandlerInstance) -> dict[str, Any]:
306271
"""Parse JSON data from the request body."""
@@ -327,18 +292,11 @@ def _parse_form_data(self, app: EventHandlerInstance) -> dict[str, Any]:
327292
# parse_qs returns dict[str, list[str]], but we want dict[str, str] for single values
328293
parsed = parse_qs(body, keep_blank_values=True)
329294

330-
# Convert list values to single values where appropriate
331-
result: dict[str, Any] = {}
332-
for key, values in parsed.items():
333-
if len(values) == 1:
334-
result[key] = values[0]
335-
else:
336-
result[key] = values # Keep as list for multiple values
337-
295+
result: dict[str, Any] = {key: values[0] if len(values) == 1 else values for key, values in parsed.items()}
338296
return result
339297

340-
except Exception as e:
341-
raise RequestValidationError(
298+
except Exception as e: # pragma: no cover
299+
raise RequestValidationError( # pragma: no cover
342300
[
343301
{
344302
"type": "form_invalid",
@@ -350,104 +308,6 @@ def _parse_form_data(self, app: EventHandlerInstance) -> dict[str, Any]:
350308
],
351309
) from e
352310

353-
def _parse_multipart_data(self, app: EventHandlerInstance) -> dict[str, Any]:
354-
"""Parse multipart form data from the request body."""
355-
try:
356-
content_type = app.current_event.headers.get("content-type", "")
357-
body = app.current_event.decoded_body or ""
358-
359-
# Extract boundary from content-type header
360-
boundary = self._extract_boundary(content_type)
361-
if not boundary:
362-
msg = "No boundary found in multipart content-type"
363-
raise ValueError(msg)
364-
365-
# Split the body by boundary and parse each part
366-
parts = body.split(f"--{boundary}")
367-
result = {}
368-
369-
for raw_part in parts:
370-
part = raw_part.strip()
371-
if not part or part == "--":
372-
continue
373-
374-
field_name, content = self._parse_multipart_part(part)
375-
if field_name:
376-
result[field_name] = content
377-
378-
return result
379-
380-
except Exception as e:
381-
raise RequestValidationError(
382-
[
383-
{
384-
"type": "multipart_invalid",
385-
"loc": ("body",),
386-
"msg": "Multipart data parsing error",
387-
"input": {},
388-
"ctx": {"error": str(e)},
389-
},
390-
],
391-
) from e
392-
393-
def _extract_boundary(self, content_type: str) -> str | None:
394-
"""Extract boundary from multipart content-type header."""
395-
if "boundary=" in content_type:
396-
return content_type.split("boundary=")[1].split(";")[0].strip()
397-
return None
398-
399-
def _parse_multipart_part(self, part: str) -> tuple[str | None, Any]:
400-
"""Parse a single multipart section and return field name and content."""
401-
# Split headers from content
402-
if "\r\n\r\n" in part:
403-
headers_section, content = part.split("\r\n\r\n", 1)
404-
elif "\n\n" in part:
405-
headers_section, content = part.split("\n\n", 1)
406-
else:
407-
return None, None
408-
409-
# Parse headers to find field name
410-
headers = {}
411-
for header_line in headers_section.split("\n"):
412-
if ":" in header_line:
413-
key, value = header_line.split(":", 1)
414-
headers[key.strip().lower()] = value.strip()
415-
416-
# Extract field name from Content-Disposition header
417-
content_disposition = headers.get("content-disposition", "")
418-
field_name = self._extract_field_name(content_disposition)
419-
420-
if not field_name:
421-
return None, None
422-
423-
# Handle file vs text field
424-
if "filename=" in content_disposition:
425-
# This is a file upload - convert to bytes
426-
content = content.rstrip("\r\n")
427-
return field_name, content.encode() if isinstance(content, str) else content
428-
else:
429-
# This is a text field - keep as string
430-
return field_name, content.rstrip("\r\n")
431-
432-
def _extract_field_name(self, content_disposition: str) -> str | None:
433-
"""Extract field name from Content-Disposition header."""
434-
if CONTENT_DISPOSITION_NAME_PARAM not in content_disposition:
435-
return None
436-
437-
# Handle both quoted and unquoted names
438-
if 'name="' in content_disposition:
439-
name_start = content_disposition.find('name="') + 6
440-
name_end = content_disposition.find('"', name_start)
441-
return content_disposition[name_start:name_end]
442-
elif CONTENT_DISPOSITION_NAME_PARAM in content_disposition:
443-
name_start = content_disposition.find(CONTENT_DISPOSITION_NAME_PARAM) + len(CONTENT_DISPOSITION_NAME_PARAM)
444-
name_end = content_disposition.find(";", name_start)
445-
if name_end == -1:
446-
name_end = len(content_disposition)
447-
return content_disposition[name_start:name_end].strip()
448-
449-
return None
450-
451311

452312
def _request_params_to_args(
453313
required_params: Sequence[ModelField],

aws_lambda_powertools/event_handler/openapi/dependant.py

Lines changed: 4 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-
File,
1817
Form,
1918
Header,
2019
Param,
2120
ParamTypes,
2221
Query,
22+
_File,
2323
analyze_param,
2424
create_response_field,
2525
get_flat_dependant,
@@ -367,9 +367,9 @@ def get_body_field_info(
367367
if not required:
368368
body_field_info_kwargs["default"] = None
369369

370-
if any(isinstance(f.field_info, File) for f in flat_dependant.body_params):
371-
body_field_info = Body
372-
body_field_info_kwargs["media_type"] = "multipart/form-data"
370+
if any(isinstance(f.field_info, _File) for f in flat_dependant.body_params):
371+
# MAINTENANCE: body_field_info: type[Body] = _File
372+
raise NotImplementedError("_File fields are not supported in request bodies")
373373
elif any(isinstance(f.field_info, Form) for f in flat_dependant.body_params):
374374
body_field_info = Body
375375
body_field_info_kwargs["media_type"] = "application/x-www-form-urlencoded"

aws_lambda_powertools/event_handler/openapi/params.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,7 @@ def __init__(
809809
)
810810

811811

812-
class File(Form):
812+
class _File(Form):
813813
"""
814814
A class used to represent a file parameter in a path operation.
815815
"""

docs/core/event_handler/api_gateway.md

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -523,47 +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 file uploads and form data
526+
#### Handling form data
527527

528528
!!! info "You must set `enable_validation=True` to handle file uploads and form data via type annotation."
529529

530-
We use the `Annotated` type to tell the Event Handler that a parameter expects file upload or form data. This automatically sets the correct OpenAPI schema for `multipart/form-data` requests.
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.
531531

532-
In the following example, we use `File` and `Form` OpenAPI types to handle file uploads and form fields:
532+
=== "working_with_form_data.py"
533533

534-
* `File` parameters expect binary file data and generate OpenAPI schema with `format: binary`
535-
* `Form` parameters expect form field values from multipart form data
536-
* The OpenAPI spec will automatically set `requestBody` content type to `multipart/form-data`
537-
538-
=== "handling_file_uploads.py"
539-
540-
```python hl_lines="5 9-10 18-19"
541-
--8<-- "examples/event_handler_rest/src/handling_file_uploads.py"
542-
```
543-
544-
1. If you're not using Python 3.9 or higher, you can install and use [`typing_extensions`](https://pypi.org/project/typing-extensions/){target="_blank" rel="nofollow"} to the same effect
545-
2. `File` is a special OpenAPI type for binary file uploads that sets `format: binary` in the schema
546-
3. `Form` is a special OpenAPI type for form field values in multipart requests
547-
548-
=== "Multiple files"
549-
550-
You can handle multiple file uploads by declaring parameters as lists:
551-
552-
```python hl_lines="9-10"
553-
--8<-- "examples/event_handler_rest/src/handling_multiple_file_uploads.py"
534+
```python hl_lines="4 11 12"
535+
--8<-- "examples/event_handler_rest/src/working_with_form_data.py"
554536
```
555537

556-
1. `files` will be a list containing the binary data of each uploaded file
557-
558-
???+ note "OpenAPI Schema Generation"
559-
When you use `File` or `Form` parameters, the generated OpenAPI specification will automatically include:
560-
561-
* `requestBody` with content type `multipart/form-data`
562-
* Proper schema definitions with `format: binary` for file parameters
563-
* Form field descriptions and constraints
564-
565-
This ensures API documentation tools like SwaggerUI correctly display file upload interfaces.
566-
567538
#### Supported types for response serialization
568539

569540
With data validation enabled, we natively support serializing the following data types to JSON:

examples/event_handler_rest/src/handling_file_uploads.py

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

examples/event_handler_rest/src/handling_multiple_file_uploads.py

Lines changed: 0 additions & 22 deletions
This file was deleted.
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)