Skip to content

Commit b104888

Browse files
feat(event_handler): add support for File field in OpenAPI utility
1 parent 3357d5f commit b104888

File tree

1 file changed

+250
-0
lines changed

1 file changed

+250
-0
lines changed

tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3682,3 +3682,253 @@ def upload(file_data: Annotated[UploadFile, File(description="A file")]):
36823682
props = schema_dict["components"]["schemas"][schema_name]["properties"]
36833683
assert props["file_data"]["type"] == "string"
36843684
assert props["file_data"]["format"] == "binary"
3685+
3686+
3687+
def test_multipart_missing_boundary(gw_event):
3688+
"""Test that missing boundary in content-type raises ValueError."""
3689+
from aws_lambda_powertools.event_handler.openapi.params import File
3690+
3691+
app = APIGatewayRestResolver(enable_validation=True)
3692+
3693+
@app.post("/upload")
3694+
def upload(file_data: Annotated[bytes, File()]):
3695+
return {"size": len(file_data)}
3696+
3697+
gw_event["httpMethod"] = "POST"
3698+
gw_event["path"] = "/upload"
3699+
gw_event["headers"]["content-type"] = "multipart/form-data" # no boundary
3700+
gw_event["body"] = base64.b64encode(b"some data").decode()
3701+
gw_event["isBase64Encoded"] = True
3702+
3703+
with pytest.raises(ValueError, match="Missing boundary"):
3704+
app(gw_event, {})
3705+
3706+
3707+
def test_multipart_quoted_boundary(gw_event):
3708+
"""Test that boundary with quotes is parsed correctly."""
3709+
from aws_lambda_powertools.event_handler.openapi.params import File
3710+
3711+
app = APIGatewayRestResolver(enable_validation=True)
3712+
3713+
@app.post("/upload")
3714+
def upload(file_data: Annotated[bytes, File()]):
3715+
return {"size": len(file_data)}
3716+
3717+
boundary = "----TestBoundary"
3718+
body, _ = _build_multipart_body(
3719+
[
3720+
{"name": "file_data", "value": b"hello", "filename": "test.txt"},
3721+
],
3722+
boundary=boundary,
3723+
)
3724+
3725+
gw_event["httpMethod"] = "POST"
3726+
gw_event["path"] = "/upload"
3727+
# Use quoted boundary
3728+
gw_event["headers"]["content-type"] = f'multipart/form-data; boundary="{boundary}"'
3729+
gw_event["body"] = body
3730+
gw_event["isBase64Encoded"] = True
3731+
3732+
result = app(gw_event, {})
3733+
assert result["statusCode"] == 200
3734+
assert json.loads(result["body"]) == {"size": 5}
3735+
3736+
3737+
def test_multipart_multiple_values_same_field(gw_event):
3738+
"""Test multiple values for the same field name are collected as list."""
3739+
from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
3740+
3741+
app = APIGatewayRestResolver(enable_validation=True)
3742+
3743+
@app.post("/upload")
3744+
def upload(file_data: Annotated[List[UploadFile], File()]):
3745+
return {"count": len(file_data), "filenames": [f.filename for f in file_data]}
3746+
3747+
# Build body with two parts having the same field name
3748+
boundary = "----TestBoundary"
3749+
raw = (
3750+
f"--{boundary}\r\n"
3751+
f'Content-Disposition: form-data; name="file_data"; filename="a.txt"\r\n'
3752+
f"\r\n"
3753+
f"content a\r\n"
3754+
f"--{boundary}\r\n"
3755+
f'Content-Disposition: form-data; name="file_data"; filename="b.txt"\r\n'
3756+
f"\r\n"
3757+
f"content b\r\n"
3758+
f"--{boundary}--\r\n"
3759+
).encode()
3760+
3761+
gw_event["httpMethod"] = "POST"
3762+
gw_event["path"] = "/upload"
3763+
gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
3764+
gw_event["body"] = base64.b64encode(raw).decode()
3765+
gw_event["isBase64Encoded"] = True
3766+
3767+
result = app(gw_event, {})
3768+
assert result["statusCode"] == 200
3769+
parsed = json.loads(result["body"])
3770+
assert parsed["count"] == 2
3771+
assert parsed["filenames"] == ["a.txt", "b.txt"]
3772+
3773+
3774+
def test_multipart_three_values_same_field(gw_event):
3775+
"""Test three or more values for same field name builds onto existing list."""
3776+
from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
3777+
3778+
app = APIGatewayRestResolver(enable_validation=True)
3779+
3780+
@app.post("/upload")
3781+
def upload(file_data: Annotated[List[UploadFile], File()]):
3782+
return {"count": len(file_data), "filenames": [f.filename for f in file_data]}
3783+
3784+
boundary = "----TestBoundary"
3785+
raw = (
3786+
f"--{boundary}\r\n"
3787+
f'Content-Disposition: form-data; name="file_data"; filename="a.txt"\r\n'
3788+
f"\r\n"
3789+
f"aaa\r\n"
3790+
f"--{boundary}\r\n"
3791+
f'Content-Disposition: form-data; name="file_data"; filename="b.txt"\r\n'
3792+
f"\r\n"
3793+
f"bbb\r\n"
3794+
f"--{boundary}\r\n"
3795+
f'Content-Disposition: form-data; name="file_data"; filename="c.txt"\r\n'
3796+
f"\r\n"
3797+
f"ccc\r\n"
3798+
f"--{boundary}--\r\n"
3799+
).encode()
3800+
3801+
gw_event["httpMethod"] = "POST"
3802+
gw_event["path"] = "/upload"
3803+
gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
3804+
gw_event["body"] = base64.b64encode(raw).decode()
3805+
gw_event["isBase64Encoded"] = True
3806+
3807+
result = app(gw_event, {})
3808+
assert result["statusCode"] == 200
3809+
parsed = json.loads(result["body"])
3810+
assert parsed["count"] == 3
3811+
assert parsed["filenames"] == ["a.txt", "b.txt", "c.txt"]
3812+
3813+
3814+
def test_multipart_part_without_headers_separator(gw_event):
3815+
"""Test that a malformed part missing the header/body separator is skipped."""
3816+
from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
3817+
3818+
app = APIGatewayRestResolver(enable_validation=True)
3819+
3820+
@app.post("/upload")
3821+
def upload(file_data: Annotated[UploadFile, File()]):
3822+
return {"filename": file_data.filename}
3823+
3824+
# Build a body with one malformed part (no \r\n\r\n) and one valid part
3825+
boundary = "----TestBoundary"
3826+
raw = (
3827+
f"--{boundary}\r\n"
3828+
f"This part has no header separator at all\r\n"
3829+
f"--{boundary}\r\n"
3830+
f'Content-Disposition: form-data; name="file_data"; filename="good.txt"\r\n'
3831+
f"\r\n"
3832+
f"good content\r\n"
3833+
f"--{boundary}--\r\n"
3834+
).encode()
3835+
3836+
gw_event["httpMethod"] = "POST"
3837+
gw_event["path"] = "/upload"
3838+
gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
3839+
gw_event["body"] = base64.b64encode(raw).decode()
3840+
gw_event["isBase64Encoded"] = True
3841+
3842+
result = app(gw_event, {})
3843+
assert result["statusCode"] == 200
3844+
parsed = json.loads(result["body"])
3845+
assert parsed["filename"] == "good.txt"
3846+
3847+
3848+
def test_multipart_part_without_field_name(gw_event):
3849+
"""Test that a part missing the name parameter in Content-Disposition is skipped."""
3850+
from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
3851+
3852+
app = APIGatewayRestResolver(enable_validation=True)
3853+
3854+
@app.post("/upload")
3855+
def upload(file_data: Annotated[UploadFile, File()]):
3856+
return {"filename": file_data.filename}
3857+
3858+
# Build a body with one part that has no name= param and one valid part
3859+
boundary = "----TestBoundary"
3860+
raw = (
3861+
f"--{boundary}\r\n"
3862+
f"Content-Disposition: form-data\r\n"
3863+
f"\r\n"
3864+
f"orphan content\r\n"
3865+
f"--{boundary}\r\n"
3866+
f'Content-Disposition: form-data; name="file_data"; filename="valid.txt"\r\n'
3867+
f"\r\n"
3868+
f"valid content\r\n"
3869+
f"--{boundary}--\r\n"
3870+
).encode()
3871+
3872+
gw_event["httpMethod"] = "POST"
3873+
gw_event["path"] = "/upload"
3874+
gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
3875+
gw_event["body"] = base64.b64encode(raw).decode()
3876+
gw_event["isBase64Encoded"] = True
3877+
3878+
result = app(gw_event, {})
3879+
assert result["statusCode"] == 200
3880+
parsed = json.loads(result["body"])
3881+
assert parsed["filename"] == "valid.txt"
3882+
3883+
3884+
def test_upload_file_validate_error():
3885+
"""Test UploadFile._validate raises ValueError for non-UploadFile values."""
3886+
from aws_lambda_powertools.event_handler.openapi.params import UploadFile
3887+
3888+
with pytest.raises(ValueError, match="Expected UploadFile, got str"):
3889+
UploadFile._validate("not an upload file")
3890+
3891+
with pytest.raises(ValueError, match="Expected UploadFile, got int"):
3892+
UploadFile._validate(42)
3893+
3894+
3895+
def test_multipart_unclosed_quote_in_header():
3896+
"""Test that _extract_header_param returns None when quote is unclosed."""
3897+
from aws_lambda_powertools.event_handler.middlewares.openapi_validation import _extract_header_param
3898+
3899+
# name=" is present but closing quote is missing
3900+
result = _extract_header_param('Content-Disposition: form-data; name="broken', "name")
3901+
assert result is None
3902+
3903+
3904+
def test_multipart_generic_parse_error(gw_event):
3905+
"""Test that non-ValueError exceptions during multipart parsing produce 422."""
3906+
from unittest.mock import patch
3907+
3908+
from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
3909+
3910+
app = APIGatewayRestResolver(enable_validation=True)
3911+
3912+
@app.post("/upload")
3913+
def upload(file_data: Annotated[UploadFile, File()]):
3914+
return {"filename": file_data.filename}
3915+
3916+
body_b64, content_type = _build_multipart_body(
3917+
[{"name": "file_data", "value": b"data", "filename": "test.txt"}],
3918+
)
3919+
3920+
gw_event["httpMethod"] = "POST"
3921+
gw_event["path"] = "/upload"
3922+
gw_event["headers"]["content-type"] = content_type
3923+
gw_event["body"] = body_b64
3924+
gw_event["isBase64Encoded"] = True
3925+
3926+
# Patch _parse_multipart_body to raise a non-ValueError (e.g. TypeError)
3927+
with patch(
3928+
"aws_lambda_powertools.event_handler.middlewares.openapi_validation._parse_multipart_body",
3929+
side_effect=TypeError("unexpected type"),
3930+
):
3931+
result = app(gw_event, {})
3932+
assert result["statusCode"] == 422
3933+
body = json.loads(result["body"])
3934+
assert body["detail"][0]["type"] == "multipart_invalid"

0 commit comments

Comments
 (0)