lib/elven_gard/network/endpoint/protocol.ex

defmodule ElvenGard.Network.Endpoint.Protocol do
  @moduledoc ~S"""
  Wrapper on top of [Ranch protocols](https://ninenines.eu/docs/en/ranch/2.1/guide/protocols/).

  This module defines a protocol behavior to handle incoming connections in the
  ElvenGard.Network library. It provides callbacks for initializing, handling
  incoming messages, and handling connection termination.

  This protocol behavior serves as a wrapper around Ranch protocols, providing
  a structured way to implement connection handling within ElvenGard.Network.

  For detailed information on implementing and using network protocols
  with ElvenGard.Network, please refer to the 
  [Endpoint Protocol guide](https://hexdocs.pm/elvengard_network/protocol.html).
  """

  alias ElvenGard.Network.Socket

  @doc """
  Callback called just before entering the GenServer loop.

  This callback is invoked when a new connection is established and before the
  GenServer loop starts processing messages.

  For the return values, see `c:GenServer.init/1`
  """
  @callback handle_init(socket :: Socket.t()) ::
              {:ok, new_socket}
              | {:ok, new_socket, timeout | :hibernate | {:continue, continue_arg}}
              | {:stop, reason :: term, new_socket}
            when new_socket: Socket.t(), continue_arg: term

  @doc """
  Callback called just after receiving a message.

  This callback is invoked whenever a message is received on the connection. It should
  return one of the following:

    - `:ignore`: the message received will not be decoded or processed by the protocol.
      It will just be ignored
    - `{:ignore, new_socket}`: same as `:ignore` but also modifies the socket
    - `{:ok, new_socket}`: classic loop - decode the packet and process it
    - `{:stop, reason, new_socket}`: stop the GenServer/Protocol and disconnect the client

  """
  @callback handle_message(message :: binary, socket :: Socket.t()) ::
              :ignore
              | {:ignore, new_socket}
              | {:ok, new_socket}
              | {:stop, reason :: term, new_socket}
            when new_socket: Socket.t()

  @doc """
  Callback called after the socket connection is closed and before the GenServer
  shutdown.

  """
  @callback handle_halt(reason :: term, socket :: Socket.t()) ::
              {:ok, new_socket}
              | {:ok, stop_reason :: term, new_socket}
            when new_socket: term

  @optional_callbacks handle_init: 1,
                      handle_message: 2,
                      handle_halt: 2

  ## Public API

  @doc false
  defmacro __using__(_opts) do
    quote do
      use GenServer

      @behaviour unquote(__MODULE__)
      @behaviour :ranch_protocol

      unquote(defs())
      unquote(message_callbacks())
      unquote(halt_callbacks())
      unquote(default_callbacks())
    end
  end

  ## Private functions

  defp defs() do
    quote location: :keep do
      @impl :ranch_protocol
      def start_link(ref, transport, opts) do
        {:ok, :proc_lib.spawn_link(__MODULE__, :init, [{ref, transport, opts}])}
      end

      @impl GenServer
      def init({ref, transport, opts}) do
        {:ok, transport_pid} = :ranch.handshake(ref)
        socket = Socket.new(transport_pid, transport, codec())

        init_error =
          "handle_init/1 must return `{:ok, socket}`, `{:ok, socket, timeout}` " <>
            "or `{:stop, reason, new_socket}`"

        case handle_init(socket) do
          {:ok, new_socket} -> do_enter_loop(new_socket)
          {:ok, new_socket, timeout} -> do_enter_loop(new_socket, timeout)
          {:stop, reason, new_socket} -> {:stop, reason, new_socket}
          _ -> raise init_error
        end
      end

      ## Helpers

      defp do_enter_loop(%Socket{} = socket, timeout \\ :infinity) do
        %Socket{transport: transport, transport_pid: transport_pid} = socket
        transport.setopts(transport_pid, active: :once)
        :gen_server.enter_loop(__MODULE__, [], socket, timeout)
      end
    end
  end

  # credo:disable-for-next-line
  defp message_callbacks() do
    quote location: :keep do
      @impl true
      def handle_info({:tcp, transport_pid, data}, %Socket{} = socket) do
        %Socket{transport: transport, remaining: remaining} = socket
        full_data = <<remaining::bitstring, data::bitstring>>

        result =
          case handle_message(full_data, socket) do
            :ignore -> {:noreply, socket}
            {:ignore, new_socket} -> {:noreply, new_socket}
            {:ok, new_socket} -> packet_loop(full_data, new_socket)
            {:stop, reason, new_socket} -> {:stop, reason, new_socket}
            term -> raise "invalid return value for handle_message/2 (got: #{inspect(term)})"
          end

        transport.setopts(transport_pid, active: :once)
        result
      end

      ## Helpers

      @app Mix.Project.get().project[:app]
      defp env_config(), do: Application.fetch_env!(@app, __MODULE__)
      defp codec(), do: env_config()[:network_codec]
      defp handlers(), do: env_config()[:packet_handler]

      defp packet_loop(<<>>, socket), do: {:noreply, socket}

      defp packet_loop(data, socket) do
        with {:next, {raw, rest}} when not is_nil(raw) <- {:next, codec().next(data, socket)},
             struct <- codec().decode(raw, socket),
             {:handle, {:cont, new_socket}} <- {:handle, handlers().handle_packet(struct, socket)} do
          packet_loop(rest, new_socket)
        else
          {:next, {nil, rest}} -> {:noreply, %Socket{socket | remaining: rest}}
          {:handle, {:halt, new_socket}} -> do_handle_halt(:normal, socket)
          {:handle, {:halt, reason, new_socket}} -> do_handle_halt(reason, socket)
        end
      end
    end
  end

  defp halt_callbacks() do
    quote location: :keep do
      @impl true
      def handle_info({:tcp_closed, transport_pid}, %Socket{} = socket) do
        do_handle_halt(:tcp_closed, socket)
      end

      @impl true
      def handle_info(:timeout, %Socket{} = socket) do
        do_handle_halt(:timeout, socket)
      end

      ## Helpers

      defp do_handle_halt(reason, socket) do
        %Socket{transport: transport, transport_pid: transport_pid} = socket
        transport.close(transport_pid)

        case handle_halt(reason, socket) do
          {:ok, new_socket} -> {:stop, :normal, new_socket}
          {:ok, stop_reason, new_socket} -> {:stop, stop_reason, new_socket}
          _ -> raise "handle_halt/2 must return `{:ok, socket}` or `{:ok, stop_reason, socket}`"
        end
      end
    end
  end

  defp default_callbacks() do
    quote do
      @impl true
      def handle_init(socket), do: {:ok, socket}

      @impl true
      def handle_message(_message, socket), do: {:ok, socket}

      @impl true
      def handle_halt(_reason, socket), do: {:ok, socket}

      defoverridable handle_init: 1,
                     handle_message: 2,
                     handle_halt: 2
    end
  end
end