Introduction
When building an API in Elixir, the default choice is Phoenix, and for good reason. But for a small service you don’t need all of it. Plug — the layer Phoenix itself is built on — is enough on its own, and building directly on it is a good way to see how HTTP handling actually works.
What is Plug?
Plug is a specification for composing modules that handle HTTP requests. Two concepts carry it:
Plug.Conn: the data structure holding everything about the connection, request and response.- Plugs: modules (or functions) that take a
conn, transform it, and return it.
Setting up the project
Create a new Mix project with a supervisor:
mix new my_api --sup
Add plug_cowboy (Plug plus the Cowboy web server) to mix.exs:
defp deps do
[
{:plug_cowboy, "~> 2.0"}
]
end
Then mix deps.get.
A first plug
A plug implements init/1 and call/2. call/2 receives a Plug.Conn and
returns the modified one:
defmodule MyAPI.HelloPlug do
import Plug.Conn
def init(options), do: options
def call(conn, _opts) do
conn
|> put_resp_content_type("application/json")
|> send_resp(200, ~s({"message": "Hello, world!"}))
end
end
Routing
Plug.Router maps paths to responses:
defmodule MyAPI.Router do
use Plug.Router
plug :match
plug :dispatch
get "/hello" do
send_resp(conn, 200, "Welcome to the Elixir API!")
end
match _ do
send_resp(conn, 404, "Not Found")
end
end
Starting the server
Start the router from your application’s supervisor:
defmodule MyAPI.Application do
use Application
def start(_type, _args) do
children = [
{Plug.Cowboy, scheme: :http, plug: MyAPI.Router, options: [port: 4000]}
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyAPI.Supervisor)
end
end
Run iex -S mix and visit http://localhost:4000/hello.
A JSON endpoint
Now something more realistic: accept a JSON payload, process it, and reply in
JSON. Add jason for JSON handling:
defp deps do
[
{:plug_cowboy, "~> 2.0"},
{:jason, "~> 1.4"}
]
end
The important decision is who parses the body. Rather than reading the raw
body stream by hand, we let Plug.Parsers do it in the router — it reads the
request once and leaves the result in conn.body_params. The endpoint plug
then just works with that parsed map:
defmodule MyAPI.UserPlug do
import Plug.Conn
def init(options), do: options
def call(conn, _opts) do
if json_request?(conn) do
handle(conn)
else
send_json(conn, 415, %{error: "Unsupported Media Type"})
end
end
defp handle(conn) do
case conn.body_params do
%{"name" => name, "age" => age} ->
send_json(conn, 200, %{
message: "User data received",
data: %{name: name, age: age, status: (if age >= 18, do: "Adult", else: "Minor")}
})
_ ->
send_json(conn, 400, %{error: "Expected a JSON object with name and age"})
end
end
defp json_request?(conn) do
conn
|> get_req_header("content-type")
|> Enum.any?(&String.starts_with?(&1, "application/json"))
end
defp send_json(conn, status, body) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(body))
end
end
Wiring it up
Plug.Parsers goes in the router, before :dispatch, so the body is parsed
by the time the endpoint runs:
defmodule MyAPI.Router do
use Plug.Router
plug Plug.Parsers,
parsers: [:json],
pass: ["application/json"],
json_decoder: Jason
plug :match
plug :dispatch
post "/users" do
MyAPI.UserPlug.call(conn, [])
end
match _ do
send_resp(conn, 404, "Not Found")
end
end
That’s the piece the two-parser trap catches people on: if you let
Plug.Parsers read the body and call Plug.Conn.read_body/1 yourself, the
second read comes back empty, because the body stream was already consumed.
Pick one. Here, Plug.Parsers owns it, and the plug reads conn.body_params.
Testing it
curl -X POST http://localhost:4000/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "age": 30}'
{
"message": "User data received",
"data": { "name": "Alice", "age": 30, "status": "Adult" }
}
A malformed or non-JSON request gets a 400; a wrong content type gets a
415.
Why Plug only
Building straight on Plug gives you fine-grained control over each request and response, with a small codebase and no framework conventions to learn. It’s a good fit for a lightweight service, and a great way to understand what Phoenix is doing for you the rest of the time. When the app grows real views, channels or a database layer, that’s the moment to reach for Phoenix.
Have comments or want to discuss this topic?
Send an email to ~bounga/public-inbox@lists.sr.ht