Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[rest] allow files to accept a tuple with duplicate field names #34021

Merged
merged 9 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion sdk/core/azure-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

### Features Added

- Support tuple input for files to `azure.core.rest.HttpRequest` #33948
- Support tuple input for file values to `azure.core.rest.HttpRequest` #33948
- Support tuple input to `files` with duplicate field names `azure.core.rest.HttpRequest` #34021

### Breaking Changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@

from ._base import HttpRequest
from ._base_async import AsyncHttpTransport, AsyncHttpResponse, _ResponseStopIteration
from ...utils._pipeline_transport_rest_shared import _aiohttp_body_helper
from ...utils._pipeline_transport_rest_shared import _aiohttp_body_helper, get_file_items
from .._tools import is_rest as _is_rest
from .._tools_async import (
handle_no_stream_rest_response as _handle_no_stream_rest_response,
Expand Down Expand Up @@ -180,7 +180,7 @@ def _get_request_data(self, request):
"""
if request.files:
form_data = aiohttp.FormData(request.data or {})
for form_file, data in request.files.items():
for form_file, data in get_file_items(request.files):
content_type = data[2] if len(data) > 2 else None
try:
form_data.add_field(form_file, data[1], filename=data[0], content_type=content_type)
Expand Down
6 changes: 3 additions & 3 deletions sdk/core/azure-core/azure/core/rest/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
_prepare_multipart_body_helper,
_serialize_request,
_format_data_helper,
get_file_items,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -113,9 +114,8 @@ def set_urlencoded_body(data, has_files):


def set_multipart_body(files: FilesType):
file_items = files.items() if isinstance(files, Mapping) else files
formatted_files = {f: _format_data_helper(d) for f, d in file_items if d is not None}
return {}, formatted_files
formatted_files = [(f, _format_data_helper(d)) for f, d in get_file_items(files) if d is not None]
return {}, dict(formatted_files) if isinstance(files, Mapping) else formatted_files
iscai-msft marked this conversation as resolved.
Show resolved Hide resolved


def set_xml_body(content):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# license information.
# --------------------------------------------------------------------------
from __future__ import absolute_import
from collections.abc import Mapping

from io import BytesIO
from email.message import Message
Expand Down Expand Up @@ -50,7 +51,7 @@
from azure.core.pipeline.transport._base import (
_HttpResponseBase as PipelineTransportHttpResponseBase,
)
from azure.core.rest._helpers import FileType, FileContent
from azure.core.rest._helpers import FilesType, FileType, FileContent

binary_type = str

Expand Down Expand Up @@ -409,3 +410,11 @@ def _aiohttp_body_helper(
response._decompressed_content = True
return response._content
return response._content


def get_file_items(files: "FilesType") -> Sequence[Tuple[str, "FileType"]]:
iscai-msft marked this conversation as resolved.
Show resolved Hide resolved
if isinstance(files, Mapping):
# casting because ItemsView technically isn't a Sequence, even
# though realistically it is ordered python 3.7 and after
return cast(Sequence[Tuple[str, "FileType"]], files.items())
return files
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest
from azure.core.rest import HttpRequest
import collections.abc
from utils import NamedIo


@pytest.fixture
Expand Down Expand Up @@ -94,3 +95,40 @@ async def content():
await assert_aiterator_body(request, b"test 123")
# in this case, request._data is what we end up passing to the requests transport
assert isinstance(request._data, collections.abc.AsyncIterable)


@pytest.mark.asyncio
async def test_multipart_tuple_input_multiple_same_name(client):
request = HttpRequest(
"POST",
url="/multipart/tuple-input-multiple-same-name",
files=[
("file", ("firstFileName", NamedIo("firstFile"), "image/pdf")),
("file", ("secondFileName", NamedIo("secondFile"), "image/png")),
],
)
(await client.send_request(request)).raise_for_status()


@pytest.mark.asyncio
async def test_multipart_tuple_input_multiple_same_name_with_tuple_file_value(client):
request = HttpRequest(
"POST",
url="/multipart/tuple-input-multiple-same-name-with-tuple-file-value",
files=[("images", ("foo.png", NamedIo("notMyName.pdf"), "image/png")), ("images", NamedIo("foo.png"))],
)
(await client.send_request(request)).raise_for_status()


@pytest.mark.asyncio
async def test_data_and_file_input_same_name(client):
request = HttpRequest(
"POST",
url="/multipart/data-and-file-input-same-name",
data={"message": "Hello, world!"},
files=[
("file", ("firstFileName", NamedIo("firstFile"), "image/pdf")),
("file", ("secondFileName", NamedIo("secondFile"), "image/png")),
],
)
(await client.send_request(request)).raise_for_status()
77 changes: 42 additions & 35 deletions sdk/core/azure-core/tests/test_rest_http_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from azure.core.pipeline._tools import is_rest
from rest_client import MockRestClient
from azure.core import PipelineClient
from utils import NamedIo


@pytest.fixture
Expand Down Expand Up @@ -550,16 +551,16 @@ def test_multipart_incorrect_tuple_entry(filebytes):
def test_multipart_tuple_input_single(filebytes):
request = HttpRequest("POST", url="http://example.org", files=[("file", filebytes)])

assert request.content == {"file": ("conftest.py", filebytes, "application/octet-stream")}
assert request.content == [("file", ("conftest.py", filebytes, "application/octet-stream"))]


def test_multipart_tuple_input_multiple(filebytes):
request = HttpRequest("POST", url="http://example.org", files=[("file", filebytes), ("file2", filebytes)])

assert request.content == {
"file": ("conftest.py", filebytes, "application/octet-stream"),
"file2": ("conftest.py", filebytes, "application/octet-stream"),
}
assert request.content == [
("file", ("conftest.py", filebytes, "application/octet-stream")),
("file2", ("conftest.py", filebytes, "application/octet-stream")),
]
kashifkhan marked this conversation as resolved.
Show resolved Hide resolved


def test_multipart_tuple_input_multiple_with_filename_and_content_type(filebytes):
Expand All @@ -569,35 +570,41 @@ def test_multipart_tuple_input_multiple_with_filename_and_content_type(filebytes
files=[("file", ("first file", filebytes, "image/pdf")), ("file2", ("second file", filebytes, "image/png"))],
)

assert request.content == {
"file": ("first file", filebytes, "image/pdf"),
"file2": ("second file", filebytes, "image/png"),
}
assert request.content == [
("file", ("first file", filebytes, "image/pdf")),
("file2", ("second file", filebytes, "image/png")),
]


def test_multipart_tuple_input_multiple_same_name(client):
request = HttpRequest(
"POST",
url="/multipart/tuple-input-multiple-same-name",
files=[
("file", ("firstFileName", NamedIo("firstFile"), "image/pdf")),
("file", ("secondFileName", NamedIo("secondFile"), "image/png")),
],
)
client.send_request(request).raise_for_status()


def test_multipart_tuple_input_multiple_same_name_with_tuple_file_value(client):
request = HttpRequest(
"POST",
url="/multipart/tuple-input-multiple-same-name-with-tuple-file-value",
files=[("images", ("foo.png", NamedIo("notMyName.pdf"), "image/png")), ("images", NamedIo("foo.png"))],
)
client.send_request(request).raise_for_status()


# NOTE: For files, we don't allow list of tuples yet, just dict. Will uncomment when we add this capability
# def test_multipart_multiple_files_single_input_content():
# files = [
# ("file", io.BytesIO(b"<file content 1>")),
# ("file", io.BytesIO(b"<file content 2>")),
# ]
# request = HttpRequest("POST", url="http://example.org", files=files)
# assert request.headers == {
# "Content-Length": "271",
# "Content-Type": "multipart/form-data; boundary=+++",
# }
# assert request.content == b"".join(
# [
# b"--+++\r\n",
# b'Content-Disposition: form-data; name="file"; filename="upload"\r\n',
# b"Content-Type: application/octet-stream\r\n",
# b"\r\n",
# b"<file content 1>\r\n",
# b"--+++\r\n",
# b'Content-Disposition: form-data; name="file"; filename="upload"\r\n',
# b"Content-Type: application/octet-stream\r\n",
# b"\r\n",
# b"<file content 2>\r\n",
# b"--+++--\r\n",
# ]
# )
def test_data_and_file_input_same_name(client):
request = HttpRequest(
"POST",
url="/multipart/data-and-file-input-same-name",
data={"message": "Hello, world!"},
iscai-msft marked this conversation as resolved.
Show resolved Hide resolved
files=[
("file", ("firstFileName", NamedIo("firstFile"), "image/pdf")),
("file", ("secondFileName", NamedIo("secondFile"), "image/png")),
],
)
client.send_request(request).raise_for_status()
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
# Licensed under the MIT License. See LICENSE.txt in the project root for
# license information.
# -------------------------------------------------------------------------
from copy import copy
from flask import (
Response,
Blueprint,
Expand Down Expand Up @@ -147,3 +146,50 @@ def multipart_request():
body_as_str.encode("ascii"),
content_type="multipart/mixed; boundary=batchresponse_66925647-d0cb-4109-b6d3-28efe3e1e5ed",
)


@multipart_api.route("/tuple-input-multiple-same-name", methods=["POST"])
def tuple_input_multiple_same_name():
assert_with_message("content type", multipart_header_start, request.content_type[: len(multipart_header_start)])

files = request.files.getlist("file")
assert_with_message("num files", 2, len(files))

file1 = files[0]
assert_with_message("file content type", "image/pdf", file1.content_type)
assert_with_message("filename", "firstFileName", file1.filename)

file2 = files[1]
assert_with_message("file content type", "image/png", file2.content_type)
assert_with_message("filename", "secondFileName", file2.filename)
return Response(status=200)


@multipart_api.route("/tuple-input-multiple-same-name-with-tuple-file-value", methods=["POST"])
def test_input_multiple_same_name_with_tuple_file_value():
assert_with_message("content type", multipart_header_start, request.content_type[: len(multipart_header_start)])

images = request.files.getlist("images")
assert_with_message("num images", 2, len(images))

tuple_image = images[0]
assert_with_message("image content type", "image/png", tuple_image.content_type)
assert_with_message("filename", "foo.png", tuple_image.filename)

single_image = images[1]
assert_with_message("file content type", "application/octet-stream", single_image.content_type)
assert_with_message("filename", "foo.png", single_image.filename)
return Response(status=200)


@multipart_api.route("/data-and-file-input-same-name", methods=["POST"])
def data_and_file_input_same_name():
assert_with_message("content type", multipart_header_start, request.content_type[: len(multipart_header_start)])

# use call to this function to check files
tuple_input_multiple_same_name()

assert_with_message("data items num", 1, len(request.form.keys()))
assert_with_message("message", "Hello, world!", request.form["message"])

return Response(status=200)
7 changes: 7 additions & 0 deletions sdk/core/azure-core/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# -------------------------------------------------------------------------
import pytest
import types
import io

############################## LISTS USED TO PARAMETERIZE TESTS ##############################
from azure.core.rest import HttpRequest as RestHttpRequest
Expand Down Expand Up @@ -172,3 +173,9 @@ def readonly_checks(response, old_response_class):
if not attr in vars(old_response):
with pytest.raises(AttributeError):
setattr(response, attr, "new_value")


class NamedIo(io.BytesIO):
def __init__(self, name: str, *args, **kwargs):
super(NamedIo, self).__init__(*args, **kwargs)
self.name = name
Loading