diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae64b4..494f229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.2.6 (TBA) + +* Added `Assent.HTTPAdapter.Finch` +* Deprecated `Assent.HTTPAdapter.Mint` + ## v0.2.5 (2023-08-21) * `Assent.Strategy.Spotify` added diff --git a/README.md b/README.md index 93040f9..5e6adcb 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ def application do end ``` -This is not necessary if you'll use another HTTP adapter, such as Mint. +This is not necessary if you'll use another HTTP adapter like Finch. Assent requires Erlang OTP 22.1 or greater. @@ -197,7 +197,9 @@ end By default Erlangs built-in `:httpc` is used for requests. SSL verification is automatically enabled when `:certifi` and `:ssl_verify_fun` packages are available. `:httpc` only supports HTTP/1.1. -If you would like HTTP/2 support, you should consider adding [`Mint`](https://github.com/ericmj/mint) to your project. +If you would like HTTP/2 support, you should consider adding [`Finch`](https://github.com/sneako/finch) to your project. + +### Finch Update `mix.exs`: @@ -205,19 +207,18 @@ Update `mix.exs`: defp deps do [ # ... - {:mint, "~> 1.0"}, - {:castore, "~> 1.0"} # Required for SSL validation + {:finch, "~> 0.16"} ] end ``` -Pass the `:http_adapter` with your provider configuration: +Ensure you start the Finch supervisor in application, and pass `:http_adapter` with your provider configuration using your connection pool: ```elixir config = [ client_id: "REPLACE_WITH_CLIENT_ID", client_secret: "REPLACE_WITH_CLIENT_SECRET", - http_adapter: Assent.HTTPAdapter.Mint + http_adapter: {Assent.HTTPAdapter.Finch, supervisor: MyFinch} ] ``` diff --git a/lib/assent/http_adapter/finch.ex b/lib/assent/http_adapter/finch.ex new file mode 100644 index 0000000..aa16b05 --- /dev/null +++ b/lib/assent/http_adapter/finch.ex @@ -0,0 +1,35 @@ +if Code.ensure_loaded?(Finch) do +defmodule Assent.HTTPAdapter.Finch do + @moduledoc """ + HTTP adapter module for making http requests with Finch. + + The Finch adapter must be configured with the supervisor by passing it as an + option: + + http_adapter: {Assent.HTTPAdapter.Finch, [supervisor: MyFinch]} + + See `Assent.HTTPAdapter` for more. + """ + alias Assent.{HTTPAdapter, HTTPAdapter.HTTPResponse} + + @behaviour HTTPAdapter + + @impl HTTPAdapter + def request(method, url, body, headers, finch_opts \\ nil) do + headers = headers ++ [HTTPAdapter.user_agent_header()] + opts = finch_opts || [] + + supervisor = Keyword.get(opts, :supervisor) || raise "Missing `:supervisor` option for the #{inspect __MODULE__} configuration" + build_opts = Keyword.get(opts, :build, []) + request_opts = Keyword.get(opts, :request, []) + + method + |> Finch.build(url, headers, body, build_opts) + |> Finch.request(supervisor, request_opts) + |> case do + {:ok, response} -> {:ok, %HTTPResponse{status: response.status, headers: response.headers, body: response.body}} + {:error, error} -> {:error, error} + end + end +end +end diff --git a/lib/assent/http_adapter/mint.ex b/lib/assent/http_adapter/mint.ex index 2d7198f..f8571af 100644 --- a/lib/assent/http_adapter/mint.ex +++ b/lib/assent/http_adapter/mint.ex @@ -1,9 +1,10 @@ +if Code.ensure_loaded?(Mint.HTTP) do defmodule Assent.HTTPAdapter.Mint do @moduledoc """ HTTP adapter module for making http requests with Mint. Mint can be configured by updating the configuration to - `http_adapter: {HTTPAdapter.Mint, [...]}`. + `http_adapter: {Assent.HTTPAdapter.Mint, [...]}`. See `Assent.HTTPAdapter` for more. """ @@ -13,6 +14,8 @@ defmodule Assent.HTTPAdapter.Mint do @impl HTTPAdapter def request(method, url, body, headers, mint_opts \\ nil) do + IO.warn("#{inspect __MODULE__} is deprecated, consider use #{inspect Assent.HTTPAdapter.Finch} instead") + headers = headers ++ [HTTPAdapter.user_agent_header()] %{scheme: scheme, port: port, host: host, path: path, query: query} = URI.parse(url) @@ -103,3 +106,4 @@ defmodule Assent.HTTPAdapter.Mint do defp merge_body(_rest, body), do: body defp merge_body(responses), do: merge_body(responses, "") end +end diff --git a/mix.exs b/mix.exs index cd0d0a8..6287f91 100644 --- a/mix.exs +++ b/mix.exs @@ -38,6 +38,7 @@ defmodule Assent.MixProject do {:certifi, ">= 0.0.0", optional: true}, {:ssl_verify_fun, ">= 0.0.0", optional: true}, + {:finch, "~> 0.15", optional: true}, {:mint, "~> 1.0", optional: true}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 8ec4015..2c060ff 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,13 @@ %{ "bandit": {:hex, :bandit, "0.7.7", "48456d09022607a312cf723a91992236aeaffe4af50615e6e2d2e383fb6bef10", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 0.6.7", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "772f0a32632c2ce41026d85e24b13a469151bb8cea1891e597fb38fde103640a"}, "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, "certifi": {:hex, :certifi, "2.11.0", "5adfe37ceb8569d019f836944aeaf27f8ac391dacaf3707f570c155b7e03aaa8", [:rebar3], [], "hexpm", "9e37e0542ec3fabaa19a0734b3900dc095797fac48c40a2a9741d8ad5e3c9bb7"}, "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, "earmark_parser": {:hex, :earmark_parser, "1.4.33", "3c3fd9673bb5dcc9edc28dd90f50c87ce506d1f71b70e3de69aa8154bc695d44", [:mix], [], "hexpm", "2d526833729b59b9fdb85785078697c72ac5e5066350663e5be6a1182da61b8f"}, "ex_doc": {:hex, :ex_doc, "0.30.6", "5f8b54854b240a2b55c9734c4b1d0dd7bdd41f71a095d42a70445c03cf05a281", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bd48f2ddacf4e482c727f9293d9498e0881597eae6ddc3d9562bd7923375109f"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, @@ -14,7 +16,9 @@ "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"}, + "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, + "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, diff --git a/test/assent/http_adapter/finch_test.exs b/test/assent/http_adapter/finch_test.exs new file mode 100644 index 0000000..b0c133c --- /dev/null +++ b/test/assent/http_adapter/finch_test.exs @@ -0,0 +1,107 @@ +defmodule Assent.HTTPAdapter.FinchTest do + use ExUnit.Case + doctest Assent.HTTPAdapter.Finch + + alias Assent.HTTPAdapter.Finch, as: FinchAdapter + alias Assent.HTTPAdapter.HTTPResponse + alias Finch.Error + + import ExUnit.CaptureLog + + describe "request/4" do + test "without supervisor" do + TestServer.start(scheme: :https) + + assert_raise RuntimeError, "Missing `:supervisor` option for the Assent.HTTPAdapter.Finch configuration", fn -> + FinchAdapter.request(:get, TestServer.url(), nil, []) + end + end + + test "handles HTTP/1" do + TestServer.add("/", via: :get) + + supervisor = start_supervised_finch!(protocol: :http1) + + assert {:ok, %HTTPResponse{status: 200, body: "HTTP/1.1"}} = FinchAdapter.request(:get, TestServer.url(), nil, [], supervisor: supervisor) + end + + test "handles SSL" do + TestServer.start(scheme: :https) + TestServer.add("/", via: :get) + + supervisor = start_supervised_finch!(conn_opts: [transport_opts: [cacerts: TestServer.x509_suite().cacerts]]) + + assert {:ok, %HTTPResponse{status: 200, body: "HTTP/2"}} = FinchAdapter.request(:get, TestServer.url(), nil, [], supervisor: supervisor) + end + + test "handles SSL with bad certificate" do + TestServer.start(scheme: :https) + + supervisor = start_supervised_finch!(conn_opts: [transport_opts: [cacerts: TestServer.x509_suite().cacerts]]) + + bad_host_url = TestServer.url(host: "bad-host.localhost") + + assert capture_log(fn -> + assert {:error, %Error{reason: :disconnected}} = FinchAdapter.request(:get, bad_host_url, nil, [], supervisor: supervisor) + end) =~ "{bad_cert,hostname_check_failed}" + end + + test "handles SSL with bad certificate and no verification" do + TestServer.start(scheme: :https) + TestServer.add("/", via: :get) + + supervisor = start_supervised_finch!(conn_opts: [transport_opts: [cacerts: TestServer.x509_suite().cacerts, verify: :verify_none]]) + + bad_host_url = TestServer.url(host: "bad-host.localhost") + + assert {:ok, %HTTPResponse{status: 200}} = FinchAdapter.request(:get, bad_host_url, nil, [], supervisor: supervisor) + end + + test "handles unreachable host" do + TestServer.start() + url = TestServer.url() + TestServer.stop() + + supervisor = start_supervised_finch!() + + assert capture_log(fn -> + assert {:error, %Error{reason: :disconnected}} = FinchAdapter.request(:get, url, nil, [], supervisor: supervisor) + end) =~ "connection refused" + end + + test "handles query in URL" do + TestServer.add("/get", via: :get, to: fn conn -> + assert conn.query_string == "a=1" + + Plug.Conn.send_resp(conn, 200, "") + end) + + supervisor = start_supervised_finch!() + + assert {:ok, %HTTPResponse{status: 200}} = FinchAdapter.request(:get, TestServer.url("/get?a=1"), nil, [], supervisor: supervisor) + end + + test "handles POST" do + TestServer.add("/post", via: :post, to: fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn, []) + params = URI.decode_query(body) + + assert params["a"] == "1" + assert params["b"] == "2" + assert Plug.Conn.get_req_header(conn, "content-type") == ["application/x-www-form-urlencoded"] + + Plug.Conn.send_resp(conn, 200, "") + end) + + supervisor = start_supervised_finch!() + + assert {:ok, %HTTPResponse{status: 200}} = FinchAdapter.request(:post, TestServer.url("/post"), "a=1&b=2", [{"content-type", "application/x-www-form-urlencoded"}], supervisor: supervisor) + end + end + + defp start_supervised_finch!(opts \\ []) do + start_supervised!({Finch, name: FinchTest, pools: %{:default => Keyword.put_new(opts, :protocol, :http2)}}) + + FinchTest + end +end diff --git a/test/assent/http_adapter/mint_test.exs b/test/assent/http_adapter/mint_test.exs index 59682b2..f931169 100644 --- a/test/assent/http_adapter/mint_test.exs +++ b/test/assent/http_adapter/mint_test.exs @@ -2,6 +2,7 @@ defmodule Assent.HTTPAdapter.MintTest do use ExUnit.Case doctest Assent.HTTPAdapter.Mint + alias ExUnit.CaptureIO alias Mint.TransportError alias Assent.HTTPAdapter.{HTTPResponse, Mint} @@ -12,7 +13,9 @@ defmodule Assent.HTTPAdapter.MintTest do mint_opts = [transport_opts: [cacerts: TestServer.x509_suite().cacerts], protocols: [:http1]] - assert {:ok, %HTTPResponse{status: 200, body: "HTTP/1.1"}} = Mint.request(:get, TestServer.url(), nil, [], mint_opts) + assert CaptureIO.capture_io(:stderr, fn -> + assert {:ok, %HTTPResponse{status: 200, body: "HTTP/1.1"}} = Mint.request(:get, TestServer.url(), nil, [], mint_opts) + end) =~ "Assent.HTTPAdapter.Mint is deprecated, consider use Assent.HTTPAdapter.Finch instead" end test "handles SSL with bad certificate" do @@ -21,7 +24,9 @@ defmodule Assent.HTTPAdapter.MintTest do bad_host_url = TestServer.url(host: "bad-host.localhost") mint_opts = [transport_opts: [cacerts: TestServer.x509_suite().cacerts]] - assert {:error, %TransportError{reason: {:tls_alert, {:handshake_failure, _error}}}} = Mint.request(:get, bad_host_url, nil, [], mint_opts) + assert CaptureIO.capture_io(:stderr, fn -> + assert {:error, %TransportError{reason: {:tls_alert, {:handshake_failure, _error}}}} = Mint.request(:get, bad_host_url, nil, [], mint_opts) + end) =~ "Assent.HTTPAdapter.Mint is deprecated, consider use Assent.HTTPAdapter.Finch instead" end test "handles SSL with bad certificate and no verification" do @@ -31,7 +36,9 @@ defmodule Assent.HTTPAdapter.MintTest do bad_host_url = TestServer.url(host: "bad-host.localhost") mint_opts = [transport_opts: [cacerts: TestServer.x509_suite().cacerts, verify: :verify_none]] - assert {:ok, %HTTPResponse{status: 200}} = Mint.request(:get, bad_host_url, nil, [], mint_opts) + assert CaptureIO.capture_io(:stderr, fn -> + assert {:ok, %HTTPResponse{status: 200}} = Mint.request(:get, bad_host_url, nil, [], mint_opts) + end) =~ "Assent.HTTPAdapter.Mint is deprecated, consider use Assent.HTTPAdapter.Finch instead" end if :crypto.supports()[:curves] do @@ -41,7 +48,9 @@ defmodule Assent.HTTPAdapter.MintTest do mint_opts = [transport_opts: [cacerts: TestServer.x509_suite().cacerts]] - assert {:ok, %HTTPResponse{status: 200, body: "HTTP/2"}} = Mint.request(:get, TestServer.url(), nil, [], mint_opts) + assert CaptureIO.capture_io(:stderr, fn -> + assert {:ok, %HTTPResponse{status: 200, body: "HTTP/2"}} = Mint.request(:get, TestServer.url(), nil, [], mint_opts) + end) =~ "Assent.HTTPAdapter.Mint is deprecated, consider use Assent.HTTPAdapter.Finch instead" end else IO.warn("No support curve algorithms, can't test in #{__MODULE__}") @@ -52,7 +61,9 @@ defmodule Assent.HTTPAdapter.MintTest do url = TestServer.url() TestServer.stop() - assert {:error, %TransportError{reason: :econnrefused}} = Mint.request(:get, url, nil, []) + assert CaptureIO.capture_io(:stderr, fn -> + assert {:error, %TransportError{reason: :econnrefused}} = Mint.request(:get, url, nil, []) + end) =~ "Assent.HTTPAdapter.Mint is deprecated, consider use Assent.HTTPAdapter.Finch instead" end test "handles query in URL" do @@ -62,7 +73,9 @@ defmodule Assent.HTTPAdapter.MintTest do Plug.Conn.send_resp(conn, 200, "") end) - assert {:ok, %HTTPResponse{status: 200}} = Mint.request(:get, TestServer.url("/get?a=1"), nil, []) + assert CaptureIO.capture_io(:stderr, fn -> + assert {:ok, %HTTPResponse{status: 200}} = Mint.request(:get, TestServer.url("/get?a=1"), nil, []) + end) =~ "Assent.HTTPAdapter.Mint is deprecated, consider use Assent.HTTPAdapter.Finch instead" end test "handles POST" do @@ -77,7 +90,9 @@ defmodule Assent.HTTPAdapter.MintTest do Plug.Conn.send_resp(conn, 200, "") end) - assert {:ok, %HTTPResponse{status: 200}} = Mint.request(:post, TestServer.url("/post"), "a=1&b=2", [{"content-type", "application/x-www-form-urlencoded"}]) + assert CaptureIO.capture_io(:stderr, fn -> + assert {:ok, %HTTPResponse{status: 200}} = Mint.request(:post, TestServer.url("/post"), "a=1&b=2", [{"content-type", "application/x-www-form-urlencoded"}]) + end) =~ "Assent.HTTPAdapter.Mint is deprecated, consider use Assent.HTTPAdapter.Finch instead" end end end