Skip to content

Commit

Permalink
feat: initial server behaviour
Browse files Browse the repository at this point in the history
- Implemented in Erlang with Elixir interface.
- Implemented as a behaviour.
- Internally, starts a `ranch` TCP listener.
- Each connection is spawned with a `gen_server`.
- `start/3` to start a server.
- `stop/1` to stop a server.
- `@callback init/1` when a client connects.
- `@callback handle_command/2` for each command received from client.
  • Loading branch information
sntran committed Feb 28, 2021
0 parents commit 84a396d
Show file tree
Hide file tree
Showing 15 changed files with 534 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
27 changes: 27 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# The directory Mix will write compiled artifacts to.
/_build/

# If you run "mix test --cover", coverage assets end up here.
/cover/

# The directory Mix downloads your dependencies sources to.
/deps/

# Where third-party dependencies like ExDoc output generated docs.
/doc/

# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch

# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump

# Also ignore archive artifacts (built via "mix archive.build").
*.ez

# Ignore package tarball (built via "mix hex.build").
gen_nntp-*.tar


# Temporary files for e.g. tests
/tmp
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# gen_nntp

**TODO: Add description**

## Installation

If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `gen_nntp` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:gen_nntp, "~> 0.1.0"}
]
end
```

Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at [https://hexdocs.pm/gen_nntp](https://hexdocs.pm/gen_nntp).
3 changes: 3 additions & 0 deletions include/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Includes

The `include/` directory is used to store Erlang .hrl files that are to be included by other applications.
5 changes: 5 additions & 0 deletions include/gen_nntp.hrl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-type name() :: atom() | {'local', term()} | {'global', term()} | {'via', module(), term()}.
-type option() ::
{'name', name()}
| {'port', pos_integer()}.
-type on_start() :: {'ok', pid()} | 'ignore' | {'error', {'already_started', pid()} | term()}.
35 changes: 35 additions & 0 deletions lib/gen_nntp.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule GenNNTP do
@moduledoc """
A behaviour for implementing a NNTP server.
"""

@type option :: :gen_nntp.option()

@typep state :: any

@doc """
Starts a NNTP server with a callback module.
Similar to starting a `gen_server`.
"""
@spec start(module(), any, [option]) :: :gen_nntp.on_start()
defdelegate start(module, args, options), to: :gen_nntp

@doc """
Stops a NNTP server by its reference.
The reference is usually the callback module.
"""
@spec stop(module()) :: :ok
defdelegate stop(ref), to: :gen_nntp

@callback init(any()) ::
{:ok, state} | {:ok, state, timeout | :hibernate} |
:ignore | {:stop, reason :: term}

@callback handle_command(command :: String.t(), state) ::
{:reply, response :: any(), state} |
{:noreply, state} |
{:stop, reason :: any(), state} |
{:stop, reason :: any(), response :: any(), state}
end
63 changes: 63 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
defmodule GenNntp.MixProject do
use Mix.Project

@version "0.1.0"

def project do
[
app: :gen_nntp,
version: @version,
name: "GenNNTP",
source_url: "https://github.com/sntran/gen_nntp",
homepage_url: "http://sntran.github.io/gen_nntp",
description: """
A behaviour for defining NNTP Server.
""",
elixir: "~> 1.11",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps(),
package: package(),
docs: docs(),
]
end

# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end

# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]

# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:ranch, "~> 2.0.0"}
]
end

defp package do
[
maintainers: [
"Son Tran-Nguyen"
],
licenses: ["Apache 2.0"],
links: %{github: "https://github.com/sntran/gen_nntp"},
files:
~w(assets examples include lib priv src) ++
~w(.formatter.exs mix.exs CHANGELOG.md LICENSE README.md Emakefile),
exclude_patterns: [".DS_Store"]
]
end

defp docs do
[
main: "GenNNTP",
source_ref: "v#{@version}"
]
end
end
3 changes: 3 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
%{
"ranch": {:hex, :ranch, "2.0.0", "fbf3d79661c071543256f9051caf19d65daa6df1cf6824d8f37a49b19a66f703", [:rebar3], [], "hexpm", "c20a4840c7d6623c19812d3a7c828b2f1bd153ef0f124cb69c54fe51d8a42ae0"},
}
3 changes: 3 additions & 0 deletions src/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Erlang sources

The `src` directory is used to store Erlang source files. The private `.hrl` files are usually kept inside the `src/` directory as well.
200 changes: 200 additions & 0 deletions src/gen_nntp.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
-module(gen_nntp).
-author('dev@sntran.com').
-behaviour(gen_server).

%% API
-export([
start/3,
stop/1
]).

%% gen_server callbacks
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2,
code_change/3
]).

%% ranch_protocol callbacks
-export([
start_link/3,
start_link/4
]).

-export_type([
name/0,
option/0,
on_start/0
]).

-include("gen_nntp.hrl").
-include("gen_nntp_internal.hrl").

-record(client, {
transport :: module(),
module :: module(),
state :: state()
}).

-callback init(Args :: term()) ->
{ok, State :: state()}
| ignore
| {stop, Reason :: any()}.

-callback handle_command(Command :: binary(), State :: state()) ->
{reply, Response :: binary(), NewState}
| {noreply, NewState}
| {stop, Reason :: any(), NewState}
| {stop, Reason :: any, Response :: binary(), NewState}
when NewState :: state().

%% ==================================================================
%% API
%% ==================================================================

%%-------------------------------------------------------------------
%% @doc Starts a NNTP server with a callback module.
%%
%% Similar to starting a `gen_server`.
%% @end
%%-------------------------------------------------------------------
-spec start(module(), term(), [option()]) -> on_start().
start(Module, Args, Options) when is_atom(Module) ->
ok = application:ensure_started(ranch),

Port = proplists:get_value(port, Options, 119),
Options1 = proplists:delete(port, Options),

ProtocolOpts = { Module, Args, Options1 },
case ranch:start_listener(
Module,
ranch_tcp, [{port, Port}],
?MODULE, ProtocolOpts
) of
{ok, Listener} -> {ok, Listener};
{error, {already_started, Listener}} -> {ok, Listener}
end.

%%-------------------------------------------------------------------
%% @doc Stops a NNTP server by its reference.
%%
%% The reference is usually the callback module.
%% @end
%%-------------------------------------------------------------------
-spec stop(module()) -> ok.
stop(Ref) ->
try ranch:stop_listener(Ref) of
_ -> ok
catch
_:_ -> ok
end.

%% ==================================================================
%% ranch_protocol Callbacks
%% ==================================================================

% ranch 1
start_link(Module, _Socket, Transport, { Module, _, _ } = Options) when is_atom(Transport) ->
start_link(Module, Transport, Options).

% ranch_protocol 2
start_link(Module, Transport, { Module, _, _ } = Options) when is_atom(Transport) ->
gen_server:start_link(?MODULE, { Transport, Options }, []).

%% ==================================================================
%% gen_server Callbacks
%% ==================================================================

%% @private
init({ Transport, { Module, Args, _Options } }) ->
% traps exit signals so we can clean up when terminated by supervisor.
process_flag(trap_exit, true),

Client = #client{
transport = Transport,
module = Module
},

case Module:init(Args) of
{ok, State} ->
% Set timeout to 0 so we can handle handshake.
{ok, Client#client{state = State}, 0};
{ok, State, Delay} when is_integer(Delay) ->
{ok, Client#client{state = State}, Delay};
ignore ->
ignore;
{stop, Reason} ->
{stop, Reason};
Else ->
Else
end.

%% @private
handle_call(_, _From, Client) -> {reply, ok, Client}.

%% @private
handle_cast(stop, Client) -> {stop, normal, Client}.

% Received after initialization timeout. Starts the handshake.
handle_info(timeout, #client{module =Module, transport = Transport} = Client) ->
{ok, Socket} = ranch:handshake(Module),
ok = Transport:setopts(Socket, [{active, once}, {packet, line}]),

Transport:send(Socket, "200 Service available, posting allowed\r\n"),
{noreply, Client};

handle_info({tcp, Socket, Line}, Client) ->
#client{transport = Transport, module = Module, state = State} = Client,
ok = Transport:setopts(Socket, [{active, once}]),

Command = string:trim(Line, trailing, "\r\n"),

NewState = case Module:handle_command(Command, State) of
{reply, Reply, State1} ->
Transport:send(Socket, <<Reply/binary, "\r\n">>),
State1;
{noreply, State1} ->
State1;
{stop, _Reason, State1} ->
Transport:close(Socket),
State1;
{stop, _Reason, Reply, State1} ->
Transport:send(Socket, <<Reply/binary, "\r\n">>),
Transport:close(Socket),
State1
end,

{noreply, Client#client{state = NewState}};

handle_info({tcp_closed, _Socket}, Client) ->
{stop, normal, Client};

handle_info({tcp_error, _Socket, Reason}, Client) ->
{stop, Reason, Client};

% handles exit message, if the gen_server is linked to other processes (than
% the supervisor) and trapping exit signals.
handle_info({'EXIT', _Pid, _Reason}, Client) ->
% ..code to handle exits here..
{noreply, Client};

handle_info(_Info, Client) ->
{noreply, Client}.

%% @private
terminate(normal, _Client) ->
% handles normal termination when callback retruns `{stop, normal, Client}`
ok;

terminate(shutdown, _Client) ->
% ..code for cleaning up here..
ok;

terminate(_, _Client) -> ok.

code_change(_OldVsn, Client, _Extra) ->
% ..code to convert state (and more) during code change
{ok, Client}.
1 change: 1 addition & 0 deletions src/gen_nntp_internal.hrl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-type state() :: term().
Loading

0 comments on commit 84a396d

Please sign in to comment.