lib/gsmlg/socket/tcp.ex

defmodule GSMLG.Socket.TCP do
  @moduledoc """
  This module wraps a passive TCP socket using `gen_tcp`.

  ## Options

  When creating a socket you can pass a series of options to use for it.

  * `:as` sets the kind of value returned by recv, either `:binary` or `:list`,
    the default is `:binary`
  * `:mode` can be either `:passive` or `:active`, default is `:passive`
  * `:local` must be a keyword list
    - `:address` the local address to use
    - `:port` the local port to use
    - `:fd` an already opened file descriptor to use
  * `:backlog` sets the listen backlog
  * `:watermark` must be a keyword list
    - `:low` defines the `:low_watermark`, see `inet:setopts`
    - `:high` defines the `:high_watermark`, see `inet:setopts`
  * `:version` sets the IP version to use
  * `:options` must be a list of atoms
    - `:keepalive` sets `SO_KEEPALIVE`
    - `:nodelay` sets `TCP_NODELAY`
  * `:packet` see `inet:setopts`
  * `:size` sets the max length of the packet body, see `inet:setopts`

  ## Examples

      server = GSMLG.Socket.TCP.listen!(1337, packet: :line)

      client = server |> GSMLG.Socket.accept!
      client |> GSMLG.Socket.Stream.send!(client |> GSMLG.Socket.Stream.recv!)
      client |> GSMLG.Socket.Stream.close

  """

  use GSMLG.Socket.Helpers
  require Record

  @opaque t :: port

  @doc """
  Return a proper error string for the given code or nil if it can't be
  converted.
  """
  @spec error(term) :: String.t()
  def error(code) do
    case :inet.format_error(code) do
      'unknown POSIX error' ->
        nil

      message ->
        message |> to_string
    end
  end

  @doc """
  Create a TCP socket connecting to the given host and port tuple.
  """
  @spec connect({GSMLG.Socket.Address.t(), :inet.port_number()}) ::
          {:ok, t} | {:error, GSMLG.Socket.Error.t()}
  def connect({address, port}) do
    connect(address, port)
  end

  @doc """
  Create a TCP socket connecting to the given host and port tuple, raising if
  an error occurs.
  """
  @spec connect!({GSMLG.Socket.Address.t(), :inet.port_number()}) :: t | no_return
  defbang(connect(descriptor))

  @doc """
  Create a TCP socket connecting to the given host and port tuple and options,
  or to the given host and port.
  """
  @spec connect(
          {GSMLG.Socket.Address.t(), :inet.port_number()} | GSMLG.Socket.Address.t(),
          Keyword.t() | :inet.port_number()
        ) :: {:ok, t} | {:error, GSMLG.Socket.Error.t()}
  def connect({address, port}, options) when options |> is_list do
    connect(address, port, options)
  end

  def connect(address, port) when port |> is_integer do
    connect(address, port, [])
  end

  @doc """
  Create a TCP socket connecting to the given host and port tuple and options,
  or to the given host and port, raising if an error occurs.
  """
  @spec connect!(
          {GSMLG.Socket.Address.t(), :inet.port_number()} | GSMLG.Socket.Address.t(),
          Keyword.t() | :inet.port_number()
        ) :: t | no_return
  defbang(connect(address, port))

  @doc """
  Create a TCP socket connecting to the given host and port.
  """
  @spec connect(String.t() | :inet.ip_address(), :inet.port_number(), Keyword.t()) ::
          {:ok, t} | {:error, GSMLG.Socket.Error.t()}
  def connect(address, port, options) when address |> is_binary do
    timeout = options[:timeout] || :infinity
    options = Keyword.delete(options, :timeout)

    :gen_tcp.connect(String.to_charlist(address), port, arguments(options), timeout)
  end

  @doc """
  Create a TCP socket connecting to the given host and port, raising in case of
  error.
  """
  @spec connect!(String.t() | :inet.ip_address(), :inet.port_number(), Keyword.t()) ::
          t | no_return
  defbang(connect(address, port, options))

  @doc """
  Create a TCP socket listening on an OS chosen port, use `local` to know the
  port it was bound on.
  """
  @spec listen :: {:ok, t} | {:error, GSMLG.Socket.Error.t()}
  def listen do
    listen(0, [])
  end

  @doc """
  Create a TCP socket listening on an OS chosen port, use `local` to know the
  port it was bound on, raising in case of error.
  """
  @spec listen! :: t | no_return
  defbang(listen)

  @doc """
  Create a TCP socket listening on an OS chosen port using the given options or
  listening on the given port.
  """
  @spec listen(:inet.port_number() | Keyword.t()) :: {:ok, t} | {:error, GSMLG.Socket.Error.t()}
  def listen(port) when port |> is_integer do
    listen(port, [])
  end

  def listen(options) when options |> is_list do
    listen(0, options)
  end

  @doc """
  Create a TCP socket listening on an OS chosen port using the given options or
  listening on the given port, raising in case of error.
  """
  @spec listen!(:inet.port_number() | Keyword.t()) :: t | no_return
  defbang(listen(port_or_options))

  @doc """
  Create a TCP socket listening on the given port and using the given options.
  """
  @spec listen(:inet.port_number(), Keyword.t()) :: {:ok, t} | {:error, GSMLG.Socket.Error.t()}
  def listen(port, options) when options |> is_list do
    options =
      options
      |> Keyword.put(:mode, :passive)
      |> Keyword.put_new(:reuse, true)

    :gen_tcp.listen(port, arguments(options))
  end

  @doc """
  Create a TCP socket listening on the given port and using the given options,
  raising in case of error.
  """
  @spec listen!(:inet.port_number(), Keyword.t()) :: t | no_return
  defbang(listen(port, options))

  @doc """
  Accept a new client from a listening socket, optionally passing options.
  """
  @spec accept(t | port) :: {:ok, t} | {:error, Error.t()}
  @spec accept(t | port, Keyword.t()) :: {:ok, t} | {:error, Error.t()}
  def accept(socket, options \\ []) do
    timeout = options[:timeout] || :infinity

    case :gen_tcp.accept(socket, timeout) do
      {:ok, socket} ->
        # XXX: the error code here is not checked
        case options[:mode] do
          :active ->
            :inet.setopts(socket, active: true)

          :once ->
            :inet.setopts(socket, active: :once)

          :passive ->
            :inet.setopts(socket, active: false)

          nil ->
            :ok
        end

        {:ok, socket}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Accept a new client from a listening socket, optionally passing options,
  raising if an error occurs.
  """
  @spec accept!(t) :: t | no_return
  @spec accept!(t, Keyword.t()) :: t | no_return
  defbang(accept(self))
  defbang(accept(self, options))

  @doc """
  Set the process which will receive the messages.
  """
  @spec process(t, pid) :: :ok | {:error, :closed | :not_owner | Error.t()}
  def process(socket, pid) do
    :gen_tcp.controlling_process(socket, pid)
  end

  @doc """
  Set the process which will receive the messages, raising if an error occurs.
  """
  @spec process!(t | port, pid) :: :ok | no_return
  def process!(socket, pid) do
    case process(socket, pid) do
      :ok ->
        :ok

      :closed ->
        raise RuntimeError, message: "the socket is closed"

      :not_owner ->
        raise RuntimeError, message: "the current process isn't the owner"

      code ->
        raise GSMLG.Socket.Error, reason: code
    end
  end

  @doc """
  Set options of the socket.
  """
  @spec options(t | GSMLG.Socket.SSL.t() | port, Keyword.t()) ::
          :ok | {:error, GSMLG.Socket.Error.t()}
  def options(socket, options) when socket |> Record.is_record(:sslsocket) do
    GSMLG.Socket.SSL.options(socket, options)
  end

  def options(socket, options) when socket |> is_port do
    :inet.setopts(socket, arguments(options))
  end

  @doc """
  Set options of the socket, raising if an error occurs.
  """
  @spec options!(t | GSMLG.Socket.SSL.t() | port, Keyword.t()) :: :ok | no_return
  defbang(options(socket, options))

  @doc """
  Convert TCP options to `:inet.setopts` compatible arguments.
  """
  @spec arguments(Keyword.t()) :: list
  def arguments(options) do
    options =
      options
      |> Keyword.put_new(:as, :binary)

    options =
      Enum.group_by(options, fn
        {:as, _} -> true
        {:size, _} -> true
        {:packet, _} -> true
        {:backlog, _} -> true
        {:watermark, _} -> true
        {:local, _} -> true
        {:version, _} -> true
        {:options, _} -> true
        _ -> false
      end)

    {local, global} = {
      Map.get(options, true, []),
      Map.get(options, false, [])
    }

    GSMLG.Socket.arguments(global) ++
      Enum.flat_map(local, fn
        {:as, :binary} ->
          [:binary]

        {:as, :list} ->
          [:list]

        {:size, size} ->
          [{:packet_size, size}]

        {:packet, packet} ->
          [{:packet, packet}]

        {:backlog, backlog} ->
          [{:backlog, backlog}]

        {:watermark, options} ->
          Enum.flat_map(options, fn
            {:low, low} ->
              [{:low_watermark, low}]

            {:high, high} ->
              [{:high_watermark, high}]
          end)

        {:local, options} ->
          Enum.flat_map(options, fn
            {:address, address} ->
              [{:ip, GSMLG.Socket.Address.parse(address)}]

            {:port, port} ->
              [{:port, port}]

            {:fd, fd} ->
              [{:fd, fd}]
          end)

        {:version, 4} ->
          [:inet]

        {:version, 6} ->
          [:inet6]

        {:options, options} ->
          Enum.flat_map(options, fn
            :keepalive ->
              [{:keepalive, true}]

            :nodelay ->
              [{:nodelay, true}]
          end)
      end)
  end
end