diff --git a/README.md b/README.md index b4da72ad..075c0219 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ ConfigCat is a [hosted feature flag service](http://configcat.com). Manage featu ```elixir def deps do [ - {:configcat, "~> 4.0.4"} + {:configcat, "~> 4.0.4"}, + # pick an HTTP client (see "HTTP Client" below) — the SDK ships none by default + {:httpoison, "~> 2.0"} ] end ``` @@ -82,6 +84,58 @@ end The ConfigCat SDK supports 3 different polling mechanisms to acquire the setting values from ConfigCat. After latest setting values are downloaded, they are stored in the internal cache then all requests are served from there. Read more about Polling Modes and how to use them at [ConfigCat Docs](https://configcat.com/docs/sdk-reference/elixir/). +## HTTP Client + +The SDK fetches configurations over HTTP through a swappable transport that +implements the `ConfigCat.HTTPClient` behaviour. + +Both `:httpoison` and `:finch` are declared as **optional** dependencies. Add +whichever you prefer to your own `mix.exs`. + +### Default — HTTPoison + +```elixir +def deps do + [ + {:configcat, "~> 4.0.4"}, + {:httpoison, "~> 2.0"} + ] +end +``` + +No further configuration needed — `ConfigCat.HTTPClient.HTTPoison` is used by default. + +### Finch + +```elixir +def deps do + [ + {:configcat, "~> 4.0.4"}, + {:finch, "~> 0.18"} + ] +end +``` + +```elixir +# config/config.exs +config :configcat, ConfigCat.HTTPClient.Finch, name: MyApp.Finch + +# application.ex +children = [ + {Finch, name: MyApp.Finch}, + {ConfigCat, sdk_key: "YOUR SDK KEY", http_client: ConfigCat.HTTPClient.Finch} +] +``` + +### Custom adapter + +Implement `ConfigCat.HTTPClient` to use any other HTTP client (Req, Mint, +Tesla, a test stub, ...) and pass the module via `:http_client`: + +```elixir +{ConfigCat, sdk_key: "YOUR SDK KEY", http_client: MyApp.ConfigCatClient} +``` + ## Need help? https://configcat.com/support diff --git a/lib/config_cat.ex b/lib/config_cat.ex index 5d20177b..f10ecb31 100644 --- a/lib/config_cat.ex +++ b/lib/config_cat.ex @@ -95,6 +95,17 @@ defmodule ConfigCat do - `hooks`: **OPTIONAL** Specify callback functions to be called when particular events are fired by the SDK. See `ConfigCat.Hooks`. + - `http_client`: **OPTIONAL** Module implementing the `ConfigCat.HTTPClient` + behaviour, used to perform HTTP requests against the ConfigCat CDN. Defaults + to `ConfigCat.HTTPClient.HTTPoison`, which is built on + [HTTPoison](https://hex.pm/packages/httpoison). Provide your own adapter to + route requests through Finch, Req, Mint, Tesla, or any other client (or to + stub HTTP in tests). + + ```elixir + {ConfigCat, [sdk_key: "YOUR SDK KEY", http_client: MyApp.ConfigCatClient]} + ``` + - `http_proxy`: **OPTIONAL** Specify this option if you need to use a proxy server to access your ConfigCat settings. You can provide a simple URL, like `https://my_proxy.example.com` or include authentication information, like @@ -261,6 +272,7 @@ defmodule ConfigCat do | {:default_user, User.t()} | {:flag_overrides, OverrideDataSource.t()} | {:hooks, [Hooks.option()]} + | {:http_client, module()} | {:http_proxy, String.t()} | {:name, instance_id()} | {:offline, boolean()} diff --git a/lib/config_cat/api.ex b/lib/config_cat/api.ex deleted file mode 100644 index 452ab857..00000000 --- a/lib/config_cat/api.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule ConfigCat.API do - @moduledoc false - - use HTTPoison.Base - - @impl HTTPoison.Base - def process_request_headers(headers) do - [{"Accept", "application/json"} | headers] - end -end diff --git a/lib/config_cat/config_fetcher.ex b/lib/config_cat/config_fetcher.ex index c47bd6ce..4db2f1d8 100644 --- a/lib/config_cat/config_fetcher.ex +++ b/lib/config_cat/config_fetcher.ex @@ -2,7 +2,6 @@ defmodule ConfigCat.ConfigFetcher do @moduledoc false alias ConfigCat.ConfigEntry - alias HTTPoison.Response defmodule FetchError do @moduledoc false @@ -43,7 +42,6 @@ defmodule ConfigCat.CacheControlConfigFetcher do alias ConfigCat.ConfigEntry alias ConfigCat.ConfigFetcher alias ConfigCat.ConfigFetcher.FetchError - alias HTTPoison.Response require ConfigCat.ConfigCatLogger, as: ConfigCatLogger require ConfigCat.Constants, as: Constants @@ -54,7 +52,7 @@ defmodule ConfigCat.CacheControlConfigFetcher do use TypedStruct typedstruct enforce: true do - field :api, module(), default: ConfigCat.API + field :http_client, module(), default: ConfigCat.HTTPClient.HTTPoison field :base_url, String.t() field :callers, [GenServer.from()], default: [] field :connect_timeout_milliseconds, non_neg_integer(), default: 8_000 @@ -104,6 +102,7 @@ defmodule ConfigCat.CacheControlConfigFetcher do {:base_url, String.t()} | {:connect_timeout_milliseconds, non_neg_integer()} | {:data_governance, ConfigCat.data_governance()} + | {:http_client, module()} | {:http_proxy, String.t()} | {:instance_id, ConfigCat.instance_id()} | {:mode, String.t()} @@ -174,11 +173,11 @@ defmodule ConfigCat.CacheControlConfigFetcher do defp do_fetch(%State{} = state, etag) do ConfigCatLogger.debug("Fetching configuration from ConfigCat") - case state.api.get(url(state), headers(state, etag), http_options(state)) do + case state.http_client.get(url(state), headers(state, etag), http_options(state)) do {:ok, response} -> handle_response(response, state, etag) - error -> + {:error, error} -> {:error, handle_error(error, state), state} end end @@ -222,7 +221,7 @@ defmodule ConfigCat.CacheControlConfigFetcher do # This function is slightly complex, but still reasonably understandable. # Breaking it up doesn't seem like it will help much. # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - defp handle_response(%Response{status_code: code, body: raw_config, headers: headers}, %State{} = state, etag) + defp handle_response(%{status: code, body: raw_config, headers: headers}, %State{} = state, etag) when code >= 200 and code < 300 do ConfigCatLogger.debug("ConfigCat configuration json fetch response code: #{code} Cached: #{extract_etag(headers)}") @@ -282,11 +281,11 @@ defmodule ConfigCat.CacheControlConfigFetcher do end end - defp handle_response(%Response{status_code: 304}, %State{} = state, _etag) do + defp handle_response(%{status: 304}, %State{} = state, _etag) do {:ok, :unchanged, state} end - defp handle_response(%Response{status_code: status} = response, %State{} = state, _etag) when status in [403, 404] do + defp handle_response(%{status: status} = response, %State{} = state, _etag) when status in [403, 404] do ConfigCatLogger.error( "Your SDK Key seems to be wrong. You can find the valid SDKKey at https://app.configcat.com/sdkkey. Received unexpected response: #{inspect(response)}", event_id: 1100 @@ -308,25 +307,28 @@ defmodule ConfigCat.CacheControlConfigFetcher do {:error, error, state} end - defp handle_error({:error, %HTTPoison.Error{reason: :checkout_timeout} = error}, %State{} = state) do + @timeout_reasons ~w(checkout_timeout timeout connect_timeout)a + + defp handle_error(%{reason: reason, transient?: transient?}, %State{} = state) + when reason in @timeout_reasons do ConfigCatLogger.error( "Request timed out while trying to fetch config JSON. Timeout values: [connect: #{state.connect_timeout_milliseconds}ms, read: #{state.read_timeout_milliseconds}ms]", event_id: 1102 ) - FetchError.exception(reason: error, transient?: true) + FetchError.exception(reason: reason, transient?: transient?) end - defp handle_error({:error, error}, _state) do + defp handle_error(%{reason: reason, transient?: transient?}, _state) do ConfigCatLogger.error( "Unexpected error occurred while trying to fetch config JSON. " <> "It is most likely due to a local network issue. " <> "Please make sure your application can reach the ConfigCat CDN servers (or your proxy server) over HTTP. " <> - "#{inspect(error)}", + "#{inspect(reason)}", event_id: 1103 ) - FetchError.exception(reason: error, transient?: true) + FetchError.exception(reason: reason, transient?: transient?) end defp extract_etag(headers) do diff --git a/lib/config_cat/http_client.ex b/lib/config_cat/http_client.ex new file mode 100644 index 00000000..a5f92006 --- /dev/null +++ b/lib/config_cat/http_client.ex @@ -0,0 +1,43 @@ +defmodule ConfigCat.HTTPClient do + @moduledoc """ + Behaviour for the HTTP transport used by the ConfigCat SDK. + + The SDK ships with `ConfigCat.HTTPClient.HTTPoison`, a default adapter built on + [HTTPoison](https://hex.pm/packages/httpoison). To plug in a different HTTP + client (Finch, Req, Mint, Tesla, a test stub, ...) implement this behaviour + and pass the module via the `:http_client` option to `ConfigCat.start_link/1`. + + defmodule MyApp.ConfigCatClient do + @behaviour ConfigCat.HTTPClient + + @impl true + def get(url, headers, opts) do + # Translate to your favourite HTTP client and return a normalized + # `{:ok, %{status: _, body: _, headers: _}}` or + # `{:error, %{reason: _, transient?: _}}`. + end + end + + ConfigCat.start_link(sdk_key: "...", http_client: MyApp.ConfigCatClient) + + Adapters are responsible for classifying errors as transient (the SDK will + treat them as retryable) or permanent. + """ + + @type header :: {String.t(), String.t()} + @type url :: String.t() + @type opts :: keyword() + + @type response :: %{ + required(:status) => 100..599, + required(:body) => binary(), + required(:headers) => [header()] + } + + @type error :: %{ + required(:reason) => any(), + required(:transient?) => boolean() + } + + @callback get(url(), [header()], opts()) :: {:ok, response()} | {:error, error()} +end diff --git a/lib/config_cat/http_client/finch.ex b/lib/config_cat/http_client/finch.ex new file mode 100644 index 00000000..91906354 --- /dev/null +++ b/lib/config_cat/http_client/finch.ex @@ -0,0 +1,92 @@ +defmodule ConfigCat.HTTPClient.Finch do + @moduledoc """ + `ConfigCat.HTTPClient` adapter built on + [Finch](https://hex.pm/packages/finch). + + Finch is declared as an optional dependency, so it is only pulled in when + the application elects to use it. + + ## Usage + + Start a Finch pool in your application supervision tree, configure the + pool name for the adapter, and select it as the SDK's HTTP client: + + # config/config.exs + config :configcat, ConfigCat.HTTPClient.Finch, name: MyApp.Finch + + # application.ex + children = [ + {Finch, name: MyApp.Finch}, + {ConfigCat, sdk_key: "...", http_client: ConfigCat.HTTPClient.Finch} + ] + + ## Option translation + + The SDK passes HTTPoison-shaped options to the adapter; this module + translates the relevant ones to Finch's request options: + + - `:timeout` → Finch `:pool_timeout` + - `:recv_timeout` → Finch `:receive_timeout` + - `:proxy` → ignored (configure your Finch pool instead) + + Errors classified as transient: `:timeout`, `:closed`, `:econnrefused`, + `:nxdomain`. + """ + + @behaviour ConfigCat.HTTPClient + + @compile {:no_warn_undefined, [Finch]} + + @transient_reasons ~w(timeout closed econnrefused nxdomain)a + + @impl ConfigCat.HTTPClient + def get(url, headers, opts) do + ensure_finch!() + + headers = [{"Accept", "application/json"} | headers] + request = Finch.build(:get, url, headers) + request_opts = build_request_opts(opts) + + case Finch.request(request, finch_name(), request_opts) do + {:ok, %{status: status, body: body, headers: response_headers}} -> + {:ok, %{status: status, body: body, headers: response_headers}} + + {:error, %{reason: reason}} -> + {:error, %{reason: reason, transient?: reason in @transient_reasons}} + + {:error, reason} -> + {:error, %{reason: reason, transient?: false}} + end + end + + defp finch_name do + Application.get_env(:configcat, __MODULE__, [])[:name] || + raise ArgumentError, """ + #{inspect(__MODULE__)} requires the name of a Finch pool. Configure it: + + config :configcat, #{inspect(__MODULE__)}, name: MyApp.Finch + """ + end + + defp build_request_opts(opts) do + Enum.flat_map(opts, fn + {:timeout, value} -> [pool_timeout: value] + {:recv_timeout, value} -> [receive_timeout: value] + _ -> [] + end) + end + + defp ensure_finch! do + if Code.ensure_loaded?(Finch) do + :ok + else + raise """ + #{inspect(__MODULE__)} requires the optional :finch dependency. + + Add it to your deps: + + {:finch, "~> 0.18"} + """ + end + end +end diff --git a/lib/config_cat/http_client/httpoison.ex b/lib/config_cat/http_client/httpoison.ex new file mode 100644 index 00000000..823189c9 --- /dev/null +++ b/lib/config_cat/http_client/httpoison.ex @@ -0,0 +1,49 @@ +defmodule ConfigCat.HTTPClient.HTTPoison do + @moduledoc """ + Default `ConfigCat.HTTPClient` adapter built on + [HTTPoison](https://hex.pm/packages/httpoison). + + HTTPoison is declared as an optional dependency, so applications that supply + their own `:http_client` adapter do not need to pull it in. If neither + HTTPoison nor a custom adapter is configured, calling `get/3` raises with + instructions for the user. + """ + + @behaviour ConfigCat.HTTPClient + + @compile {:no_warn_undefined, [HTTPoison]} + + @timeout_reasons ~w(checkout_timeout timeout connect_timeout)a + @transient_reasons @timeout_reasons ++ ~w(closed econnrefused nxdomain)a + + @impl ConfigCat.HTTPClient + def get(url, headers, opts) do + ensure_httpoison!() + headers = [{"Accept", "application/json"} | headers] + + case HTTPoison.get(url, headers, opts) do + {:ok, %{status_code: status, body: body, headers: response_headers}} -> + {:ok, %{status: status, body: body, headers: response_headers}} + + {:error, %{reason: reason}} -> + {:error, %{reason: reason, transient?: reason in @transient_reasons}} + end + end + + defp ensure_httpoison! do + if Code.ensure_loaded?(HTTPoison) do + :ok + else + raise ArgumentError, """ + #{inspect(__MODULE__)} requires the optional :httpoison dependency. + + Either add it to your deps: + + {:httpoison, "~> 2.0"} + + or provide your own HTTP client by passing `:http_client` to + `ConfigCat.start_link/1`. See `ConfigCat.HTTPClient` for the behaviour. + """ + end + end +end diff --git a/lib/config_cat/supervisor.ex b/lib/config_cat/supervisor.ex index 097287bf..6946c8dc 100644 --- a/lib/config_cat/supervisor.ex +++ b/lib/config_cat/supervisor.ex @@ -135,6 +135,7 @@ defmodule ConfigCat.Supervisor do |> Keyword.put(:mode, options[:cache_policy].mode) |> Keyword.take([ :base_url, + :http_client, :http_proxy, :connect_timeout_milliseconds, :read_timeout_milliseconds, diff --git a/mix.exs b/mix.exs index 25899bc4..eb037dcf 100644 --- a/mix.exs +++ b/mix.exs @@ -77,7 +77,8 @@ defmodule ConfigCat.MixProject do {:elixir_uuid, "~> 1.2"}, {:ex_doc, "~> 0.31.0", only: :dev, runtime: false}, {:excoveralls, "~> 0.18.0", only: :test}, - {:httpoison, "~> 1.7 or ~> 2.0"}, + {:finch, "~> 0.18", optional: true}, + {:httpoison, "~> 1.7 or ~> 2.0", optional: true}, {:jason, "~> 1.2"}, {:mix_test_interactive, "~> 1.2", only: :dev, runtime: false}, {:mox, "~> 1.1", only: :test}, diff --git a/mix.lock b/mix.lock index 3227b393..10abce2b 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,9 @@ "ex_doc": {:hex, :ex_doc, "0.31.2", "8b06d0a5ac69e1a54df35519c951f1f44a7b7ca9a5bb7a260cd8a174d6322ece", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "317346c14febaba9ca40fd97b5b5919f7751fb85d399cc8e7e8872049f37e0af"}, "excoveralls": {:hex, :excoveralls, "0.18.2", "86efd87a0676a3198ff50b8c77620ea2f445e7d414afa9ec6c4ba84c9f8bdcc2", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "230262c418f0de64077626a498bd4fdf1126d5c2559bb0e6b43deac3005225a4"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "httpoison": {:hex, :httpoison, "2.2.3", "a599d4b34004cc60678999445da53b5e653630651d4da3d14675fedc9dd34bd6", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fa0f2e3646d3762fdc73edb532104c8619c7636a6997d20af4003da6cfc53e53"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -17,13 +19,18 @@ "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, "mix_test_interactive": {:hex, :mix_test_interactive, "1.2.2", "72f72faa7007d6cb9634ee5f6989b25ee5b194c5729e5e45a962e68b2e217374", [:mix], [{:file_system, "~> 0.2", [hex: :file_system, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "f49f2a70d00aee93418506dde4d95387fe56bdba501ef9d2aa06ea07d4823508"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "tz": {:hex, :tz, "0.26.6", "4d46178dd5bc4d2c1e78c9affcc3fd46764e29cd2a148c06666edb83cb18629f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "9ca97ea48b412f2404740867f6c321ee8ce112602035bb79b0b90c9c03174652"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, diff --git a/test/config_cat/config_fetcher_test.exs b/test/config_cat/config_fetcher_test.exs index f33ba03b..113890a4 100644 --- a/test/config_cat/config_fetcher_test.exs +++ b/test/config_cat/config_fetcher_test.exs @@ -9,7 +9,6 @@ defmodule ConfigCat.ConfigFetcherTest do alias ConfigCat.FetchTime alias ConfigCat.Hooks alias ConfigCat.MockAPI - alias HTTPoison.Response require ConfigCat.Constants, as: Constants @@ -27,7 +26,7 @@ defmodule ConfigCat.ConfigFetcherTest do start_supervised!({Hooks, instance_id: instance_id}) - default_options = [api: MockAPI, mode: mode, instance_id: instance_id, sdk_key: sdk_key] + default_options = [http_client: MockAPI, mode: mode, instance_id: instance_id, sdk_key: sdk_key] {:ok, pid} = start_supervised({ConfigFetcher, Keyword.merge(default_options, options)}) @@ -36,13 +35,17 @@ defmodule ConfigCat.ConfigFetcherTest do {:ok, instance_id} end + defp response(fields) do + Map.merge(%{status: 200, body: "", headers: []}, Map.new(fields)) + end + test "successful fetch" do {:ok, fetcher} = start_fetcher(@fetcher_options) url = global_config_url() stub(MockAPI, :get, fn ^url, _headers, _options -> - {:ok, %Response{status_code: 200, body: @raw_config, headers: [{"ETag", @etag}]}} + {:ok, response(status: 200, body: @raw_config, headers: [{"ETag", @etag}])} end) before = FetchTime.now_ms() @@ -65,7 +68,7 @@ defmodule ConfigCat.ConfigFetcherTest do expect(MockAPI, :get, 1, fn ^url, _headers, _options -> Process.sleep(50) - {:ok, %Response{status_code: 200, body: @raw_config, headers: [{"ETag", @etag}]}} + {:ok, response(status: 200, body: @raw_config, headers: [{"ETag", @etag}])} end) results = @@ -86,12 +89,12 @@ defmodule ConfigCat.ConfigFetcherTest do test "user agent header that includes the fetch mode" do {:ok, fetcher} = start_fetcher(@fetcher_options) - response = %Response{status_code: 200, body: @raw_config} + resp = response(status: 200, body: @raw_config) stub(MockAPI, :get, fn _url, headers, _options -> assert_user_agent_matches(headers, ~r"^ConfigCat-Elixir/#{@mode}-") - {:ok, response} + {:ok, resp} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -100,11 +103,7 @@ defmodule ConfigCat.ConfigFetcherTest do test "sends proper cache control header on later requests" do {:ok, fetcher} = start_fetcher(@fetcher_options) - initial_response = %Response{ - status_code: 200, - body: @raw_config, - headers: [{"ETag", @etag}] - } + initial_response = response(status: 200, body: @raw_config, headers: [{"ETag", @etag}]) stub(MockAPI, :get, fn _url, headers, _options -> assert List.keyfind(headers, "ETag", 0) == nil @@ -113,10 +112,7 @@ defmodule ConfigCat.ConfigFetcherTest do {:ok, _} = ConfigFetcher.fetch(fetcher, nil) - not_modified_response = %Response{ - status_code: 304, - headers: [{"ETag", @etag}] - } + not_modified_response = response(status: 304, headers: [{"ETag", @etag}]) expect(MockAPI, :get, fn _url, headers, _options -> assert {"If-None-Match", @etag} = List.keyfind(headers, "If-None-Match", 0) @@ -129,24 +125,18 @@ defmodule ConfigCat.ConfigFetcherTest do test "returns unchanged response when server responds that the config hasn't changed" do {:ok, fetcher} = start_fetcher(@fetcher_options) - response = %Response{ - status_code: 304, - headers: [{"ETag", @etag}] - } + resp = response(status: 304, headers: [{"ETag", @etag}]) - stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, response} end) + stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, resp} end) assert {:ok, :unchanged} = ConfigFetcher.fetch(fetcher, @etag) end test "returns unchanged response (with lowercase 'etag') when server responds that the config hasn't changed" do {:ok, fetcher} = start_fetcher(@fetcher_options) - response = %Response{ - status_code: 304, - headers: [{"etag", @etag}] - } + resp = response(status: 304, headers: [{"etag", @etag}]) - stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, response} end) + stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, resp} end) assert {:ok, :unchanged} = ConfigFetcher.fetch(fetcher, @etag) end @@ -154,20 +144,20 @@ defmodule ConfigCat.ConfigFetcherTest do test "returns error for non-200 response from ConfigCat" do {:ok, fetcher} = start_fetcher(@fetcher_options) - response = %Response{status_code: 503} + resp = response(status: 503) - stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, response} end) - assert {:error, %FetchError{reason: ^response}} = ConfigFetcher.fetch(fetcher, nil) + stub(MockAPI, :get, fn _url, _headers, _options -> {:ok, resp} end) + assert {:error, %FetchError{reason: ^resp}} = ConfigFetcher.fetch(fetcher, nil) end @tag capture_log: true test "returns error for error response from ConfigCat" do {:ok, fetcher} = start_fetcher(@fetcher_options) - error = %HTTPoison.Error{reason: "failed"} + error = %{reason: :failed, transient?: true} stub(MockAPI, :get, fn _url, _headers, _options -> {:error, error} end) - assert {:error, %FetchError{reason: ^error}} = ConfigFetcher.fetch(fetcher, nil) + assert {:error, %FetchError{reason: :failed, transient?: true}} = ConfigFetcher.fetch(fetcher, nil) end test "allows base URL to be configured" do @@ -177,19 +167,19 @@ defmodule ConfigCat.ConfigFetcherTest do url = config_url(base_url, @sdk_key) - expect(MockAPI, :get, fn ^url, _headers, _options -> {:ok, %Response{status_code: 200, body: @raw_config}} end) + expect(MockAPI, :get, fn ^url, _headers, _options -> {:ok, response(status: 200, body: @raw_config)} end) {:ok, _} = ConfigFetcher.fetch(fetcher, nil) end test "uses default timeouts if none provided" do {:ok, fetcher} = start_fetcher(@fetcher_options) - response = %Response{status_code: 200, body: @raw_config} + resp = response(status: 200, body: @raw_config) expect(MockAPI, :get, fn _url, _headers, options -> assert Keyword.get(options, :recv_timeout) == 5000 assert Keyword.get(options, :timeout) == 8000 - {:ok, response} + {:ok, resp} end) {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -205,12 +195,12 @@ defmodule ConfigCat.ConfigFetcherTest do read_timeout_milliseconds: read_timeout ) - response = %Response{status_code: 200, body: @raw_config} + resp = response(status: 200, body: @raw_config) expect(MockAPI, :get, fn _url, _headers, options -> assert Keyword.get(options, :recv_timeout) == read_timeout assert Keyword.get(options, :timeout) == connect_timeout - {:ok, response} + {:ok, resp} end) {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -220,11 +210,11 @@ defmodule ConfigCat.ConfigFetcherTest do proxy = "https://PROXY" {:ok, fetcher} = start_fetcher(@fetcher_options, http_proxy: proxy) - response = %Response{status_code: 200, body: @raw_config} + resp = response(status: 200, body: @raw_config) expect(MockAPI, :get, fn _url, _headers, options -> assert Keyword.get(options, :proxy) == proxy - {:ok, response} + {:ok, resp} end) {:ok, _} = ConfigFetcher.fetch(fetcher, nil) diff --git a/test/config_cat/data_governance_test.exs b/test/config_cat/data_governance_test.exs index 8e7372d9..75707abf 100644 --- a/test/config_cat/data_governance_test.exs +++ b/test/config_cat/data_governance_test.exs @@ -9,7 +9,6 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do alias ConfigCat.ConfigEntry alias ConfigCat.Hooks alias ConfigCat.MockAPI - alias HTTPoison.Response require ConfigCat.Constants, as: Constants require ConfigCat.RedirectMode, as: RedirectMode @@ -30,7 +29,7 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do start_supervised!({Hooks, instance_id: instance_id}) - default_options = [api: MockAPI, instance_id: instance_id, mode: mode, sdk_key: sdk_key] + default_options = [http_client: MockAPI, instance_id: instance_id, mode: mode, sdk_key: sdk_key] {:ok, pid} = start_supervised({ConfigFetcher, Keyword.merge(default_options, options)}) allow(MockAPI, self(), pid) @@ -50,13 +49,13 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 2, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) |> expect(:get, 0, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) |> expect(:get, 0, fn ^redirect_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) assert {:ok, %ConfigEntry{config: ^config}} = ConfigFetcher.fetch(fetcher, nil) @@ -75,13 +74,13 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 0, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) |> expect(:get, 2, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) |> expect(:get, 0, fn ^redirect_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: raw_config}} + {:ok, %{status: 200, headers: [], body: raw_config}} end) assert {:ok, %ConfigEntry{config: ^config}} = ConfigFetcher.fetch(fetcher, nil) @@ -99,10 +98,10 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 1, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_eu}} + {:ok, %{status: 200, headers: [], body: config_to_eu}} end) |> expect(:get, 2, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_eu}} + {:ok, %{status: 200, headers: [], body: config_eu}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -120,10 +119,10 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 0, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_eu}} + {:ok, %{status: 200, headers: [], body: config_to_eu}} end) |> expect(:get, 2, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_eu}} + {:ok, %{status: 200, headers: [], body: config_eu}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -145,13 +144,13 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 2, fn ^custom_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_global}} + {:ok, %{status: 200, headers: [], body: config_to_global}} end) |> expect(:get, 0, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) |> expect(:get, 0, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -173,13 +172,13 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 2, fn ^custom_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_eu}} + {:ok, %{status: 200, headers: [], body: config_to_eu}} end) |> expect(:get, 0, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) |> expect(:get, 0, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -197,10 +196,10 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 1, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_forced}} + {:ok, %{status: 200, headers: [], body: config_to_forced}} end) |> expect(:get, 2, fn ^forced_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: "{}"}} + {:ok, %{status: 200, headers: [], body: "{}"}} end) |> expect(:get, 0, fn ^eu_url, _headers, _options -> :not_called @@ -226,16 +225,16 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 0, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) |> expect(:get, 0, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: %{}}} + {:ok, %{status: 200, headers: [], body: %{}}} end) |> expect(:get, 1, fn ^custom_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_forced}} + {:ok, %{status: 200, headers: [], body: config_to_forced}} end) |> expect(:get, 3, fn ^forced_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_forced}} + {:ok, %{status: 200, headers: [], body: config_to_forced}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) @@ -253,20 +252,20 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do MockAPI |> expect(:get, 1, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_eu}} + {:ok, %{status: 200, headers: [], body: config_to_eu}} end) |> expect(:get, 1, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_global}} + {:ok, %{status: 200, headers: [], body: config_to_global}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) MockAPI |> expect(:get, 1, fn ^eu_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_global}} + {:ok, %{status: 200, headers: [], body: config_to_global}} end) |> expect(:get, 1, fn ^global_url, _headers, _options -> - {:ok, %Response{status_code: 200, body: config_to_eu}} + {:ok, %{status: 200, headers: [], body: config_to_eu}} end) assert {:ok, _} = ConfigFetcher.fetch(fetcher, nil) diff --git a/test/support/cache_policy_case.ex b/test/support/cache_policy_case.ex index b2345f4b..a7f9de5a 100644 --- a/test/support/cache_policy_case.ex +++ b/test/support/cache_policy_case.ex @@ -14,7 +14,6 @@ defmodule ConfigCat.CachePolicyCase do alias ConfigCat.Hooks alias ConfigCat.InMemoryCache alias ConfigCat.MockFetcher - alias HTTPoison.Response using do quote do @@ -112,7 +111,7 @@ defmodule ConfigCat.CachePolicyCase do @spec assert_returns_error(function()) :: true def assert_returns_error(force_refresh_fn) do - response = %Response{status_code: 503} + response = %{status: 503, body: "", headers: []} error = FetchError.exception(reason: response, transient?: true) stub(MockFetcher, :fetch, fn _id, _etag -> {:error, error} end) diff --git a/test/support/mocks.ex b/test/support/mocks.ex index 6b64119b..257b2b92 100644 --- a/test/support/mocks.ex +++ b/test/support/mocks.ex @@ -1,4 +1,4 @@ -Mox.defmock(ConfigCat.MockAPI, for: HTTPoison.Base) +Mox.defmock(ConfigCat.MockAPI, for: ConfigCat.HTTPClient) Mox.defmock(ConfigCat.MockCachePolicy, for: ConfigCat.CachePolicy.Behaviour) Mox.defmock(ConfigCat.MockConfigCache, for: ConfigCat.ConfigCache) Mox.defmock(ConfigCat.MockFetcher, for: ConfigCat.ConfigFetcher)