lib/chaperon/action/http.ex

defmodule Chaperon.Action.HTTP do
  @moduledoc """
  HTTP based actions to be run in a `Chaperon.Scenario` module for a given
  `Chaperon.Session`.

  This supports `GET`, `POST`, `PUT`, `PATCH`, `DELETE` & `HEAD` requests with
  support for optional headers & query params.
  """

  defstruct method: :get,
            path: nil,
            headers: %{},
            params: %{},
            body: nil,
            decode: nil,
            callback: nil,
            metrics_url: nil

  @type method :: :get | :post | :put | :patch | :delete | :head

  @type options :: [
          form: map | Keyword.t(),
          json: map | Keyword.t(),
          headers: map | Keyword.t(),
          params: map | Keyword.t(),
          decode: :json | (HTTPoison.Response.t() -> any),
          with_result: Chaperon.Session.result_callback(),
          metrics_url: String.t()
        ]

  @type t :: %Chaperon.Action.HTTP{
          method: method,
          path: String.t(),
          headers: map,
          params: map,
          body: binary,
          decode: :json | (HTTPoison.Response.t() -> any),
          callback: Chaperon.Session.result_callback(),
          metrics_url: String.t()
        }

  @spec get(String.t(), options) :: t
  def get(path, opts) do
    %Chaperon.Action.HTTP{
      method: :get,
      path: path
    }
    |> add_options(opts)
  end

  @spec post(String.t(), options) :: t
  def post(path, opts) do
    %Chaperon.Action.HTTP{
      method: :post,
      path: path
    }
    |> add_options(opts)
  end

  @spec put(String.t(), options) :: t
  def put(path, opts) do
    %Chaperon.Action.HTTP{
      method: :put,
      path: path
    }
    |> add_options(opts)
  end

  @spec patch(String.t(), options) :: t
  def patch(path, opts) do
    %Chaperon.Action.HTTP{
      method: :patch,
      path: path
    }
    |> add_options(opts)
  end

  @spec delete(String.t(), options) :: t
  def delete(path, opts \\ []) do
    %Chaperon.Action.HTTP{
      method: :delete,
      path: path
    }
    |> add_options(opts)
  end

  alias __MODULE__
  alias Chaperon.Session

  def url(%{path: ""}, %Session{config: %{base_url: base_url}}) do
    base_url <> "/"
  end

  def url(%{path: path}, %Session{config: %{base_url: base_url}}) do
    if is_full_url?(path) do
      path
    else
      base_url <> path
    end
  end

  def url(%{path: path}, _) do
    path
  end

  def is_full_url?("http://" <> _), do: true
  def is_full_url?("https://" <> _), do: true
  def is_full_url?("ws://" <> _), do: true
  def is_full_url?("wss://" <> _), do: true
  def is_full_url?(_), do: false

  def full_url(action = %HTTP{method: method, params: params}, session) do
    url = url(action, session)

    case method do
      :get -> url <> query_params_string(params)
      _ -> url
    end
  end

  def metrics_url(%{metrics_url: metrics_url}, %Session{config: %{base_url: base_url}})
      when not is_nil(metrics_url) do
    base_url <> metrics_url
  end

  def metrics_url(action, session) do
    if session.config[:skip_query_params_in_metrics] do
      action
      |> url(session)
    else
      action
      |> full_url(session)
    end
  end

  def full_path(%{path: path, params: params}), do: path <> query_params_string(params)

  def query_params_string([]), do: ""

  def query_params_string(params) do
    case URI.encode_query(params) do
      "" -> ""
      q -> "?" <> q
    end
  end

  def options(action, session) do
    opts =
      session.config
      |> Map.get(:http, %{})
      |> Enum.into([])
      |> Keyword.merge(params: action.params)

    case hackney_opts(action, session) do
      [] ->
        opts

      hackney_opts ->
        opts
        |> Keyword.merge(hackney: hackney_opts)
    end
  end

  @default_headers %{
    "User-Agent" => "chaperon",
    "Accept" => "*/*"
  }

  @spec add_options(any, Chaperon.Action.HTTP.options()) :: t
  def add_options(action, opts) do
    alias Keyword, as: KW
    import Map, only: [merge: 2]

    headers = opts[:headers] || %{}
    params = opts[:params] || %{}
    decode = opts[:decode]
    callback = opts[:with_result]
    metrics_url = opts[:metrics_url]

    {new_headers, body} =
      opts
      |> KW.delete(:headers)
      |> KW.delete(:params)
      |> KW.delete(:decode)
      |> KW.delete(:with_result)
      |> KW.delete(:metrics_url)
      |> parse_body

    headers =
      action.headers
      |> merge(@default_headers)
      |> merge(headers)
      |> merge(new_headers)

    %{
      action
      | headers: headers,
        params: params,
        body: body,
        decode: decode,
        callback: callback,
        metrics_url: metrics_url
    }
  end

  defp hackney_opts(_action, session) do
    opts = [
      cookie: session.cookies,
      basic_auth: session.config[:basic_auth],
      pool: :chaperon,
      insecure: session.config[:insecure]
    ]

    opts
    |> Enum.map(&hackney_opt/1)
    |> Enum.reject(&is_nil/1)
  end

  # don't pass if no value set
  defp hackney_opt({_key, nil}), do: nil
  # don't pass empty list of cookies
  defp hackney_opt({:cookie, []}), do: nil
  # pass everything else as hackney option
  defp hackney_opt(opt), do: opt

  defp parse_body([]), do: {%{}, ""}

  defp parse_body(json: data) when is_list(data) do
    data =
      if Keyword.keyword?(data) do
        data |> Enum.into(%{})
      else
        data
      end

    data
    |> json_body
  end

  defp parse_body(json: data), do: data |> json_body
  defp parse_body(form: data), do: data |> form_body

  defp json_body(data) do
    {
      %{"Content-Type" => "application/json", "Accept" => "application/json"},
      data |> Poison.encode!()
    }
  end

  defp form_body(data) do
    {
      %{"Content-Type" => "application/x-www-form-urlencoded"},
      data |> URI.encode_query()
    }
  end
end

defimpl Chaperon.Actionable, for: Chaperon.Action.HTTP do
  alias Chaperon.Action.Error
  alias Chaperon.Action.HTTP
  import Chaperon.Timing
  import Chaperon.Session
  use Chaperon.Session.Logging

  def run(action, session) do
    full_url = HTTP.full_url(action, session)

    session
    |> log_info("#{action.method |> to_string |> String.upcase()} #{full_url}")

    start = timestamp()

    case HTTPoison.request(
           action.method,
           HTTP.url(action, session),
           action.body || "",
           action.headers,
           HTTP.options(action, session)
         ) do
      {:ok, response} ->
        session
        |> add_result(action, response)
        |> add_request_metrics(action, response, timestamp() - start)
        |> store_response_cookies(response)
        |> run_callback_if_defined(action, response)
        |> ok

      {:error, reason} ->
        session
        |> log_error("HTTP action #{action} failed")

        session =
          session
          |> run_error_callback(action, reason)

        {:error, %Error{reason: reason, action: action, session: session}}
    end
  end

  defp add_request_metrics(
         session,
         action,
         _response = %HTTPoison.Response{status_code: status_code},
         duration
       ) do
    metrics_url = HTTP.metrics_url(action, session)

    session =
      session
      |> add_metric({action.method, metrics_url}, duration)

    case status_code do
      c when c < 400 ->
        session

      c when c in 400..599 ->
        session
        |> add_metric({{:error, {:http, c}}, {action.method, metrics_url}}, duration)
    end
  end

  def run_callback_if_defined(session, action, response) do
    case response.status_code do
      code when code in 200..399 ->
        session
        |> log_debug("HTTP Response #{action} : #{code}")
        |> run_callback(action, response)

      code ->
        session
        |> log_warn("HTTP Response #{action} failed with status code: #{code}")
        |> log_warn(response.body)
        |> run_error_callback(action, response)
    end
  end

  def abort(action, session) do
    # TODO
    {:ok, action, session}
  end
end

defimpl String.Chars, for: Chaperon.Action.HTTP do
  alias Chaperon.Action.HTTP

  @methods [:get, :post, :put, :patch, :delete, :head]
  @method_strings @methods
                  |> Enum.map(&{&1, &1 |> Kernel.to_string() |> String.upcase()})
                  |> Enum.into(%{})

  def to_string(http) do
    "#{@method_strings[http.method]} #{HTTP.full_url(http, %{})}"
  end
end