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