defmodule IndieWeb.Http do @default_retry_time 10 defmodule Error do @moduledoc "Defines an error obtained when making a network request." @enforce_keys ~w(message)a @typedoc "Representative type of errors with `IndieWeb.Http`." @type t :: %__MODULE__{message: any(), raw: any()} defstruct ~w(message raw)a end defmodule Response do @moduledoc "Defines a response obtained when making a network request." @enforce_keys ~w(code body url headers raw)a defstruct ~w(body code url headers rels raw)a @type t :: %__MODULE__{ body: String.t(), code: non_neg_integer, url: String.t(), headers: Access.t(), rels: map(), raw: any() } end defmodule Client do @moduledoc false use Tesla adapter(Tesla.Adapter.Hackney, timeout: 30_000) plug(Tesla.Middleware.Logger) plug(Tesla.Middleware.FollowRedirects) plug(Tesla.Middleware.KeepRequest) plug(Tesla.Middleware.MethodOverride) plug(Tesla.Middleware.DecodeRels) plug(Tesla.Middleware.Headers, [ {"user-agent", IndieWeb.Http.user_agent()} ]) end def user_agent() do version = Application.spec(:indieweb, :vsn) "IndieWeb-Elixir/#{version} (https://git.jacky.wtf/indieweb/elixir/tree/v#{version})" end def make_absolute_uri(path, _) when path in ["", nil] do path end def make_absolute_uri(path, base_uri) when path == base_uri and is_binary(path) do path end def make_absolute_uri(path, base_uri) when is_binary(path) do base_uri |> URI.merge(path) |> URI.to_string() end def extract_link_header_values(%__MODULE__.Response{} = resp) do resp |> Map.get(:raw, %{}) |> Map.get(:opts, []) |> Keyword.get(:rels, %{}) || %{} end def request(url, method \\ :get, opts \\ []) def request(url, method, opts) when is_binary(url) do case __MODULE__.Client.request([url: url, method: method] ++ opts) do {:ok, %Tesla.Env{} = env} -> {:ok, %__MODULE__.Response{ raw: env, code: env.status, body: env.body, rels: env.opts[:rels] || %{}, headers: env.headers, url: env.url }} {:error, error} -> {:error, %__MODULE__.Error{message: error, raw: nil}} end end def request(_url, _method, _opts), do: {:error, :no_url_provided} for method <- ~w(get post options head put patch delete)a do @doc """ Sends a #{String.upcase(Atom.to_string(method))} request to the specified URL. See `request/3` for more information about making requests. """ def unquote(method)(url, opts \\ []), do: __MODULE__.request(url, unquote(method), opts) end def post_encoded(url, opts) do post( url, opts ++ [ body: URI.encode_query(opts[:body]), headers: Keyword.get(opts, :headers, []) ++ [{"Content-Type", "application/x-www-form-urlencoded"}] ] ) end # TODO: Check the docs around 'Retry-After' to determine the possible formats. @doc "Determines how many seconds from now does one have to wait to retry a request." @spec extract_retry_time(IndieWeb.Http.Response.t()) :: integer() def extract_retry_time(resp) do resp.headers |> Enum.find_value(0, fn {"Retry-After", count} -> if String.contains?(count, ":") do case Calendar.DateTime.Parse.httpdate!(count) do {:ok, retry_dt} -> DateTime.diff(retry_dt, DateTime.utc_now(), :second) _ -> @default_retry_time end else case Integer.parse(count) do {seconds, _} -> seconds _ -> @default_retry_time end end _ -> nil end) end end defimpl String.Chars, for: IndieWeb.Http.Response do def to_string(resp) do Jason.encode!(%{ "body" => resp.body, "headers" => Map.new(resp.headers), "url" => resp.url, "code" => resp.code, "rels" => resp.rels }) end end