diff --git a/config/.credo.exs b/config/.credo.exs index 8251065..5d354d7 100644 --- a/config/.credo.exs +++ b/config/.credo.exs @@ -70,7 +70,8 @@ # If you don't want TODO comments to cause `mix credo` to fail, just # set this value to 0 (zero). # - {Credo.Check.Design.TagTODO, [priority: :low]}, + # {Credo.Check.Design.TagTODO, [priority: :low]}, + {Credo.Check.Design.TagTODO, false}, {Credo.Check.Design.TagFIXME, []}, # diff --git a/guides/introduction/getting-started.md b/guides/introduction/getting-started.md index 4e58edd..c29eb40 100644 --- a/guides/introduction/getting-started.md +++ b/guides/introduction/getting-started.md @@ -56,14 +56,14 @@ defmodule LoginServer.Frontend do @impl ElvenGard.Frontend def handle_init(args) do - port = get_in(args, [:port]) - Logger.info("Login server started on port #{port}") + ip = Keyword.fetch!(args.listener_opts.socket_opts, :ip) + port = Keyword.fetch!(args.listener_opts.socket_opts, :port) + Logger.info("Listening for connections on #{:inet.ntoa(ip)}:#{port}") {:ok, nil} end @impl ElvenGard.Frontend - def handle_connection(socket, transport) do - client = Client.new(socket, transport) + def handle_connection(client) do Logger.info("New connection: #{client.id}") {:ok, client} end diff --git a/lib/elven_gard/frontend.ex b/lib/elven_gard/frontend.ex index 22da341..8b0cb50 100644 --- a/lib/elven_gard/frontend.ex +++ b/lib/elven_gard/frontend.ex @@ -1,3 +1,201 @@ +defmodule ElvenGard.Frontend.State do + @moduledoc false + + require Record + + alias ElvenGard.Structures.Client + + Record.defrecord(:frontend_state, + callback_module: nil, + packet_handler: nil, + packet_protocol: nil, + client: nil + ) + + @type t :: + record(:frontend_state, + callback_module: module, + packet_handler: module, + packet_protocol: module, + client: Client.t() + ) +end + +defmodule ElvenGard.Frontend.RanchProtocol do + @moduledoc false + + @behaviour :ranch_protocol + + use GenServer + + import ElvenGard.Frontend.State + + alias ElvenGard.Frontend + alias ElvenGard.Structures.Client + + @timeout 5_000 + + ## Ranch behaviour + + @impl :ranch_protocol + def start_link(ref, socket, transport, protocol_options) do + opts = { + ref, + socket, + transport, + protocol_options + } + + pid = :proc_lib.spawn_link(__MODULE__, :init, [opts]) + {:ok, pid} + end + + ## Genserver behaviour + + @impl GenServer + @spec init({reference, pid, atom, keyword}) :: no_return + def init({ref, socket, transport, protocol_options}) do + callback_module = Keyword.fetch!(protocol_options, :callback_module) + packet_protocol = Keyword.fetch!(protocol_options, :packet_protocol) + packet_handler = Keyword.fetch!(protocol_options, :packet_handler) + + :ok = :ranch.accept_ack(ref) + :ok = transport.setopts(socket, [{:active, true}]) + + {:ok, client} = + socket + |> Client.new(transport, packet_protocol) + |> callback_module.handle_connection() + + state = + frontend_state( + callback_module: callback_module, + packet_protocol: packet_protocol, + packet_handler: packet_handler, + client: client + ) + + :gen_server.enter_loop(__MODULE__, [], state, @timeout) + end + + @impl GenServer + def handle_info({:tcp, _socket, data}, state) do + frontend_state( + callback_module: cb_module, + packet_handler: handler, + packet_protocol: protocol, + client: client + ) = state + + # TODO: Manage errors on `handle_message`: don't execute the protocol ??? + {:ok, tmp_client} = cb_module.handle_message(client, data) + + payload = protocol.complete_decode(data, tmp_client) + + case do_handle_packet(payload, tmp_client, handler) do + {:cont, final_client} -> + {:noreply, frontend_state(state, client: final_client)} + + {:halt, {:ok, args}, final_client} -> + new_state = frontend_state(state, client: final_client) + do_halt_ok(args, new_state) + + {:halt, {:error, reason}, final_client} -> + new_state = frontend_state(state, client: final_client) + do_halt_error(reason, new_state) + + x -> + raise """ + #{inspect(handler)}.handle_packet/3 have to return `{:cont, client}`, \ + `{:halt, {:ok, :some_args}, client}`, or `{:halt, {:error, reason}, client} `. \ + Returned: #{inspect(x)} + """ + end + end + + @impl GenServer + def handle_info({:tcp_closed, _socket}, state) do + frontend_state(callback_module: cb_module, client: client) = state + {:ok, new_client} = cb_module.handle_disconnection(client, :normal) + {:stop, :normal, frontend_state(state, client: new_client)} + end + + @impl GenServer + def handle_info({:tcp_error, _socket, reason}, state) do + frontend_state(callback_module: cb_module, client: client) = state + {:ok, new_client} = cb_module.handle_error(client, reason) + {:stop, reason, frontend_state(state, client: new_client)} + end + + @impl GenServer + def handle_info(:timeout, state) do + frontend_state(callback_module: cb_module, client: client) = state + {:ok, new_client} = cb_module.handle_error(client, :timeout) + {:stop, :normal, frontend_state(state, client: new_client)} + end + + ## Private function + + @doc false + @spec do_handle_packet({term, map} | list(tuple), Client.t(), module) :: + {:cont, Client.t()} + | {:halt, {:ok, term}, Client.t()} + | {:halt, {:error, Frontend.conn_error()}, Client.t()} + defp do_handle_packet({header, params}, client, handler) do + handler.handle_packet(header, params, client) + end + + defp do_handle_packet([{_header, _params} | _t] = packet_list, client, handler) do + Enum.reduce_while(packet_list, {:cont, client}, fn packet, {_, client} -> + res = do_handle_packet(packet, client, handler) + {elem(res, 0), res} + end) + end + + defp do_handle_packet(x, _client, _handler) do + raise """ + unable to handle packet #{inspect(x)}. + Please check that your protocol returns a tuple in the form of {header, \ + %{param1: :val1, param2: :val2, ...} or a list of tuples + """ + end + + @doc false + @spec do_halt_ok(term, State.t()) :: {:stop, :normal, State.t()} + defp do_halt_ok(args, state) do + frontend_state(callback_module: cb_module, client: client) = state + new_client = client |> cb_module.handle_halt_ok(args) |> close_socket(:normal, cb_module) + new_state = frontend_state(state, client: new_client) + {:stop, :normal, new_state} + end + + @doc false + @spec do_halt_error(term, State.t()) :: {:stop, :normal, State.t()} + defp do_halt_error(reason, state) do + frontend_state(callback_module: cb_module, client: client) = state + new_client = client |> cb_module.handle_halt_error(reason) |> close_socket(reason, cb_module) + new_state = frontend_state(state, client: new_client) + {:stop, :normal, new_state} + end + + @doc false + @spec close_socket(ElvenGard.Frontend.handle_return(), term, module) :: Client.t() + defp close_socket({:ok, %Client{} = client}, reason, cb_module), + do: do_close_socket(client, reason, cb_module) + + defp close_socket({:error, _, %Client{} = client}, reason, cb_module), + do: do_close_socket(client, reason, cb_module) + + @doc false + @spec do_close_socket(Client.t(), term, module) :: Client.t() + defp do_close_socket(%Client{} = client, reason, cb_module) do + %Client{socket: socket, transport: transport} = client + {:ok, final_client} = cb_module.handle_disconnection(client, reason) + transport.close(socket) + final_client + end +end + defmodule ElvenGard.Frontend do @moduledoc """ TODO: Documentation for ElvenGard.Frontend @@ -9,207 +207,104 @@ defmodule ElvenGard.Frontend do @type handle_ok :: {:ok, Client.t()} @type handle_error :: {:error, term, Client.t()} @type handle_return :: handle_ok | handle_error - @type state :: Client.t() - @callback handle_init(args :: list) :: {:ok, term} | {:error, term} - @callback handle_connection(socket :: identifier, transport :: atom) :: handle_return - @callback handle_client_ready(client :: Client.t()) :: handle_return + @callback handle_init(args :: map) :: + {:ok, map} + | {:ok, map, timeout() | :hibernate | {:continue, term()}} + | :ignore + | {:stop, reason :: any()} + @callback handle_connection(client :: Client.t()) :: handle_return @callback handle_disconnection(client :: Client.t(), reason :: term) :: handle_return @callback handle_message(client :: Client.t(), message :: binary) :: handle_return @callback handle_error(client :: Client.t(), error :: conn_error) :: handle_return @callback handle_halt_ok(client :: Client.t(), args :: term) :: handle_return @callback handle_halt_error(client :: Client.t(), error :: conn_error) :: handle_return - @doc """ - Use ElvenGard.Frontend behaviour - """ + @doc false defmacro __using__(opts) do - parent = __MODULE__ caller = __CALLER__.module - port = get_in(opts, [:port]) || 3000 - protocol = get_in(opts, [:packet_protocol]) - handler = get_in(opts, [:packet_handler]) - use_opts = put_in(opts, [:port], port) + port = Keyword.get(opts, :port, 3000) + protocol = Keyword.get(opts, :packet_protocol) + handler = Keyword.get(opts, :packet_handler) - # Check is there is any protocol + # Check if there is any protocol unless protocol do raise "please, specify a packet_protocol for #{caller}" end - # Check is there is any handler + # Check if there is any handler unless handler do raise "please, specify a packet_handler for #{caller}" end quote do - @behaviour unquote(parent) - @behaviour :ranch_protocol - - alias ElvenGard.Structures.Client - - @timeout Application.get_env(:elven_gard, :response_timeout, 2000) - - @doc false - def child_spec(opts) do - listener_name = __MODULE__ - num_acceptors = Application.get_env(:elven_gard, :num_acceptors, 10) - transport = :ranch_tcp - transport_opts = [port: unquote(port)] - protocol = __MODULE__ - protocol_opts = [] - - # TODO: Use args (pass them to ranch opts ?) - {:ok, _args} = - opts - |> Enum.concat(unquote(use_opts)) - |> handle_init() - - :ranch.child_spec( - listener_name, - num_acceptors, - transport, - transport_opts, - protocol, - protocol_opts - ) - end + @behaviour unquote(__MODULE__) - @doc false - @impl true - def start_link(ref, socket, transport, protocol_options) do - opts = [ - ref, - socket, - transport, - protocol_options - ] - - pid = :proc_lib.spawn_link(__MODULE__, :init, opts) - {:ok, pid} - end + use GenServer - @doc """ - Accept Ranch ack and handle packets - """ - def init(ref, socket, transport, _) do - with :ok <- :ranch.accept_ack(ref), - :ok = transport.setopts(socket, [{:active, true}]), - {:ok, client} <- handle_connection(socket, transport) do - {:ok, final_client} = - %Client{client | protocol: unquote(protocol)} - |> handle_client_ready() - - :gen_server.enter_loop(__MODULE__, [], final_client, 10_000) - end + def start_link(args) do + GenServer.start_link(__MODULE__, args, name: __MODULE__) end - # - # All GenServer handles - # - - def handle_info({:tcp, socket, data}, %Client{} = client) do - # TODO: Manage errors on `handle_message`: don't execute the protocol - {:ok, tmp_state} = handle_message(client, data) - - payload = unquote(protocol).complete_decode(data, tmp_state) - - case do_handle_packet(payload, tmp_state) do - {:cont, final_client} -> - {:noreply, final_client} - - {:halt, {:ok, args}, final_client} -> - do_halt_ok(final_client, args) - - {:halt, {:error, reason}, final_client} -> - do_halt_error(final_client, reason) - - x -> - raise """ - #{unquote(handler)}.handle_packet/3 have to return `{:cont, client}`, \ - `{:halt, {:ok, :some_args}, client}`, or `{:halt, {:error, reason}, client} `. \ - Returned: #{inspect(x)} - """ + @impl GenServer + def init(_) do + ip_address = Application.get_env(:elven_gard, :ip_address, "127.0.0.1") + port = unquote(port) + {:ok, ranch_ip} = ip_address |> to_char_list() |> :inet.parse_ipv4_address() + + listener_opts = %{ + num_acceptors: Application.get_env(:elven_gard, :num_acceptors, 10), + max_connections: Application.get_env(:elven_gard, :max_connections, 1024), + handshake_timeout: Application.get_env(:elven_gard, :handshake_timeout, 5000), + socket_opts: [ip: ranch_ip, port: port] + } + + opts = %{ + listener_name: {__MODULE__, ip_address, port}, + transport: :ranch_tcp, + listener_opts: listener_opts, + protocol: unquote(__MODULE__).RanchProtocol, + protocol_opts: [ + callback_module: __MODULE__, + packet_protocol: unquote(protocol), + packet_handler: unquote(handler) + ] + } + + case handle_init(opts) do + {:ok, opts} -> {:ok, nil, {:continue, {:init_listener, opts, nil}}} + {:ok, opts, action} -> {:ok, nil, {:continue, {:init_listener, opts, action}}} + res -> res end end - def handle_info({:tcp_closed, _socket}, %Client{} = client) do - {:ok, new_state} = handle_disconnection(client, :normal) - {:stop, :normal, new_state} - end - - def handle_info({:tcp_error, _socket, reason}, %Client{} = client) do - {:ok, new_state} = handle_error(client, reason) - {:stop, reason, new_state} - end - - def handle_info(:timeout, %Client{} = client) do - {:ok, new_state} = handle_error(client, :timeout) - {:stop, :normal, new_state} - end - - # - # Private function - # - - @spec do_handle_packet({term, map} | list(tuple), Client.t()) :: - {:cont, unquote(parent).state} - | {:halt, {:ok, term}, unquote(parent).state} - | {:halt, {:error, unquote(parent).conn_error()}, unquote(parent).state} - defp do_handle_packet({header, params}, client) do - unquote(handler).handle_packet(header, params, client) - end - - defp do_handle_packet([{_header, _params} | _t] = packet_list, client) do - Enum.reduce_while(packet_list, {:cont, client}, fn packet, {_, client} -> - res = do_handle_packet(packet, client) - {elem(res, 0), res} - end) - end - - defp do_handle_packet(x, _client) do - raise """ - unable to handle packet #{inspect(x)}. - Please check that your protocol returns a tuple in the form of {header, \ - %{param1: :val1, param2: :val2, ...} or a list of tuples - """ - end - - @spec do_halt_ok(Client.t(), term) :: {:stop, :normal, Client.t()} - defp do_halt_ok(%Client{} = client, args) do - final_client = - client - |> handle_halt_ok(args) - |> close_socket(:normal) - - {:stop, :normal, final_client} - end - - @spec do_halt_error(Client.t(), term) :: {:stop, :normal, Client.t()} - defp do_halt_error(%Client{} = client, reason) do - final_client = - client - |> handle_halt_error(reason) - |> close_socket(reason) - - {:stop, :normal, final_client} - end - - @spec close_socket(unquote(parent).handle_return(), term) :: Client.t() - defp close_socket({:ok, %Client{} = client}, reason), do: do_close_socket(client, reason) - - defp close_socket({:error, _, %Client{} = client}, reason), - do: do_close_socket(client, reason) - - @spec do_close_socket(Client.t(), term) :: Client.t() - defp do_close_socket(%Client{} = client, reason) do - %Client{ - socket: socket, - transport: transport - } = client - - {:ok, final_client} = handle_disconnection(client, reason) - transport.close(socket) - final_client + @impl GenServer + def handle_continue({:init_listener, opts, action}, state) do + %{ + listener_name: listener_name, + transport: transport, + listener_opts: listener_opts, + protocol: protocol, + protocol_opts: protocol_opts + } = opts + + {:ok, pid} = + :ranch.start_listener( + listener_name, + transport, + listener_opts, + protocol, + protocol_opts + ) + + # FIXMME: Not sure if it's a good practice.... + # Maybe a monitor would be better + Process.link(pid) + + case action do + nil -> {:noreply, state} + _ -> {:noreply, state, action} + end end # @@ -217,8 +312,7 @@ defmodule ElvenGard.Frontend do # def handle_init(_args), do: {:ok, nil} - def handle_connection(socket, transport), do: Client.new(socket, transport) - def handle_client_ready(client), do: {:ok, client} + def handle_connection(client), do: {:ok, client} def handle_disconnection(client, _reason), do: {:ok, client} def handle_message(client, _message), do: {:ok, client} def handle_error(client, _reason), do: {:ok, client} @@ -226,8 +320,7 @@ defmodule ElvenGard.Frontend do def handle_halt_error(client, _reason), do: {:ok, client} defoverridable handle_init: 1, - handle_connection: 2, - handle_client_ready: 1, + handle_connection: 1, handle_disconnection: 2, handle_message: 2, handle_error: 2, diff --git a/lib/elven_gard/packet.ex b/lib/elven_gard/packet.ex index 2387a7a..e337ada 100644 --- a/lib/elven_gard/packet.ex +++ b/lib/elven_gard/packet.ex @@ -140,7 +140,8 @@ defmodule ElvenGard.Packet do Module.put_attribute(caller, :elven_packet_definitions, @elven_current_packet) params_map = - Enum.reduce(@elven_current_packet.fields, [], fn %FieldDefinition{name: name} = x, acc -> + @elven_current_packet.fields + |> Enum.reduce([], fn %FieldDefinition{name: name} = x, acc -> case {Keyword.get(x.opts, :optional), Keyword.get(x.opts, :using)} do {true, _} -> acc {_, nil} -> [{name, {:_, [], Elixir}} | acc] diff --git a/lib/elven_gard/protocol/textual.ex b/lib/elven_gard/protocol/textual.ex index 2a2f5d2..fbbe37c 100644 --- a/lib/elven_gard/protocol/textual.ex +++ b/lib/elven_gard/protocol/textual.ex @@ -70,7 +70,7 @@ defmodule ElvenGard.Protocol.Textual do m = unquote(model) Logger.debug(fn -> - "Can't decode packet with header #{name}: not defined in model #{m}" + "Can't decode packet with header #{name}: not defined in model #{inspect(m)}" end) {name, params} diff --git a/lib/elven_gard/structures/client.ex b/lib/elven_gard/structures/client.ex index 4a8b3cb..c08fdfb 100644 --- a/lib/elven_gard/structures/client.ex +++ b/lib/elven_gard/structures/client.ex @@ -5,10 +5,10 @@ defmodule ElvenGard.Structures.Client do You can store some metadata on this structure. """ - @keys [:id, :socket, :transport, :metadata] + @keys [:id, :socket, :transport, :protocol, :metadata] @enforce_keys @keys - defstruct @keys ++ [:protocol] + defstruct @keys @type t :: %__MODULE__{} @type metadata_key :: [term, ...] | term @@ -17,12 +17,13 @@ defmodule ElvenGard.Structures.Client do @doc """ Create a new structure """ - @spec new(identifier, atom, map) :: __MODULE__.t() - def new(socket, transport, metadata \\ %{}) do + @spec new(identifier, atom, module, map) :: __MODULE__.t() + def new(socket, transport, protocol, metadata \\ %{}) do %__MODULE__{ id: UUID.uuid4(), socket: socket, transport: transport, + protocol: protocol, metadata: metadata } end diff --git a/mix.exs b/mix.exs index c460f96..2958776 100644 --- a/mix.exs +++ b/mix.exs @@ -70,7 +70,7 @@ defmodule ElvenGard.MixProject do defp deps() do [ - {:ranch, "~> 1.5"}, + {:ranch, "~> 1.7"}, {:elixir_uuid, "~> 1.2"}, {:ex_doc, "~> 0.21", only: :dev, runtime: false}, {:dialyxir, "~> 0.5", optional: true, only: [:dev, :test], runtime: false},