File size: 4,983 Bytes
8da2481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
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