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