lib/chainweb/request.ex

defmodule Kadena.Chainweb.Request do
  @moduledoc """
  A module to work with Chainweb requests.
  Requests are composed in a functional manner.
  The request does not happen until it is configured and passed to `perform/1`.
  """

  alias Kadena.Chainweb.{Client, Error, Network}

  @type api_type :: :pact | :p2p
  @type network_id :: :testnet04 | :mainnet01
  @type chain_id :: 0..19 | String.t() | nil
  @type method :: :get | :post | :put
  @type headers :: [{binary(), binary()}]
  @type body :: String.t() | nil
  @type query :: Keyword.t()
  @type encoded_query :: String.t() | nil
  @type endpoint :: String.t() | nil
  @type path :: String.t() | nil
  @type segment :: String.t() | nil
  @type opts :: Keyword.t()
  @type params :: Keyword.t()
  @type response :: {:ok, map()} | {:error, Error.t()}
  @type parsed_response :: {:ok, struct()} | {:error, Error.t()}
  @type location :: String.t() | nil

  @type t :: %__MODULE__{
          method: method(),
          api_type: api_type(),
          network_id: network_id(),
          chain_id: chain_id(),
          endpoint: endpoint(),
          path: path(),
          segment: segment(),
          segment_path: path(),
          query: query(),
          headers: headers(),
          encoded_query: encoded_query(),
          location: location(),
          body: body()
        }

  defstruct [
    :method,
    :api_type,
    :network_id,
    :chain_id,
    :endpoint,
    :path,
    :segment,
    :segment_path,
    :query,
    :headers,
    :encoded_query,
    :location,
    body: ""
  ]

  @spec new(method :: method(), opts :: opts()) :: t()
  def new(method, [{api_type, opts}]) when api_type in [:pact, :p2p] do
    network_id = Keyword.get(opts, :network_id)
    chain_id = Keyword.get(opts, :chain_id)
    endpoint = Keyword.get(opts, :endpoint)
    path = Keyword.get(opts, :path)
    segment = Keyword.get(opts, :segment)
    segment_path = Keyword.get(opts, :segment_path)

    %__MODULE__{
      method: method,
      api_type: api_type,
      network_id: network_id,
      chain_id: chain_id,
      endpoint: endpoint,
      path: path,
      segment: segment,
      segment_path: segment_path,
      query: [],
      headers: []
    }
  end

  @spec set_location(t(), location :: location()) :: t()
  def set_location(%__MODULE__{} = request, location), do: %{request | location: location}

  @spec set_chain_id(t(), chain_id :: chain_id()) :: t()
  def set_chain_id(%__MODULE__{} = request, chain_id), do: %{request | chain_id: chain_id}

  @spec set_network(t(), network :: network_id()) :: t()
  def set_network(%__MODULE__{} = request, network), do: %{request | network_id: network}

  @spec add_body(request :: t(), body :: body()) :: t()
  def add_body(%__MODULE__{} = request, body), do: %{request | body: body}

  @spec add_headers(request :: t(), headers :: headers()) :: t()
  def add_headers(%__MODULE__{} = request, headers), do: %{request | headers: headers}

  @spec add_query(request :: t(), params :: params()) :: t()
  def add_query(%__MODULE__{} = request, params),
    do: %{request | query: params, encoded_query: build_query_string(params)}

  @spec perform(request :: t()) :: response()
  def perform(%__MODULE__{method: method, headers: headers, body: body} = request) do
    request
    |> build_request_url()
    |> (&Client.request(method, &1, headers, body)).()
  end

  @spec results(response :: response(), opts :: opts()) :: parsed_response()
  def results({:ok, results}, as: resource), do: {:ok, resource.new(results)}
  def results({:error, error}, _resource), do: {:error, error}

  @spec build_request_url(request :: t()) :: binary()
  defp build_request_url(
         %__MODULE__{
           endpoint: endpoint,
           path: path,
           segment: segment,
           segment_path: segment_path,
           encoded_query: encoded_query
         } = request
       ) do
    base_url = Network.base_url(request)

    IO.iodata_to_binary([
      if(base_url, do: base_url, else: []),
      if(endpoint, do: ["/" | to_string(endpoint)], else: []),
      if(path, do: ["/" | to_string(path)], else: []),
      if(segment, do: ["/" | to_string(segment)], else: []),
      if(segment_path, do: ["/" | to_string(segment_path)], else: []),
      if(encoded_query, do: ["?" | encoded_query], else: [])
    ])
  end

  @spec build_query_string(params :: params()) :: encoded_query()
  defp build_query_string(params) do
    params
    |> Enum.reject(&is_empty_param/1)
    |> encode_query()
  end

  @spec encode_query(query :: query()) :: encoded_query()
  defp encode_query([]), do: nil
  defp encode_query(query), do: URI.encode_query(query)

  @spec is_empty_param(param :: {atom(), any()}) :: boolean()
  defp is_empty_param({_key, nil}), do: true
  defp is_empty_param({_key, value}), do: to_string(value) == ""
end