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

Conditionally process :form bodies to handle nested params #470

Merged
merged 2 commits into from
Mar 2, 2023
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
2 changes: 1 addition & 1 deletion lib/httpoison.ex
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ defmodule HTTPoison.Request do
headers = request.headers |> Enum.map(fn {k, v} -> "-H '#{k}: #{v}'" end) |> Enum.join(" ")

body =
case request.body do
case HTTPoison.Base.maybe_process_form(request.body) do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted to make this a function in HTTPoison.Base so that we can use the same logic for generating curl args vs hackney args

"" -> ""
{:file, filename} -> "-d @#{filename}"
{:form, form} -> form |> Enum.map(fn {k, v} -> "-F '#{k}=#{v}'" end) |> Enum.join(" ")
Expand Down
36 changes: 35 additions & 1 deletion lib/httpoison/base.ex
Original file line number Diff line number Diff line change
Expand Up @@ -345,11 +345,16 @@ defmodule HTTPoison.Base do
|> process_request_url()
|> HTTPoison.Base.build_request_url(params)

body =
request.body
|> process_request_body()
|> HTTPoison.Base.maybe_process_form()

request = %Request{
method: request.method,
url: url,
headers: process_request_headers(request.headers),
body: process_request_body(request.body),
body: body,
params: params,
options: options
}
Expand Down Expand Up @@ -985,4 +990,33 @@ defmodule HTTPoison.Base do
_ -> nil
end)
end

def maybe_process_form({:form, body}) do
{:form,
Enum.flat_map(body, fn
{k, [{_k, _v} | _rest] = v} -> flatten_nested_body(v, k)
{k, v} when is_map(v) -> flatten_nested_body(v, k)
{k, v} -> [{k, v}]
end)}
end

def maybe_process_form(body) do
body
end

defp flatten_nested_body(body, parent_key) do
flattened_body =
Enum.reduce(body, [], fn
{key, nested_key_values}, acc when is_map(nested_key_values) ->
flatten_nested_body(nested_key_values, "#{parent_key}[#{key}]") ++ acc

{key, [{_key, _value} | _rest] = nested_key_values}, acc ->
flatten_nested_body(nested_key_values, "#{parent_key}[#{key}]") ++ acc

{key, value}, acc ->
[{"#{parent_key}[#{key}]", value} | acc]
end)

Enum.reverse(flattened_body)
end
end
52 changes: 52 additions & 0 deletions test/httpoison_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,58 @@ defmodule HTTPoisonTest do
)
end

test "post nested form data when given as keyword list" do
assert_response(
HTTPoison.post(
"localhost:4002/post",
{:form, [not_nested: [1, 2], nested: [foo: "bar", again: [hello: "world"]]]},
%{"Content-type" => "application/x-www-form-urlencoded"}
),
fn %HTTPoison.Response{request: request} = response ->
assert {:form,
[{:not_nested, [1, 2]}, {"nested[foo]", "bar"}, {"nested[again][hello]", "world"}]} =
request.body

Regex.match?(~r/"not_nested".*\x01\x02/, response.body)
Regex.match?(~r/"nested\[foo\]".*"bar"/, response.body)
Regex.match?(~r/"nested\[again\]\[hello\]".*"world"/, response.body)

assert Request.to_curl(response.request) ==
{:ok,
"curl -X POST -H 'Content-type: application/x-www-form-urlencoded' -F 'not_nested=\x01\x02' -F 'nested[foo]=bar' -F 'nested[again][hello]=world' http://localhost:4002/post"}
end
)
end

test "post nested form data when given as map (no order guaranteed)" do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important to note that we throw away guarantees re: ordering params when using maps, but that feels like the expected behaviour in Elixir

assert_response(
HTTPoison.post(
"localhost:4002/post",
{:form, %{not_nested: [1, 2], nested: %{foo: "bar", again: %{hello: "world"}}}},
%{"Content-type" => "application/x-www-form-urlencoded"}
),
fn %HTTPoison.Response{request: request} = response ->
assert {:form, body} = request.body

assert [{:not_nested, [1, 2]}, {"nested[foo]", "bar"}, {"nested[again][hello]", "world"}]
|> MapSet.new()
|> MapSet.equal?(MapSet.new(body))

Regex.match?(~r/"not_nested".*\x01\x02/, response.body)
Regex.match?(~r/"nested\[foo\]".*"bar"/, response.body)
Regex.match?(~r/"nested\[again\]\[hello\]".*"world"/, response.body)

assert {:ok,
"curl -X POST -H 'Content-type: application/x-www-form-urlencoded'" <> form_params} =
Request.to_curl(response.request)

assert form_params =~ "-F 'not_nested=\x01\x02'"
assert form_params =~ "-F 'nested[foo]=bar'"
assert form_params =~ "-F 'nested[again][hello]=world'"
end
)
end

test "put" do
assert_response(HTTPoison.put("localhost:4002/put", "test"), fn response ->
assert Request.to_curl(response.request) ==
Expand Down