lib/chaperon/action/websocket/connect.ex

defmodule Chaperon.Action.WebSocket.Connect do
  @moduledoc """
  WebSocket connection action that needs to be run in order for a
  `Chaperon.Session` to be successfully connected to a web server via WebSocket.

  Assigns `websocket` and `websocket_url` values to a session when run,
  which are used by the remaining websocket actions found under
  `Chaperon.Action.WebSocket`.
  The stored values are accessible via `session.assigned.websocket` &
  `session.assigned.websocket_url`.
  """

  defstruct path: nil,
            options: []

  def url(action, session) do
    case Chaperon.Action.HTTP.url(action, session) do
      "https" <> rest ->
        "wss" <> rest

      "http" <> rest ->
        "ws" <> rest

      "ws" <> _ = ws_url ->
        ws_url
    end
  end
end

defimpl Chaperon.Actionable, for: Chaperon.Action.WebSocket.Connect do
  alias Chaperon.Session
  alias Chaperon.Action.WebSocket
  alias Chaperon.Action.WebSocket.Connect
  import Chaperon.Timing, only: [seconds: 1]
  use Chaperon.Session.Logging

  def run(action, session) do
    ws_url = Connect.url(action, session)

    session
    |> log_info("WS_CONN #{ws_url}")

    timeout = Session.timeout(session)
    async_connect(session, ws_url)

    receive do
      {:ws_connected, ws_client, ^ws_url} ->
        session
        |> WebSocket.assign_for_action(action, ws_client, ws_url)
        |> Session.ok()

      {:ws_closed, ^ws_url} ->
        session
        |> log_error("Failed to connect via WS to #{ws_url} - Connection closed remotely")

      {:ws_failed, ^ws_url, error = %WebSockex.RequestError{code: code, message: message}} ->
        session
        |> log_error(
          "Failed to connect via WS to #{ws_url} - Failed connection request response: #{code} : #{
            message
          }"
        )
        |> Session.error({:ws_failed, ws_url, error})
    after
      timeout ->
        session
        |> log_info("Timeout while connecting via WS to #{ws_url}")
        |> Session.error({:timeout, :ws_conn, ws_url, timeout})
    end
  end

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

  def async_connect(session, ws_url) do
    parent = self()

    spawn_link(fn ->
      session
      |> connection_attempt_loop(ws_url, parent)
    end)
  end

  defp connection_attempt_loop(session, ws_url, parent) do
    case WebSocket.Client.start_link(session, ws_url) do
      {:ok, ws_client} ->
        send(parent, {:ws_connected, ws_client, ws_url})

      {:error, %WebSockex.ConnError{original: :closed}} ->
        send(parent, {:ws_closed, ws_url})

      {:error, %WebSockex.ConnError{original: :timeout}} ->
        session
        |> log_warn("Failed to connect via WS to #{ws_url} - TIMEOUT")
        |> Session.random_delay(
          session
          |> Session.config("ws.connect_timeout", 3 |> seconds)
        )
        |> connection_attempt_loop(ws_url, parent)

      {:error, req_err = %WebSockex.RequestError{}} ->
        send(parent, {:ws_failed, ws_url, req_err})
    end
  end
end

defimpl String.Chars, for: Chaperon.Action.WebSocket.Connect do
  def to_string(%{path: path}), do: "WS Connect[#{path}]"
end