Skip to content

Commit

Permalink
encode_body: Support %File.Stream{} in :form_multipart
Browse files Browse the repository at this point in the history
Closes #401
  • Loading branch information
wojtekmach committed Aug 1, 2024
1 parent d7906dc commit 507ebd1
Show file tree
Hide file tree
Showing 4 changed files with 36 additions and 23 deletions.
1 change: 1 addition & 0 deletions lib/req/steps.ex
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ defmodule Req.Steps do

%{request | body: multipart.body}
|> Req.Request.put_new_header("content-type", multipart.content_type)
|> Req.Request.put_new_header("content-length", Integer.to_string(multipart.size))

data = request.options[:json] ->
%{request | body: Jason.encode_to_iodata!(data)}
Expand Down
39 changes: 22 additions & 17 deletions lib/req/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,8 @@ defmodule Req.Utils do
%CollectWithHash{collectable: collectable, type: type}
end

@crlf "\r\n"

@doc """
Encodes fields into "multipart/form-data" format.
"""
Expand All @@ -336,40 +338,44 @@ defmodule Req.Utils do
options[:boundary] ||
Base.encode16(:crypto.strong_rand_bytes(16), padding: false, case: :lower)

crlf = "\r\n"
footer = [[@crlf, "--", boundary, "--", @crlf]]

body =
{body, size} =
fields
|> Enum.reduce([], &add_form_parts(&2, encode_form_part(&1, boundary)))
|> add_form_parts([[crlf, "--", boundary, "--", crlf]])
|> Enum.reduce({[], 0}, &add_form_parts(&2, encode_form_part(&1, boundary)))
|> add_form_parts({footer, IO.iodata_length(footer)})

%{
size: size,
content_type: "multipart/form-data; boundary=#{boundary}",
body: body
}
end

defp add_form_parts(parts1, parts2) when is_list(parts1) and is_list(parts2) do
[parts1, parts2]
defp add_form_parts({parts1, size1}, {parts2, size2})
when is_list(parts1) and is_list(parts2) do
{[parts1, parts2], size1 + size2}
end

defp add_form_parts(parts1, parts2) do
Stream.concat(parts1, parts2)
defp add_form_parts({parts1, size1}, {parts2, size2}) do
{Stream.concat(parts1, parts2), size1 + size2}
end

defp encode_form_part({name, {value, options}}, boundary) do
options = Keyword.validate!(options, [:filename, :content_type])

{parts, options} =
{parts, parts_size, options} =
case value do
integer when is_integer(integer) ->
{[Integer.to_string(integer)], options}
part = Integer.to_string(integer)
{[part], byte_size(part), options}

value when is_binary(value) or is_list(value) ->
{[value], options}
{[value], IO.iodata_length(value), options}

stream = %File.Stream{} ->
filename = Path.basename(stream.path)
size = File.stat!(stream.path).size

options =
options
Expand All @@ -378,7 +384,7 @@ defmodule Req.Utils do
MIME.from_path(filename)
end)

{stream, options}
{stream, size, options}
end

params =
Expand All @@ -388,17 +394,16 @@ defmodule Req.Utils do
[]
end

crlf = "\r\n"

headers =
if content_type = options[:content_type] do
["content-type: ", content_type, crlf]
["content-type: ", content_type, @crlf]
else
[]
end

headers = ["content-disposition: form-data; name=\"#{name}\"", params, crlf, headers]
add_form_parts([[crlf, "--", boundary, crlf, headers, crlf]], parts)
headers = ["content-disposition: form-data; name=\"#{name}\"", params, @crlf, headers]
header = [[@crlf, "--", boundary, @crlf, headers, @crlf]]
add_form_parts({header, IO.iodata_length(header)}, {parts, parts_size})
end

defp encode_form_part({name, value}, boundary) do
Expand Down
1 change: 1 addition & 0 deletions test/req/steps_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ defmodule Req.StepsTest do
plug = fn conn ->
conn = Plug.Parsers.call(conn, Plug.Parsers.init(parsers: [:multipart]))

assert Plug.Conn.get_req_header(conn, "content-length") == ["393"]
assert %{"a" => "1", "b" => b, "c" => c} = conn.body_params

assert b.filename == "b.txt"
Expand Down
18 changes: 12 additions & 6 deletions test/req/utils_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ defmodule Req.UtilsTest do

describe "encode_form_multipart" do
test "it works" do
%{content_type: content_type, body: body} =
%{content_type: content_type, body: body, size: size} =
Req.Utils.encode_form_multipart(
[
field1: 1,
Expand All @@ -82,9 +82,11 @@ defmodule Req.UtilsTest do
boundary: "foo"
)

body = IO.iodata_to_binary(body)
assert size == byte_size(body)
assert content_type == "multipart/form-data; boundary=foo"

assert IO.iodata_to_binary(body) == """
assert body == """
\r\n\
--foo\r\n\
content-disposition: form-data; name=\"field1\"\r\n\
Expand All @@ -107,7 +109,7 @@ defmodule Req.UtilsTest do
test "can return stream", %{tmp_dir: tmp_dir} do
File.write!("#{tmp_dir}/2.txt", "22")

%{body: body} =
%{body: body, size: size} =
Req.Utils.encode_form_multipart(
[
field1: 1,
Expand All @@ -117,8 +119,10 @@ defmodule Req.UtilsTest do
)

assert is_function(body)
body = body |> Enum.to_list() |> IO.iodata_to_binary()
assert size == byte_size(body)

assert body |> Enum.to_list() |> IO.iodata_to_binary() == """
assert body == """
\r\n\
--foo\r\n\
content-disposition: form-data; name=\"field1\"\r\n\
Expand All @@ -132,7 +136,7 @@ defmodule Req.UtilsTest do
--foo--\r\n\
"""

%{body: body} =
%{body: body, size: size} =
Req.Utils.encode_form_multipart(
[
field2: File.stream!("#{tmp_dir}/2.txt"),
Expand All @@ -142,8 +146,10 @@ defmodule Req.UtilsTest do
)

assert is_function(body)
body = body |> Enum.to_list() |> IO.iodata_to_binary()
assert size == byte_size(body)

assert body |> Enum.to_list() |> IO.iodata_to_binary() == """
assert body == """
\r\n\
--foo\r\n\
content-disposition: form-data; name=\"field2\"; filename=\"2.txt\"\r\n\
Expand Down

0 comments on commit 507ebd1

Please sign in to comment.