Skip to content

Commit

Permalink
fix: discrepency in attrs, msgspec and pydantic for multi-part forms (l…
Browse files Browse the repository at this point in the history
…itestar-org#2280)

* fix: add failing test on multipart msgspec and attrs, pydantic works
* fix: remove the decode_json from the multipart parser
---------

Co-authored-by: Janek Nouvertné <[email protected]>
  • Loading branch information
euri10 and provinzkraut authored Sep 23, 2023
1 parent 6c61f7a commit fb10da1
Show file tree
Hide file tree
Showing 2 changed files with 67 additions and 51 deletions.
8 changes: 2 additions & 6 deletions litestar/_multipart.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@
from urllib.parse import unquote

from litestar.datastructures.upload_file import UploadFile
from litestar.exceptions import SerializationException, ValidationException
from litestar.serialization import decode_json
from litestar.exceptions import ValidationException

__all__ = ("parse_body", "parse_content_header", "parse_multipart_form")

Expand Down Expand Up @@ -156,10 +155,7 @@ def parse_multipart_form(
)
fields[field_name].append(form_file)
elif post_data:
try:
fields[field_name].append(decode_json(post_data, type_decoders=type_decoders))
except SerializationException:
fields[field_name].append(post_data.decode(content_charset))
fields[field_name].append(post_data.decode(content_charset))
else:
fields[field_name].append(None)

Expand Down
110 changes: 65 additions & 45 deletions tests/unit/test_kwargs/test_multipart_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@
from pathlib import Path
from typing import Any, DefaultDict, Dict, List, Optional

import msgspec
import pytest
from pydantic import BaseConfig, BaseModel
from attr import define, field
from attr.validators import ge, instance_of, lt
from pydantic import BaseConfig, BaseModel, ConfigDict, Field
from typing_extensions import Annotated

from litestar import Request, post
from litestar.contrib.pydantic import _model_dump, _model_dump_json
from litestar.contrib.pydantic import _model_dump
from litestar.datastructures.upload_file import UploadFile
from litestar.enums import RequestEncodingType
from litestar.params import Body
from litestar.status_codes import HTTP_201_CREATED, HTTP_400_BAD_REQUEST
from litestar.testing import create_test_client
from tests import PydanticPerson, PydanticPersonFactory

from . import Form

Expand Down Expand Up @@ -100,26 +102,24 @@ def test_method(data: Annotated[t_type, Body(media_type=RequestEncodingType.MULT


def test_request_body_multi_part_mixed_field_content_types() -> None:
person = PydanticPersonFactory.build()

@dataclass
class MultiPartFormWithMixedFields:
image: UploadFile
tags: List[int]
profile: PydanticPerson

@post(path="/form")
async def test_method(data: MultiPartFormWithMixedFields = Body(media_type=RequestEncodingType.MULTI_PART)) -> None:
file_data = await data.image.read()
assert file_data == b"data"
assert data.tags == [1, 2, 3]
assert data.profile == person

with create_test_client(test_method) as client:
response = client.post(
"/form",
files={"image": ("image.png", b"data")},
data={"tags": ["1", "2", "3"], "profile": _model_dump_json(person)},
data={
"tags": ["1", "2", "3"],
},
)
assert response.status_code == HTTP_201_CREATED

Expand Down Expand Up @@ -449,7 +449,7 @@ async def hello_world(
class ProductForm:
name: str
int_field: int
options: List[int]
options: str
optional_without_default: Optional[float]
optional_with_default: Optional[int] = None

Expand Down Expand Up @@ -490,41 +490,61 @@ def handler(
assert response.status_code == HTTP_201_CREATED


def test_multipart_handling_of_none_json_lists_with_multiple_elements() -> None:
@post("/")
def handler(
data: Annotated[ProductForm, Body(media_type=RequestEncodingType.MULTI_PART)],
) -> None:
assert data
MAX_INT_POSTGRES = 10

with create_test_client(route_handlers=[handler]) as client:
response = client.post(
"/",
content=(
b"--1f35df74046888ceaa62d8a534a076dd\r\n"
b'Content-Disposition: form-data; name="name"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
b"moishe zuchmir\r\n"
b"--1f35df74046888ceaa62d8a534a076dd\r\n"
b'Content-Disposition: form-data; name="int_field"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
b"1\r\n"
b"--1f35df74046888ceaa62d8a534a076dd\r\n"
b'Content-Disposition: form-data; name="options"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
b"1\r\n"
b"--1f35df74046888ceaa62d8a534a076dd\r\n"
b'Content-Disposition: form-data; name="options"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
b"2\r\n"
b"--1f35df74046888ceaa62d8a534a076dd\r\n"
b'Content-Disposition: form-data; name="optional_without_default"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n\r\n"
b"--1f35df74046888ceaa62d8a534a076dd\r\n"
b'Content-Disposition: form-data; name="optional_with_default"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n\r\n"
b"--1f35df74046888ceaa62d8a534a076dd--\r\n"
),
headers={"Content-Type": "multipart/form-data; boundary=1f35df74046888ceaa62d8a534a076dd"},
)

@define
class AddProductFormAttrs:
name: str
amount: int = field(validator=[instance_of(int), ge(1), lt(MAX_INT_POSTGRES)])


class AddProductFormPydantic(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
name: str
amount: int = Field(ge=1, lt=MAX_INT_POSTGRES)


class AddProductFormMsgspec(msgspec.Struct):
name: str
amount: Annotated[int, msgspec.Meta(lt=MAX_INT_POSTGRES, ge=1)]


@pytest.mark.parametrize("form_object", [AddProductFormMsgspec, AddProductFormPydantic, AddProductFormAttrs])
@pytest.mark.parametrize("form_type", [RequestEncodingType.URL_ENCODED, RequestEncodingType.MULTI_PART])
def test_multipart_and_url_encoded_behave_the_same(form_object, form_type) -> None: # type: ignore[no-untyped-def]
@post(path="/form")
async def form_(request: Request, data: Annotated[form_object, Body(media_type=form_type)]) -> int:
assert isinstance(data.name, str)
return data.amount # type: ignore[no-any-return]

with create_test_client(
route_handlers=[
form_,
]
) as client:
if form_type == RequestEncodingType.URL_ENCODED:
response = client.post(
"/form",
data={
"name": 1,
"amount": 1,
},
)
else:
response = client.post(
"/form",
content=(
b"--1f35df74046888ceaa62d8a534a076dd\r\n"
b'Content-Disposition: form-data; name="name"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
b"1\r\n"
b"--1f35df74046888ceaa62d8a534a076dd\r\n"
b'Content-Disposition: form-data; name="amount"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
b"1\r\n"
b"--1f35df74046888ceaa62d8a534a076dd--\r\n"
),
headers={"Content-Type": "multipart/form-data; boundary=1f35df74046888ceaa62d8a534a076dd"},
)
assert response.status_code == HTTP_201_CREATED

0 comments on commit fb10da1

Please sign in to comment.