diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ac31a..52ad4a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## v0.2.8 (TBA) +`Req` will be used by default if available in your project, otherwise `:httpc` will be used. + +- `Req` HTTP adapter added +- `Req` supported by default as HTTP client +- Global application config support for HTTP and JWT adapters - More expressive errors now including the whole HTTP response where applicable ## v0.2.7 (2023-09-12) diff --git a/README.md b/README.md index dc09278..745ee35 100644 --- a/README.md +++ b/README.md @@ -41,20 +41,31 @@ Add Assent to your list of dependencies in `mix.exs`: defp deps do [ # ... - {:assent, "~> 0.2.7"}, - - # Required for SSL validation with :httpc adapter - {:certifi, "~> 2.4"}, - {:ssl_verify_fun, "~> 1.1"} + {:assent, "~> 0.2.7"} ] end ``` Run `mix deps.get` to install it. -### Releases +#### HTTP client installation + +By default, `Req` is used if you have it in your dependency list. If not, Erlang's `:httpc` will be used instead. -By default, `:httpc` will be used for HTTP requests. To compile the app with `:httpc` support, please add `:inets` to `:extra_applications` in `mix.exs`: +If you are using `:httpc` you should add the following dependencies to enable SSL validation: + +```elixir +defp deps do + [ + # ... + # Required for SSL validation when using the `:httpc` adapter + {:certifi, "~> 2.4"}, + {:ssl_verify_fun, "~> 1.1"} + ] +end +``` + +You must also add `:inets` to `:extra_applications` in `mix.exs`: ```elixir def application do @@ -68,7 +79,7 @@ def application do end ``` -This is not necessary if you use another HTTP adapter like Finch. +This is not necessary if you use another HTTP adapter like `Req` or `Finch`. ## Getting started @@ -241,14 +252,60 @@ defmodule TestProvider do end ``` -## HTTP Adapter +## HTTP Client + +Assent supports [`Req`](https://github.com/wojtekmach/req), [`Finch`](https://github.com/sneako/finch), and [`:httpc`](https://www.erlang.org/doc/man/httpc.html) out of the box. The `Req` HTTP client adapter will be used by default if enabled, otherwise Erlang's `:httpc` adapter will be included. + +You can explicitly set the HTTP client adapter in the configuration: + +```elixir +config = [ + client_id: "REPLACE_WITH_CLIENT_ID", + client_secret: "REPLACE_WITH_CLIENT_SECRET", + http_adapter: Assent.HTTPAdapter.Httpc +] +``` + +Or globally in the config: + +```elixir +config :assent, http_adapter: Assent.HTTPAdapter.Httpc +``` + +### `Req` + +Req doesn't require any additional configuration and will work out of the box: + +```elixir +defp deps do + [ + # ... + {:req, "~> 0.4"} + ] +end +``` + +### `:httpc` -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 `Req` is not available, 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 [`Finch`](https://github.com/sneako/finch) to your project. +```elixir +defp deps do + [ + # ... + # Required for SSL validation if using the `:httpc` adapter + {:certifi, "~> 2.4"}, + {:ssl_verify_fun, "~> 1.1"} + ] +end +``` + +You must include `:inets` to `:extra_applications` to include `:httpc` in your release. ### Finch +Finch will require a supervisor in your application. + Update `mix.exs`: ```elixir @@ -295,6 +352,12 @@ config = [ ] ``` +Or globally in the config: + +```elixir +config :assent, jwt_adapter: AssAssent.JWTAdapter.JOSE +``` + ## LICENSE (The MIT License) diff --git a/lib/assent/http_adapter.ex b/lib/assent/http_adapter.ex index e30647d..df34866 100644 --- a/lib/assent/http_adapter.ex +++ b/lib/assent/http_adapter.ex @@ -2,6 +2,19 @@ defmodule Assent.HTTPAdapter do @moduledoc """ HTTP adapter helper module. + You can configure the which HTTP adapter Assent uses by setting the + configuring: + + http_adapter: Assent.HTTPAdapter.Httpc + + Default options can be set by passing a list of options: + + http_adapter: {Assent.HTTPAdapter.Httpc, [...]} + + You can also set global application config: + + config :assent, :http_adapter, Assent.HTTPAdapter.Httpc + ## Usage defmodule MyApp.MyHTTPAdapter do diff --git a/lib/assent/http_adapter/httpc.ex b/lib/assent/http_adapter/httpc.ex index 53cbc1a..1de0808 100644 --- a/lib/assent/http_adapter/httpc.ex +++ b/lib/assent/http_adapter/httpc.ex @@ -1,13 +1,17 @@ defmodule Assent.HTTPAdapter.Httpc do @moduledoc """ - HTTP adapter module for making http requests with httpc. + HTTP adapter module for making http requests with `:httpc`. SSL support will automatically be enabled if the `:certifi` and `:ssl_verify_fun` libraries exists in your project. You can also override - the httpc options by updating the configuration: + the `:httpc` options by updating the configuration: http_adapter: {Assent.HTTPAdapter.Httpc, [...]} + For releases please make sure you have included `:inets` in your application: + + extra_applications: [:inets] + See `Assent.HTTPAdapter` for more. """ alias Assent.{HTTPAdapter, HTTPAdapter.HTTPResponse} @@ -16,6 +20,8 @@ defmodule Assent.HTTPAdapter.Httpc do @impl HTTPAdapter def request(method, url, body, headers, httpc_opts \\ nil) do + raise_on_missing_httpc!() + headers = headers ++ [HTTPAdapter.user_agent_header()] request = httpc_request(url, body, headers) opts = parse_httpc_ssl_opts(httpc_opts, url) @@ -25,6 +31,25 @@ defmodule Assent.HTTPAdapter.Httpc do |> format_response() end + defp raise_on_missing_httpc! do + Code.ensure_loaded?(:httpc) || raise """ + #{inspect __MODULE__} requires `:httpc` to be included in your + application. + + Please add `:inets` to `:extra_applications`: + + def application do + [ + # ... + extra_applications: [ + #... + :inets + ] + ] + end + """ + end + defp httpc_request(url, body, headers) do url = to_charlist(url) headers = Enum.map(headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end) @@ -103,7 +128,7 @@ defmodule Assent.HTTPAdapter.Httpc do defp raise_on_missing_ssl_verify_fun! do Code.ensure_loaded?(:ssl_verify_hostname) || raise """ - This request can NOT be verified for valid SSL certificate. + This #{inspect __MODULE__} request can NOT be verified for valid SSL certificate. Please add `:ssl_verify_fun` to your projects dependencies: @@ -134,7 +159,7 @@ defmodule Assent.HTTPAdapter.Httpc do defp raise_on_missing_certifi! do Code.ensure_loaded?(:certifi) || raise """ - This request requires a CA trust store. + This #{inspect __MODULE__} request requires a CA trust store. Please add `:certifi` to your projects dependencies: diff --git a/lib/assent/http_adapter/mint.ex b/lib/assent/http_adapter/mint.ex index e61816a..fe14722 100644 --- a/lib/assent/http_adapter/mint.ex +++ b/lib/assent/http_adapter/mint.ex @@ -1,14 +1,6 @@ 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: - - http_adapter: {Assent.HTTPAdapter.Mint, [...]} - - See `Assent.HTTPAdapter` for more. - """ + @moduledoc false alias Assent.{HTTPAdapter, HTTPAdapter.HTTPResponse} @behaviour HTTPAdapter diff --git a/lib/assent/http_adapter/req.ex b/lib/assent/http_adapter/req.ex new file mode 100644 index 0000000..4feec96 --- /dev/null +++ b/lib/assent/http_adapter/req.ex @@ -0,0 +1,43 @@ +if Code.ensure_loaded?(Req) do + defmodule Assent.HTTPAdapter.Req do + @moduledoc """ + HTTP adapter module for making http requests with Req. + + You can also override the Req options by updating the configuration: + + http_adapter: {Assent.HTTPAdapter.Req, [...]} + + See `Assent.HTTPAdapter` for more. + """ + alias Assent.{HTTPAdapter, HTTPAdapter.HTTPResponse} + + @behaviour HTTPAdapter + + @impl HTTPAdapter + def request(method, url, body, headers, req_opts \\ nil) do + headers = headers ++ [HTTPAdapter.user_agent_header()] + opts = req_opts || [] + + opts = + Keyword.merge([ + method: method, + url: url, + headers: headers, + body: body + ], opts) + + opts + |> Req.new() + |> Req.request() + |> case do + {:ok, response} -> + headers = Enum.map(headers, fn {key, value} -> {String.downcase(to_string(key)), to_string(value)} end) + + {:ok, %HTTPResponse{status: response.status, headers: headers, body: response.body}} + + {:error, error} -> + {:error, error} + end + end + end + end diff --git a/lib/assent/jwt_adapter.ex b/lib/assent/jwt_adapter.ex index 47d67f7..4cd935a 100644 --- a/lib/assent/jwt_adapter.ex +++ b/lib/assent/jwt_adapter.ex @@ -2,6 +2,18 @@ defmodule Assent.JWTAdapter do @moduledoc """ JWT adapter helper module. + You can configure the JWT adapter by updating the configuration: + + jwt_adapter: {Assent.JWTAdapter.AssentJWT, [...]} + + Default options can be set by passing a list of options: + + jwt_adapter: {Assent.JWTAdapter.AssentJWT, [...]} + + You can also set global application config: + + config :assent, :jwt_adapter, Assent.JWTAdapter.AssentJWT + ## Usage defmodule MyApp.MyJWTAdapter do @@ -44,10 +56,11 @@ defmodule Assent.JWTAdapter do defp fetch_adapter(opts) do default_opts = Keyword.put(opts, :json_library, Config.json_library(opts)) + default_jwt_adapter = Application.get_env(:assent, :jwt_adapter, Assent.JWTAdapter.AssentJWT) - case Keyword.get(opts, :jwt_adapter, Assent.JWTAdapter.AssentJWT) do - {adapter, opts} -> {adapter, Keyword.merge(default_opts, opts)} - adapter -> {adapter, default_opts} + case Keyword.get(opts, :jwt_adapter, default_jwt_adapter) do + {adapter, opts} -> {adapter, Keyword.merge(default_opts, opts)} + adapter when is_atom(adapter) -> {adapter, default_opts} end end diff --git a/lib/assent/jwt_adapter/assent_jwt.ex b/lib/assent/jwt_adapter/assent_jwt.ex index 3ba8f1f..ad8d868 100644 --- a/lib/assent/jwt_adapter/assent_jwt.ex +++ b/lib/assent/jwt_adapter/assent_jwt.ex @@ -2,6 +2,10 @@ defmodule Assent.JWTAdapter.AssentJWT do @moduledoc """ JWT adapter module for parsing JSON Web Tokens natively. + You can append options to the configuration: + + jwt_adapter: {Assent.JWTAdapter.AssentJWT, [...]} + See `Assent.JWTAdapter` for more. """ alias Assent.{Config, JWTAdapter} diff --git a/lib/assent/jwt_adapter/jose.ex b/lib/assent/jwt_adapter/jose.ex index 98a7756..b8f9442 100644 --- a/lib/assent/jwt_adapter/jose.ex +++ b/lib/assent/jwt_adapter/jose.ex @@ -2,6 +2,10 @@ defmodule Assent.JWTAdapter.JOSE do @moduledoc """ JWT adapter module for parsing JSON Web Tokens with JOSE. + You can append options to the configuration: + + jwt_adapter: {Assent.JWTAdapter.JOSE, [...]} + See `Assent.JWTAdapter` for more. """ alias Assent.JWTAdapter diff --git a/lib/assent/strategy.ex b/lib/assent/strategy.ex index f1620a9..02ccc64 100644 --- a/lib/assent/strategy.ex +++ b/lib/assent/strategy.ex @@ -44,10 +44,20 @@ defmodule Assent.Strategy do |> decode_response(config) end + @default_http_client Enum.find_value([ + {Req1, Assent.HTTPAdapter.Req}, + {:httpc, Assent.HTTPAdapter.Httpc} + ], + fn {dep, module} -> + Code.ensure_loaded?(dep) && {module, []} + end) + defp fetch_http_adapter(config) do - case Config.get(config, :http_adapter, Assent.HTTPAdapter.Httpc) do - {http_adapter, opts} -> {http_adapter, opts} - http_adapter -> {http_adapter, nil} + default_http_adapter = Application.get_env(:assent, :http_adapter, @default_http_client) + + case Config.get(config, :http_adapter, default_http_adapter) do + {http_adapter, opts} -> {http_adapter, opts} + http_adapter when is_atom(http_adapter) -> {http_adapter, nil} end end diff --git a/mix.exs b/mix.exs index aa33386..f9b8efb 100644 --- a/mix.exs +++ b/mix.exs @@ -40,11 +40,12 @@ defmodule Assent.MixProject do {:finch, "~> 0.15", optional: true}, {:mint, "~> 1.0", optional: true}, + {:req, "~> 0.4", optional: true}, + {:jason, "~> 1.0", optional: true}, # Required for Req {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:credo, "~> 1.1", only: [:dev, :test]}, - {:jason, "~> 1.0", only: [:dev, :test]}, {:test_server, "~> 0.1.0", only: :test}, {:bandit, ">= 0.0.0", only: :test} ] diff --git a/mix.lock b/mix.lock index bd14adb..5eebd87 100644 --- a/mix.lock +++ b/mix.lock @@ -21,6 +21,7 @@ "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "plug": {:hex, :plug, "1.15.2", "94cf1fa375526f30ff8770837cb804798e0045fd97185f0bb9e5fcd858c792a3", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02731fa0c2dcb03d8d21a1d941bdbbe99c2946c0db098eee31008e04c6283615"}, "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "req": {:hex, :req, "0.4.5", "2071bbedd280f107b9e33e1ddff2beb3991ec1ae06caa2cca2ab756393d8aca5", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "dd23e9c7303ddeb2dee09ff11ad8102cca019e38394456f265fb7b9655c64dd8"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "test_server": {:hex, :test_server, "0.1.14", "c3cdf0b6c1be691ae50a14ee3ea4bd026250c321c2012f5dfaed336d8702a562", [:mix], [{:bandit, ">= 0.7.6", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 2.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:x509, "~> 0.6", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "5af2f98a22765ff3cc66f09d20c88b754586cf0c45cf0d2e0068e2a47f5041a7"}, diff --git a/test/assent/http_adapter/req_test.exs b/test/assent/http_adapter/req_test.exs new file mode 100644 index 0000000..ee56d4b --- /dev/null +++ b/test/assent/http_adapter/req_test.exs @@ -0,0 +1,70 @@ +defmodule Assent.HTTPAdapter.ReqTest do + use ExUnit.Case + doctest Assent.HTTPAdapter.Req + + alias Mint.TransportError + alias Assent.HTTPAdapter.{HTTPResponse, Req} + + describe "request/4" do + test "handles SSL" do + TestServer.start(scheme: :https) + TestServer.add("/", via: :get) + + req_opts = [connect_options: [transport_opts: [cacerts: TestServer.x509_suite().cacerts]]] + + assert {:ok, %HTTPResponse{status: 200, body: "HTTP/1.1"}} = Req.request(:get, TestServer.url(), nil, [], req_opts) + end + + test "handles SSL with bad certificate" do + TestServer.start(scheme: :https) + + bad_host_url = TestServer.url(host: "bad-host.localhost") + req_opts = [connect_options: [transport_opts: [cacerts: TestServer.x509_suite().cacerts]]] + + assert {:error, %TransportError{reason: {:tls_alert, {:handshake_failure, _error}}}} = Req.request(:get, bad_host_url, nil, [], req_opts) + end + + test "handles SSL with bad certificate and no verification" do + TestServer.start(scheme: :https) + TestServer.add("/", via: :get) + + bad_host_url = TestServer.url(host: "bad-host.localhost") + req_opts = [connect_options: [transport_opts: [cacerts: TestServer.x509_suite().cacerts, verify: :verify_none]]] + + assert {:ok, %HTTPResponse{status: 200}} = Req.request(:get, bad_host_url, nil, [], req_opts) + end + + test "handles unreachable host" do + TestServer.start() + url = TestServer.url() + TestServer.stop() + + assert {:error, %TransportError{reason: :econnrefused}} = Req.request(:get, url, nil, [], retry: false) + 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) + + assert {:ok, %HTTPResponse{status: 200}} = Req.request(:get, TestServer.url("/get?a=1"), nil, []) + 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) + + assert {:ok, %HTTPResponse{status: 200}} = Req.request(:post, TestServer.url("/post"), "a=1&b=2", [{"content-type", "application/x-www-form-urlencoded"}]) + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 3c49441..b4513d8 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,5 @@ +Application.put_env(:assent, :http_adapter, Assent.HTTPAdapter.Httpc) + Logger.configure(level: :warning) ExUnit.start()