From cf47d808339b97a8ac0e7213ae0752746bab8f92 Mon Sep 17 00:00:00 2001 From: Danylo Kondratiev Date: Tue, 31 Dec 2024 11:56:05 +0200 Subject: [PATCH 1/3] Support any enumerable in mutlipart --- lib/req/utils.ex | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/lib/req/utils.ex b/lib/req/utils.ex index cbbc60b..b6acb1c 100644 --- a/lib/req/utils.ex +++ b/lib/req/utils.ex @@ -464,6 +464,11 @@ defmodule Req.Utils do } end + defp add_form_parts({_parts1, size1}, {_parts2, size2}) + when not is_integer(size1) or not is_integer(size2) do + raise ArgumentError, "multipart part sizes must be integers" + end + defp add_form_parts({parts1, size1}, {parts2, size2}) when is_list(parts1) and is_list(parts2) do {[parts1, parts2], size1 + size2} @@ -474,7 +479,7 @@ defmodule Req.Utils do end defp encode_form_part({name, {value, options}}, boundary) do - options = Keyword.validate!(options, [:filename, :content_type]) + options = Keyword.validate!(options, [:filename, :content_type, :content_length]) {parts, parts_size, options} = case value do @@ -504,6 +509,12 @@ defmodule Req.Utils do end) {stream, size, options} + + enum -> + Enumerable.impl_for!(enum) + size = Keyword.get(options, :content_length, 0) + + {enum, size, options} end params = @@ -514,11 +525,11 @@ defmodule Req.Utils do end headers = - if content_type = options[:content_type] do - ["content-type: ", content_type, @crlf] - else - [] - end + [ + maybe_content_type(options), + maybe_content_length(options) + ] + |> Enum.reject(&is_nil/1) headers = ["content-disposition: form-data; name=\"#{name}\"", params, @crlf, headers] header = [[@crlf, "--", boundary, @crlf, headers, @crlf]] @@ -529,6 +540,18 @@ defmodule Req.Utils do encode_form_part({name, {value, []}}, boundary) end + defp maybe_content_type(options) do + if content_type = options[:content_type] do + ["content-type: ", content_type, @crlf] + end + end + + defp maybe_content_length(options) do + if content_length = options[:content_length] do + ["content-length: ", Integer.to_string(content_length), @crlf] + end + end + @doc """ Loads .netrc file. From ee08a43971745959992b574b31e496b357f2b2cc Mon Sep 17 00:00:00 2001 From: Danylo Kondratiev Date: Sun, 5 Jan 2025 16:57:41 +0200 Subject: [PATCH 2/3] Add tests --- test/req/utils_test.exs | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/req/utils_test.exs b/test/req/utils_test.exs index 5db0504..99838d6 100644 --- a/test/req/utils_test.exs +++ b/test/req/utils_test.exs @@ -175,6 +175,45 @@ defmodule Req.UtilsTest do """ end + test "raise when size is not integer" do + assert_raise ArgumentError, fn -> + enum = Stream.cycle([1]) |> Stream.take(1) + Req.Utils.encode_form_multipart([field1: {enum, content_length: "10"}], boundary: "foo") + end + end + + test "content-length" do + %{content_type: content_type, body: body, size: size} = + Req.Utils.encode_form_multipart([field1: {"a", content_length: 11}], boundary: "foo") + + body = IO.iodata_to_binary(body) + + assert size == byte_size(body) + assert content_type == "multipart/form-data; boundary=foo" + + assert body == """ + \r\n\ + --foo\r\n\ + content-disposition: form-data; name=\"field1\"\r\n\ + content-length: 11\r\n\ + \r\n\ + a\r\n\ + --foo--\r\n\ + """ + end + + test "can accept any enumerable" do + enum = Stream.cycle(["a"]) |> Stream.take(1) + + %{body: body, size: size} = + Req.Utils.encode_form_multipart([field1: {enum, content_length: 11}], boundary: "foo") + + body = body |> Enum.to_list() |> IO.iodata_to_binary() + + # content_length is +10 of actual size + assert size == byte_size(body) + 10 + end + @tag :tmp_dir test "can return stream", %{tmp_dir: tmp_dir} do File.write!("#{tmp_dir}/2.txt", "22") From 9ea25c3fa2c848ab1b41cf374516f9d4ca981c4b Mon Sep 17 00:00:00 2001 From: Danylo Kondratiev Date: Sun, 5 Jan 2025 17:09:29 +0200 Subject: [PATCH 3/3] Update docs --- lib/req/steps.ex | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/req/steps.ex b/lib/req/steps.ex index d00a504..ff0d018 100644 --- a/lib/req/steps.ex +++ b/lib/req/steps.ex @@ -398,7 +398,9 @@ defmodule Req.Steps do * `File.Stream` - * `{value, options}` tuple. Supported options are `:filename` and `:content_type` + * `Enumerable` + + * `{value, options}` tuple. Supported options are: `:filename`, `:content_type`, `:content_length` * `:json` - if set, encodes the request body as JSON (using `Jason.encode_to_iodata!/1`), sets the `accept` header to `application/json`, and the `content-type` header to `application/json`. @@ -419,6 +421,14 @@ defmodule Req.Steps do iex> resp.body["files"] %{"b" => "2"} + Encoding streaming form (`multipart/form-data`): + + iex> stream = Stream.cycle(["abc"]) |> Stream.take(3) + iex> fields = [file: {stream, filename: "b.txt", content_length: 9}] + iex> resp = Req.post!("https://httpbin.org/anything", form_multipart: fields) + iex> resp.body["files"] + %{"file" => "abcabcabc"} + Encoding JSON: iex> Req.post!("https://httpbin.org/post", json: %{a: 2}).body["json"]