Clickbait. It’s actually just a Plug. Let’s write and deploy an Elixir microservice in a single file.
Elixir development is famous for many things. It’s incredible resilience, easy scalability, and probably the best standard library I’ve seen. Best documented as well. This also reflects on it’s ecosystem where things are well thought-out and designed with debugability in mind (is that even a word?).
Famously, when you start working in Elixir you first encounter it’s PETAL stack, spearheaded with Phoenix. Want to write a Twitter? Phoenix. Want to write a data pipeline? Phoenix. Want to write a CMS? Phoenix. Want to write a microservice? Phoenix.
And I won’t say I disagree, Phoenix is extremely easy to embrace. But, and there is always a but, I’ve recently had a use case where I’ve had to write an extremely simple API. We’re talking braindead simple. It was a custom connector for a data pipeline we are working on at Weld. I didn’t pick up Phoenix, instead I instinctively used Flask.
Update 23.02.2023: This is why I love blogs. Folks on the Elixir Slack told me that Chris McCord made a 1-file Phoenix!
I figured it would be an fun topic for a blogpost to try and to the same in Elixir. Let’s follow Flask docs word by word and see how it would look like. Lets use some obnoxious hypewords like microframework.
Retort, a Microkernel for Elixir — Quickstart
Plug provides configuration and conventions, with sensible defaults, to get started. This section of the documentation explains the different parts of the Plug framework and how they can be used, customised, and extended. Beyond Plug itself, look for community-maintained extensions to add even more functionality.
A minimal Application
Eager to get started? This page gives a good introduction to Retort. Follow Installation to set up a project and install Retort first.
A minimal Retort application looks something like this:
defmodule App do
use Plug.Router
plug(Plug.Logger)
plug(:match)
plug(:dispatch)
get("/", do: send_resp(conn, 200, "<p>Hello, World!</p>"))
end
Plug.Cowboy.http(App, [])
IO.puts("* Serving Retort app 'Hello'")
IO.puts("* Running on http://127.0.0.1:4000 (Press CTRL+C to quit)")
So what did that code do?
- First we defined the App module which will be our entrypoint.
- We then use the
Plug.Router
library in our module to inherit all of Plug's fancy-pants routing macros. Subsequent 3 lines define order of operations. They tell the Plug to log requests, match the route, and finally dispatch the reuqest. - With a
get
macro and a route, we define a function which returns the message we want to display in the user’s browser. The default content type is HTML, so HTML in the string will be rendered by the browser. - Next we ask the Plug library to use our module as an entrypoint.
To run the application, use the iex
command.
$ iex app.exs
* Serving Retort app 'Hello'
* Running on http://127.0.0.1:4000 (Press CTRL+C to quit)
This launches a very simple Cowboy server, which is good enough for development, testing and production. Now head over to http://127.0.0.1:4000/, and you should see your hello world greeting.
If another program is already using port 4000, you’ll see Failed to start Ranch listener Hello.HTTP ... for reason :eaddrinuse (address already in use)
when the server tries to start. Just specify a different port in your application, and you're good to go: Plug.Cowboy.http(App, [], port: 4001)
Externally Visible Server
If you run the server you will notice that the server is accessible both from your own computer, and from any other in the network.
Your application is listening on all public IPs.
Debug Mode
Our Retort server can do more than just start the server. By enabling debug mode, the server will show an interactive debugger in the browser if an error occurs during a request.
if Mix.env == :dev do
use Plug.Debugger, otp_app: :my_app
end
That’s all folks. Some recipes for the end
We can always take it further. Add templating? Always return JSON? its super easy to do with Plug. And that’s the kicker, the conventions are so idiomatic and well documented, that you won’t have to invent new syntax or craft special google search queries — it’s just Elixir…
I guess the only tip is to be careful about when it’s too much. At some point its easier to install Phoenix and strip it down ( phx.new
has some great flags) than to do this. But if you need a 1-file solution, Plug is it.
How do I install dependencies? There are no virtual environments or lockfiles, just install them at runtime.
Mix.install([{:jason, "~> 1.4"},{:plug_cowboy, "~> 2.6"}])
Respond with JSON:
get "/" do
conn
|> put_resp_content_type("application/json")
|> send_resp(status_code, Jason.encode!(%{test: :ok}))
end
Need a healthcheck?
get("/ping", do: send_resp(conn, 200, "pong!"))
Need fancy Phoenix style respond syntax with defaults?
get("/", do: respond(conn, {:ok, %{test: :ok}}))
defp respond(conn, {:error, status_code}) do
code = Plug.Conn.Status.code(status_code)
type = Plug.Conn.Status.reason_atom(code)
message = Plug.Conn.Status.reason_phrase(code)
respond(conn, {status_code, %{code: code, type: type, message: message}})
end
defp respond(conn, {status_code, data}) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status_code, Jason.encode!(data))
end
Wish to gracefully handle all throws?
use Plug.ErrorHandler
defp handle_errors(conn, %{kind: kind, reason: reason, stack: stack}) do
type = Plug.Conn.Status.reason_atom(conn.status)
message = Plug.Conn.Status.reason_phrase(conn.status)
respond(conn, {conn.status, %{message: message, code: conn.status, type: type, message: message}})
end
Binary releases? With a simple change to using a mix file, you can make binary releases and ship apps like you would in Go or Rust.
mix new app
MIX_ENV=prod mix release
$ _build/prod/rel/my_app/bin/my_app start