From 2fca7b846aeee0f0b7f591b685fd0f87ae85b0eb Mon Sep 17 00:00:00 2001 From: dariodf Date: Tue, 17 May 2022 05:41:39 -0300 Subject: [PATCH] Add `HTTPoison.Request.to_curl/1` to get an equivalent curl command (#459) --- lib/httpoison.ex | 102 ++++++++++++++++++++++++++++++++++++++ test/httpoison_test.exs | 107 ++++++++++++++++++++++++++++++++++------ 2 files changed, 193 insertions(+), 16 deletions(-) diff --git a/lib/httpoison.ex b/lib/httpoison.ex index 7b7b084..04228ae 100644 --- a/lib/httpoison.ex +++ b/lib/httpoison.ex @@ -55,6 +55,108 @@ defmodule HTTPoison.Request do params: params, options: options } + + @doc """ + Returns an equivalent `curl` command for the given request. + + ## Examples + iex> request = %HTTPoison.Request{url: "https://api.github.com", method: :get, headers: [{"Content-Type", "application/json"}]} + iex> HTTPoison.Request.to_curl(request) + "curl -X GET -H 'Content-Type: application/json' https://api.github.com ;" + + iex> request = HTTPoison.get!("https://api.github.com", [{"Content-Type", "application/json"}]).request + iex> HTTPoison.Request.to_curl(request) + "curl -X GET -H 'Content-Type: application/json' https://api.github.com ;" + """ + @spec to_curl(t()) :: {:ok, binary()} | {:error, atom()} + def to_curl(request = %__MODULE__{}) do + options = + Enum.reduce(request.options, [], fn + {:timeout, timeout}, acc -> + ["--connect-timeout #{Float.round(timeout / 1000, 3)}" | acc] + + {:recv_timeout, timeout}, acc -> + ["--max-time #{Float.round(timeout / 1000, 3)}" | acc] + + {:proxy, {:socks5, host, port}}, acc -> + proxy_auth = + if request.options[:socks5_user] do + user = request.options[:socks5_user] + pass = request.options[:socks5_pass] + " --proxy-basic --proxy-user #{user}:#{pass}" + end + + ["--socks5 #{host}:#{port}#{proxy_auth}" | acc] + + {:proxy, {host, port}}, acc -> + ["--proxy #{host}:#{port}" | acc] + + {:proxy_auth, {user, pass}}, acc -> + ["--proxy-user #{user}:#{pass}" | acc] + + {:ssl, ssl_opts}, acc -> + ssl_opts = + Enum.reduce(ssl_opts, [], fn + {:keyfile, keyfile}, acc -> ["--key #{keyfile}" | acc] + {:certfile, certfile}, acc -> ["--cert #{certfile}" | acc] + {:cacertfile, cacertfile}, acc -> ["--cacert #{cacertfile}" | acc] + end) + |> Enum.join(" ") + + [ssl_opts | acc] + + {:follow_redirect, true}, acc -> + max_redirs = Keyword.get(request.options, :max_redirect, 5) + ["-L --max-redirs #{max_redirs}" | acc] + + {:hackney, _}, _ -> + throw({:error, :hackney_opts_not_supported}) + + _, acc -> + acc + end) + |> Enum.join(" ") + + {scheme_opts, url} = + case URI.parse(request.url) do + %URI{scheme: "http+unix"} = uri -> + uri = %URI{uri | scheme: "http", host: nil, authority: nil} + {"--unix-socket #{uri.host}", URI.to_string(uri)} + + _ -> + {"", request.url} + end + + method = "-X " <> (request.method |> to_string() |> String.upcase()) + headers = request.headers |> Enum.map(fn {k, v} -> "-H '#{k}: #{v}'" end) |> Enum.join(" ") + + body = + case request.body do + "" -> "" + {:file, filename} -> "-d @#{filename}" + {:form, form} -> form |> Enum.map(fn {k, v} -> "-F '#{k}=#{v}'" end) |> Enum.join(" ") + {:stream, stream} -> "-d '#{Enum.join(stream, "")}'" + {:multipart, _} -> throw({:error, :multipart_not_supported}) + body when is_binary(body) -> "-d '#{body}'" + _ -> "" + end + + {:ok, + [ + "curl", + options, + scheme_opts, + method, + headers, + body, + url + ] + |> Enum.map(&String.trim/1) + |> Enum.filter(&(&1 != "")) + |> Enum.join(" ")} + catch + e -> e + end end defmodule HTTPoison.Response do diff --git a/test/httpoison_test.exs b/test/httpoison_test.exs index 706862b..36565c7 100644 --- a/test/httpoison_test.exs +++ b/test/httpoison_test.exs @@ -2,6 +2,7 @@ defmodule HTTPoisonTest do use ExUnit.Case, async: true import PathHelpers alias Jason + alias HTTPoison.Request test "get" do assert_response(HTTPoison.get("localhost:8080/deny"), fn response -> @@ -17,6 +18,9 @@ defmodule HTTPoisonTest do assert args["foo"] == "bar" assert args["baz"] == "bong" assert args |> Map.keys() |> length == 2 + + assert Request.to_curl(response.request) == + {:ok, "curl -X GET http://localhost:8080/get?baz=bong&foo=bar"} end) end @@ -34,23 +38,33 @@ defmodule HTTPoisonTest do assert args["baz"] == "bong" assert args["bar"] == "zing" assert args |> Map.keys() |> length == 3 + + assert Request.to_curl(response.request) == + {:ok, + "curl -X GET http://localhost:8080/get?bar=zing&foo=first&foo=second&baz=bong"} end) end test "head" do assert_response(HTTPoison.head("localhost:8080/get"), fn response -> assert response.body == "" + assert Request.to_curl(response.request) == {:ok, "curl -X HEAD http://localhost:8080/get"} end) end test "post charlist body" do - assert_response(HTTPoison.post("localhost:8080/post", 'test')) + assert_response(HTTPoison.post("localhost:8080/post", 'test'), fn response -> + assert Request.to_curl(response.request) == {:ok, "curl -X POST http://localhost:8080/post"} + end) end test "post binary body" do {:ok, file} = File.read(fixture_path("image.png")) - assert_response(HTTPoison.post("localhost:8080/post", file)) + assert_response(HTTPoison.post("localhost:8080/post", file), fn response -> + assert Request.to_curl(response.request) == + {:ok, "curl -X POST -d '#{file}' http://localhost:8080/post"} + end) end test "post form data" do @@ -60,30 +74,49 @@ defmodule HTTPoisonTest do }), fn response -> Regex.match?(~r/"key".*"value"/, response.body) + + assert Request.to_curl(response.request) == + {:ok, + "curl -X POST -H 'Content-type: application/x-www-form-urlencoded' -F 'key=value' http://localhost:8080/post"} end ) end test "put" do - assert_response(HTTPoison.put("localhost:8080/put", "test")) + assert_response(HTTPoison.put("localhost:8080/put", "test"), fn response -> + assert Request.to_curl(response.request) == + {:ok, "curl -X PUT -d 'test' http://localhost:8080/put"} + end) end test "put without body" do - assert_response(HTTPoison.put("localhost:8080/put")) + assert_response(HTTPoison.put("localhost:8080/put"), fn response -> + assert Request.to_curl(response.request) == + {:ok, "curl -X PUT http://localhost:8080/put"} + end) end test "patch" do - assert_response(HTTPoison.patch("localhost:8080/patch", "test")) + assert_response(HTTPoison.patch("localhost:8080/patch", "test"), fn response -> + assert Request.to_curl(response.request) == + {:ok, "curl -X PATCH -d 'test' http://localhost:8080/patch"} + end) end test "delete" do - assert_response(HTTPoison.delete("localhost:8080/delete")) + assert_response(HTTPoison.delete("localhost:8080/delete"), fn response -> + assert Request.to_curl(response.request) == + {:ok, "curl -X DELETE http://localhost:8080/delete"} + end) end test "options" do assert_response(HTTPoison.options("localhost:8080/get"), fn response -> assert get_header(response.headers, "content-length") == "0" assert is_binary(get_header(response.headers, "allow")) + + assert Request.to_curl(response.request) == + {:ok, "curl -X OPTIONS http://localhost:8080/get"} end) end @@ -93,13 +126,22 @@ defmodule HTTPoisonTest do "http://localhost:8080/redirect-to?url=http%3A%2F%2Flocalhost:8080%2Fget", [], follow_redirect: true - ) + ), + fn response -> + assert Request.to_curl(response.request) == + {:ok, + "curl -L --max-redirs 5 -X GET http://localhost:8080/redirect-to?url=http%3A%2F%2Flocalhost:8080%2Fget"} + end ) end test "option follow redirect relative url" do assert_response( - HTTPoison.get("http://localhost:8080/relative-redirect/1", [], follow_redirect: true) + HTTPoison.get("http://localhost:8080/relative-redirect/1", [], follow_redirect: true), + fn response -> + assert Request.to_curl(response.request) == + {:ok, "curl -L --max-redirs 5 -X GET http://localhost:8080/relative-redirect/1"} + end ) end @@ -112,7 +154,10 @@ defmodule HTTPoisonTest do end test "explicit http scheme" do - assert_response(HTTPoison.head("http://localhost:8080/get")) + assert_response(HTTPoison.head("http://localhost:8080/get"), fn response -> + assert Request.to_curl(response.request) == + {:ok, "curl -X HEAD http://localhost:8080/get"} + end) end test "https scheme" do @@ -126,7 +171,12 @@ defmodule HTTPoisonTest do "https://localhost:8433/get", [], ssl: [cacertfile: cacert_file, keyfile: key_file, certfile: cert_file] - ) + ), + fn response -> + assert Request.to_curl(response.request) == + {:ok, + "curl --cert #{cert_file} --key #{key_file} --cacert #{cacert_file} -X GET https://localhost:8433/get"} + end ) end @@ -135,7 +185,14 @@ defmodule HTTPoisonTest do case {HTTParrot.unix_socket_supported?(), Application.fetch_env(:httparrot, :socket_path)} do {true, {:ok, path}} -> path = URI.encode_www_form(path) - assert_response(HTTPoison.get("http+unix://#{path}/get")) + + assert_response( + HTTPoison.get("http+unix://#{path}/get"), + fn response -> + assert Request.to_curl(response.request) == + {:ok, "curl --unix-socket #{path} -X GET http:/get"} + end + ) _ -> :ok @@ -144,18 +201,29 @@ defmodule HTTPoisonTest do end test "char list URL" do - assert_response(HTTPoison.head('localhost:8080/get')) + assert_response(HTTPoison.head('localhost:8080/get'), fn response -> + assert Request.to_curl(response.request) == + {:ok, "curl -X HEAD http://localhost:8080/get"} + end) end test "request headers as a map" do map_header = %{"X-Header" => "X-Value"} - assert HTTPoison.get!("localhost:8080/get", map_header).body =~ "X-Value" + assert response = HTTPoison.get!("localhost:8080/get", map_header) + assert response.body =~ "X-Value" + + assert Request.to_curl(response.request) == + {:ok, "curl -X GET -H 'X-Header: X-Value' http://localhost:8080/get"} end test "cached request" do if_modified = %{"If-Modified-Since" => "Tue, 11 Dec 2012 10:10:24 GMT"} response = HTTPoison.get!("localhost:8080/cache", if_modified) assert %HTTPoison.Response{status_code: 304, body: ""} = response + + assert Request.to_curl(response.request) == + {:ok, + "curl -X GET -H 'If-Modified-Since: Tue, 11 Dec 2012 10:10:24 GMT' http://localhost:8080/cache"} end test "send cookies" do @@ -168,6 +236,9 @@ defmodule HTTPoisonTest do has_foo = Enum.member?(response.headers, {"set-cookie", "foo=1; Version=1; Path=/"}) has_bar = Enum.member?(response.headers, {"set-cookie", "bar=2; Version=1; Path=/"}) assert has_foo and has_bar + + assert Request.to_curl(response.request) == + {:ok, "curl -X GET http://localhost:8080/cookies/set?foo=1&bar=2"} end test "exception" do @@ -239,10 +310,14 @@ defmodule HTTPoisonTest do enumerable = Jason.encode!(expected) |> String.split("") headers = %{"Content-type" => "application/json"} response = HTTPoison.post("localhost:8080/post", {:stream, enumerable}, headers) - assert_response(response) - {:ok, %HTTPoison.Response{body: body}} = response - assert Jason.decode!(body)["json"] == expected + assert_response(response, fn response -> + assert Jason.decode!(response.body)["json"] == expected + + assert Request.to_curl(response.request) == + {:ok, + "curl -X POST -H 'Content-type: application/json' -d '{\"some\":\"bytes\"}' http://localhost:8080/post"} + end) end test "max_body_length limits body size" do