jonnifyelixir
F5 / F5.01 / thin-server

F5.01 · part 2 of 3

A thin web server in Elixir

The server that makes the Portal runnable is small enough to read in one screen: a Plug.Router matched and dispatched by Bandit, a pure-Elixir HTTP server, added as one child in the supervision tree. Each route does exactly one thing — turn the request into a command or a query, call Portal.Engine, and send back the result. There is no framework here and no business logic; the router knows how to speak HTTP and nothing about the Portal’s rules.

Follow a request through the four steps of the server, then read the whole thing in code. You can curl it on day one.

A request, four steps

Bandit accepts the connection and hands a conn to the router; the router matches a handler; the handler calls the engine; the result is encoded and sent. Only the third step is real work — the rest is plumbing the language and the libraries already provide. Select a step to see what it does.

The request path · select a step

HTTP request GET /courses/:id Plug.Router match + dispatch Portal.Engine dispatch/1 · query/2 send_resp status + body the router speaks HTTP; the engine holds the rules — one call between them

step: Portal.Engine

does: calls the engine

A thin server is mostly other people’s code: Bandit speaks HTTP, Plug routes, Jason encodes. Your part is the one line in the middle that calls the Portal.

The whole server

The router is the entire web layer. A get reads through a query; a post writes through a command and maps the engine’s tagged result onto a status code; an unmatched request is a 404. Adding it to the supervision tree as a Bandit child is the last step — now the Portal is on a port.

defmodule Portal.Web.Router do
  use Plug.Router

  plug :match
  plug Plug.Parsers, parsers: [:urlencoded, :json], json_decoder: Jason
  plug :dispatch

  # read: a query, no side effects
  get "/courses/:user_id" do
    {:ok, courses} = Portal.Engine.query(:courses_of, user_id)
    send_resp(conn, 200, Jason.encode!(courses))
  end

  # write: a command, mapped onto a status code
  post "/enroll" do
    cmd = %Portal.Commands.EnrollLearner{
      user_id: conn.params["user"],
      course_id: conn.params["course"]
    }
    case Portal.Engine.dispatch(cmd) do
      {:ok, _event}    -> send_resp(conn, 201, "enrolled")
      {:error, reason} -> send_resp(conn, 422, to_string(reason))
    end
  end

  match _, do: send_resp(conn, 404, "not found")
end

# the whole server: one child in the supervision tree
children = [
  Portal.Engine,
  {Bandit, plug: Portal.Web.Router, port: 4000}
]

a request

An HTTP verb and a path — POST /enroll.

one engine call

The handler builds a command and calls dispatch/1 — nothing more.

Next: a web layer built for replacement — why this thinness is what lets Phoenix take over in F6.

Booting the Portal as a supervised application

An OTP application is a started, supervised unit of work, not merely a folder of modules. The entry point is a module that calls use Application and implements start/2, whose job is to start a supervisor over the application’s children. The Supervisor and Application guide names the wiring that turns the project into one: the application/0 callback in mix.exs carries a mod: entry, {Portal.Application, []}, naming the module to start and the argument to pass it. With that line in place, mix run, an iex -S mix session, and a release all boot the same tree the same way.

The children are the Portal.Engine and the Bandit server from the list above, now started under Portal.Supervisor with a :one_for_one strategy. The thin server is a supervised application from the first commit, so a crash in one child restarts that child cleanly instead of taking the node down.

The boot path · mix.exs to a running tree

mix.exs mod: {Portal.Application, []} Portal.Application start/2 Portal.Supervisor strategy :one_for_one Portal.Engine the domain core Bandit Plug.Router · port 4000 boots the tree
# lib/portal/application.ex — the OTP entry point, here from the first commit
defmodule Portal.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      Portal.Engine,                                       # the domain core
      {Bandit, plug: Portal.Web.Router, port: 4000}    # the thin web server
    ]
    # one_for_one: a crashed child restarts on its own, not the node
    opts = [strategy: :one_for_one, name: Portal.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

# mix.exs — application/0 names the module to start with mod:
def application do
  [
    extra_applications: [:logger],
    mod: {Portal.Application, []}    # Mix starts this on boot
  ]
end

# a single command boots the whole supervised tree:
#   $ mix run --no-halt
Process.whereis(Portal.Supervisor)    # => #PID<0.214.0>  (the tree is up)
Supervisor.which_children(Portal.Supervisor)
# => [{Bandit, #PID<0.221.0>, :supervisor, [Bandit]},
#     {Portal.Engine, #PID<0.216.0>, :worker, [Portal.Engine]}]

an application

A started, supervised unit — not a loose folder of modules that something else must remember to launch.

one mod: line

mod: {Portal.Application, []} in mix.exs makes every boot start the same supervised tree.

Wired through mod: from the first commit, the Portal boots as a supervised tree, so a falling child is restarted in place rather than dragging the node down with it.

References

Primary sources for this lesson, and where it connects in the course.

Sources

Related in this course