defmodule Plausible.HTTPClient.Non200Error do @moduledoc false defstruct reason: nil @type t :: %__MODULE__{reason: Finch.Response.t()} @spec new(Finch.Response.t()) :: t() def new(%Finch.Response{status: status} = response) when is_integer(status) and (status < 200 or status >= 300) do %__MODULE__{reason: response} end end defmodule Plausible.HTTPClient.Interface do @moduledoc false @type t() :: module() @type finch_request_opts() :: Keyword.t() @type url() :: Finch.Request.url() @type headers() :: Finch.Request.headers() @type params() :: Finch.Request.body() | map() @type response() :: {:ok, Finch.Response.t()} | {:error, Finch.error() | Plausible.HTTPClient.Non200Error.t()} @callback get(url(), headers(), params()) :: response() @callback get(url(), headers()) :: response() @callback get(url()) :: response() @callback post(url(), headers(), params()) :: response() @callback post(url(), headers(), params(), finch_request_opts()) :: response() end defmodule Plausible.HTTPClient do @moduledoc """ HTTP Client built on top of Finch. By default, request parameters are json-encoded. If a raw binary value is supplied, no encoding is performed. If x-www-form-urlencoded content-type is set in headers, URL encoding is invoked. """ require OpenTelemetry.Tracer alias Plausible.HTTPClient.Non200Error @doc """ Make a POST request """ @behaviour Plausible.HTTPClient.Interface @impl Plausible.HTTPClient.Interface def post(url, headers \\ [], params \\ nil, finch_req_opts \\ []) do call(:post, url, headers, params, finch_req_opts) end @doc """ Make a GET request """ @impl Plausible.HTTPClient.Interface def get(url, headers \\ [], params \\ nil) do call(:get, url, headers, params) end @spec impl() :: Plausible.HTTPClient.Interface.t() def impl() do Application.get_env(:plausible, :http_impl, __MODULE__) end defp call(method, url, headers, params, finch_req_opts \\ []) do OpenTelemetry.Tracer.with_span "http_client.request" do {params, headers} = maybe_encode_params(params, headers) method |> build_request(url, headers, params) |> trace_request() |> do_request(finch_req_opts) |> maybe_decode_body() |> tag_error() |> trace_response() end end defp build_request(method, url, headers, params) do Finch.build(method, url, headers, params) end defp do_request(request, finch_req_opts) do Finch.request(request, Plausible.Finch, finch_req_opts) end defp maybe_encode_params(params, headers) when is_binary(params) or is_nil(params) do {params, headers} end defp maybe_encode_params(params, headers) when is_map(params) do content_type = Enum.find_value(headers, "", fn {k, v} -> if String.downcase(k) == "content-type" do v end end) case String.downcase(content_type) do "application/x-www-form-urlencoded" -> {URI.encode_query(params), headers} "application/json" -> {Jason.encode!(params), headers} _ -> {Jason.encode!(params), [{"content-type", "application/json"} | headers]} end end defp tag_error({:ok, %Finch.Response{status: status}} = ok) when is_integer(status) and status >= 200 and status < 300 do ok end defp tag_error({:ok, %Finch.Response{status: _} = response}) do {:error, Non200Error.new(response)} end defp tag_error({:error, _} = error) do error end defp maybe_decode_body({:ok, %{headers: headers, body: body} = resp}) when is_binary(body) and body != "" do if json?(headers) do {:ok, update_in(resp.body, &Jason.decode!/1)} else {:ok, resp} end end defp maybe_decode_body(response), do: response defp json?(headers) do found = Enum.find(headers, fn {"content-type", "application/json" <> _} -> true _ -> false end) is_tuple(found) end defp trace_request(%Finch.Request{} = request) do OpenTelemetry.Tracer.set_attributes([ {"http_client.request.host", request.host}, {"http_client.request.method", request.method}, {"http_client.request.path", request.path} ]) request end defp trace_response(response) do case response do {:ok, %{status: status}} -> OpenTelemetry.Tracer.set_attributes([ {"http_client.response.status", status} ]) {:error, %Non200Error{reason: %{status: status}}} -> OpenTelemetry.Tracer.set_attributes([ {"http_client.request.failure", "non_200"}, {"http_client.response.status", status} ]) {:error, exception} when is_exception(exception) -> OpenTelemetry.Tracer.set_attributes([ {"http_client.request.failure", Exception.message(exception)} ]) _any -> :skip end response end end