defmodule Salemove.HttpClient do
@moduledoc """
Generic HTTP client built on top of Tesla and used to build specific JSON API HTTP clients
with ability to configure them during runtime, using, for example, environment variables.
## Example
defmodule Github do
use Salemove.HttpClient, base_url: "https://api.github.com/"
def user_repos(login, opts \\ []) do
get("/user/" <> login <> "/repos", opts)
end
end
Github.user_repos("take-five")
## Configuration options
There are number of available configuration options:
* `:base_url` - Base URL of service (including schema, i.e. `https://api.github.com/`)
* `:adapter` - HTTP Adapter module, defaults to `Tesla.Adapter.Finch` backed by a
Finch pool started by this library (named `Salemove.HttpClient.Finch`, configurable
via the `:finch_pools` application environment - see `Salemove.HttpClient.Application`)
* `:adapter_options` - adapter specific options, see documentation for concrete adapter
* `:json` - JSON encoding/decoding options. If omitted, default options are used - see `Tesla.Middleware.JSON`.
If set to `false`, request body is sent as application/x-www-form-urlencoded not JSON.
* `:decode_json` - whether to JSON-decode the response body. Defaults to `true`. When set to
`false`, the response body is returned as a raw string instead of being decoded into a map.
* `:retry` - Retry few times in case of connection refused error. See `Tesla.Middleware.Retry`.
* `:stats` - StatsD instrumenting options. The `:tesla_statsd` dependency
must be included in your service to use this option. See `Tesla.StatsD`
for more details.
* `:username` - along with `:password` option adds basic authentication to all requests.
See `Tesla.Middleware.BasicAuth`.
* `:password` - see `:username`.
* `:log` - Logging options, see `Salemove.HttpClient.Middleware.Logger`
* `:proxy` - HTTP(S) proxy URL. Only supported by `Tesla.Adapter.Hackney`; with the
default Finch adapter, configure the proxy on the Finch pool instead (see
`Salemove.HttpClient.Middleware.Proxy`)
HTTP client can be configured at runtime and at compile time via configuration files. Note,
that you can use `{:system, env_name}` tuples to configure the client
### Configuration via request options
You can pass additional `Keyword` argument to request functions:
Github.user_repos("take-five", adapter: Tesla.Mock, base_url: "http://mocked-gh/")
### Configuration via config files
In `config/config.exs`:
config :salemove_http_client,
adapter: Tesla.Mock,
base_url: "http://mocked-gh/"
"""
@http_verbs ~w(head get delete trace options post put patch)a
defmacro __using__(defaults_options \\ []) do
friendly_api = Enum.map(@http_verbs, &generate_http_verb/1)
quote do
alias Salemove.HttpClient
@type url :: HttpClient.url()
@type body :: HttpClient.body()
@type options :: HttpClient.options()
@type response :: HttpClient.response()
@doc false
@spec request(options) :: response
def request(options) do
unquote(defaults_options)
|> Keyword.merge(options)
|> HttpClient.perform_request()
end
unquote(friendly_api)
end
end
alias Salemove.HttpClient.Decoder
@typedoc "Client or request-specific options"
@type options :: Keyword.t()
@type response :: Decoder.on_decode()
@type url :: Tesla.Env.url()
@type body :: Tesla.Env.body()
@doc false
@spec default_adapter() :: module()
def default_adapter, do: Tesla.Adapter.Finch
@doc false
@spec perform_request(options) :: response
def perform_request(options) do
options = Confex.Resolver.resolve!(options)
options
|> build_client()
|> Tesla.request(options)
|> Decoder.decode()
end
defp build_client(options) do
application_defaults()
|> Keyword.merge(options, &deep_merge/3)
|> Confex.Resolver.resolve!()
|> build_stack()
|> Tesla.client(Salemove.HttpClient.Adapter)
end
defp application_defaults do
app_env = Keyword.delete(Application.get_all_env(:salemove_http_client), :finch_pools)
Keyword.merge(library_defaults(), app_env)
end
defp library_defaults do
[
adapter: default_adapter(),
adapter_options: [
name: Salemove.HttpClient.Finch,
receive_timeout: 4500
],
retry: false
]
end
defp build_stack(options) do
encode_json_enabled = Keyword.get(options, :json, true)
decode_json_enabled = Keyword.get(options, :decode_json, true)
stats_enabled = Keyword.get(options, :stats, false)
if stats_enabled, do: Code.ensure_loaded!(Tesla.StatsD)
[]
|> push_middleware(Salemove.HttpClient.Middleware.MapHeaders)
# Retry has to come before observability middlewares for all attempts to be
# recorded separately.
|> push_middleware({Tesla.Middleware.Retry, options[:retry]}, if: options[:retry])
# BaseUrl has to come before observability and proxy middlewares. This
# allows them to work with the full URL.
|> push_middleware({Tesla.Middleware.BaseUrl, Keyword.fetch!(options, :base_url)})
|> push_middleware(Tesla.Middleware.KeepRequest)
|> push_middleware(Tesla.Middleware.Telemetry)
|> push_middleware(Tesla.Middleware.OpenTelemetry, if: opentelemetry_enabled?(options))
|> push_middleware({Tesla.StatsD, options[:stats]}, if: stats_enabled)
|> push_middleware(Tesla.Middleware.PathParams)
|> push_middleware({Salemove.HttpClient.Middleware.Proxy, options})
|> push_middleware(Tesla.Middleware.FormUrlencoded, if: !encode_json_enabled)
|> push_middleware({Tesla.Middleware.EncodeJson, options[:json]}, if: encode_json_enabled)
|> push_middleware({Tesla.Middleware.DecodeJson, options[:json]}, if: decode_json_enabled)
|> push_middleware(
{Tesla.Middleware.BasicAuth, options},
if: options[:username] && options[:password]
)
|> push_middleware({Salemove.HttpClient.Middleware.Logger, options[:log]})
|> Enum.reverse()
end
if Code.ensure_loaded?(Tesla.Middleware.OpenTelemetry) do
defp opentelemetry_enabled?(options) do
Keyword.get(options, :opentelemetry, true)
end
else
defp opentelemetry_enabled?(_), do: false
end
defp push_middleware(stack, middleware, [if: condition] \\ [if: true]) do
if condition do
[middleware | stack]
else
stack
end
end
defp generate_http_verb(verb) when verb in [:post, :put, :patch] do
quote do
@doc """
Perform a #{unquote(verb |> to_string() |> String.upcase())} request.
See `HttpClient.request/2` for available options.
"""
@spec unquote(verb)(url, body, options) :: response
def unquote(verb)(url, body, options \\ []) do
options
|> Keyword.merge(url: url, body: body, method: unquote(verb))
|> request()
end
end
end
defp generate_http_verb(verb) do
quote do
@doc """
Perform a #{unquote(verb |> to_string() |> String.upcase())} request.
See `HttpClient.request/2` for available options.
"""
@spec unquote(verb)(url, options) :: response
def unquote(verb)(url, options \\ []) do
options
|> Keyword.merge(url: url, method: unquote(verb))
|> request()
end
end
end
defp deep_merge(_key, value1, value2) when is_list(value1) and is_list(value2) do
if Keyword.keyword?(value1) and Keyword.keyword?(value2) do
Keyword.merge(value1, value2, &deep_merge/3)
else
value2
end
end
defp deep_merge(_key, _value1, value2) do
value2
end
end