Elixir is a modern functional programming language that thrives on performance, scalability, and maintainability. When building APIs in Elixir, most developers typically opt for a framework like Phoenix for its out-of-the-box capabilities and conventions. However, you can also create an API with just Plug, allowing for a leaner, more minimalistic approach. Plug is a powerful library that enables direct interaction with HTTP requests and responses, making it an excellent tool for those who want full control over their application’s behavior.

What is Plug?

Plug is a specification for composing modules and handling HTTP requests in Elixir. It provides a simple and flexible API for building web applications or services. At its core, Plug defines two key concepts:

  • Plug.Conn: A data structure that represents the connection and holds request/response information.
  • Plug Modules: Components that implement the plug behavior to transform or process requests.

Setting Up Your Project

To start, you’ll need to create a new Mix project. Run the following command:

mix new my_api --sup

Next, add plug_cowboy to your dependencies in mix.exs:

defp deps do
  [
    {:plug_cowboy, "~> 2.0"}
  ]
end

Then, fetch the dependencies:

mix deps.get

Simple example

Writing Your First Plug

Plug allows you to create composable modules for handling various aspects of your API. Begin by creating a new module for your plug:

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

This module implements two functions, init/1 and call/2, as required by the Plug behavior. The call/2 function takes a Plug.Conn and processes it before returning the modified connection.

Setting Up the Router

To route incoming requests to your plug, you can use the Plug.Router module:

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 Application

To serve your API, you need to use a web server. Plug integrates seamlessly with Cowboy, a lightweight HTTP server. Update your application’s supervisor to start the router:

defmodule MyAPI.Application do
  use Application
  def start(_type, _args) do
    children = [
      {Plug.Cowboy, scheme: :http, plug: MyAPI.Router, options: [port: 4000]}
    ]
    opts = [strategy: :one_for_one, name: MyAPI.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Now, run your application:

iex -S mix

Visit http://localhost:4000/hello to see your API in action!

Advanced example

If you’re ready to go beyond the basics, let’s explore how to build a more complex API endpoint that handles JSON parsing in requests and sends responses in JSON format. We’re going to expand on our earlier example and create an endpoint that accepts a JSON payload, processes the data, and sends back a meaningful JSON response. Let’s break it down step by step.

Extending the Setup

Ensure your dependencies include plug_cowboy (for the HTTP server) and jason, a JSON library in Elixir, for parsing and encoding JSON. Update the deps section in mix.exs:

defp deps do
  [
    {:plug_cowboy, "~> 2.0"},
    {:jason, "~> 1.4"}
  ]
end

After adding these, fetch the dependencies with:

mix deps.get

Writing an Advanced Plug

Now, let’s create a Plug that accepts JSON data in the request body, processes it, and responds with JSON. For this, we will import functions from Plug.Conn and use Jason for JSON handling.

Create a new Plug module:

defmodule MyAPI.UserPlug do
  import Plug.Conn

  def init(options), do: options

  def call(conn, _opts) do
    # Ensure we have a valid JSON content-type in the request
    case get_req_header(conn, "content-type") do
      ["application/json"] ->
        # Read and parse the JSON payload
        {:ok, body, _conn} = Plug.Conn.read_body(conn)
        case Jason.decode(body) do
          {:ok, %{"name" => name, "age" => age}} ->
            # Process the JSON data and construct a response
            response = %{
              message: "User data received",
              data: %{
                name: name,
                age: age,
                status: if age >= 18, do: "Adult", else: "Minor"
              }
            }

            conn
            |> put_resp_content_type("application/json")
            |> send_resp(200, Jason.encode!(response))

          {:error, _reason} ->
            conn
            |> put_resp_content_type("application/json")
            |> send_resp(400, Jason.encode!(%{error: "Invalid JSON format"}))
        end

      _ ->
        # Handle missing or incorrect content-type
        conn
        |> put_resp_content_type("application/json")
        |> send_resp(415, Jason.encode!(%{error: "Unsupported Media Type"}))
    end
  end
end

Code Breakdown

Header Validation:

  • Checks if the request has a Content-Type of application/json. If not, it sends a 415 Unsupported Media Type response.

JSON Parsing:

  • Reads the request body using Plug.Conn.read_body/1.
  • Decodes the body into a map using Jason.decode/1. If decoding fails, sends a 400 Bad Request response with an error message.

Response Construction:

  • Extracts name and age from the request JSON.
  • Constructs a JSON response with a custom message and additional data, such as the user’s status (Adult or Minor).

Output:

  • Sends the response with a 200 OK status, along with the JSON-encoded data.

Updating the Router

To use this new plug, let’s update the router to add an endpoint for handling POST requests. We’ll also include Plug.Parsers to handle JSON requests gracefully:

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

What’s Happening Here?

Plug.Parsers:

  • This plug pre-processes incoming requests, allowing us to easily parse JSON payloads in the body.

Dynamic Plug Handling:

  • The /users endpoint delegates the logic to our MyAPI.UserPlug.

Fallback Match:

  • A 404 Not Found response is sent if no known route matches the incoming request.

Running and Testing the API

Start your application:

iex -S mix

Now you can test your API by sending a POST request with a JSON payload using a tool like curl, Postman, or even an HTTP client library.

Example Request using curl:

curl -X POST http://localhost:4000/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "age": 30}'

Example Response (status 200):

{
  "message": "User data received",
  "data": {
    "name": "Alice",
    "age": 30,
    "status": "Adult"
  }
}

If you send an invalid JSON payload, the API will reply with an appropriate error message (status 400). If the content type is missing or incorrect, it will return status 415.

Why This Matters

With this approach, you’re now able to handle robust, dynamic JSON data exchanges in your API endpoints. By leveraging Plug.Conn and libraries like Jason, you can create custom logic while still keeping your Elixir application lightweight and nimble.

Why Use Plug Only?

Building an API with just Plug gives you granular control over each request and response. Without the overhead of a full framework, you can achieve better performance and a smaller codebase, ideal for lightweight services or learning the internals of web development in Elixir.

Closing Thoughts

Using Plug for an Elixir API not only teaches you a lot about HTTP handling but also gives you the flexibility to build exactly what you need without the additional abstractions of larger frameworks. It’s perfect for simple APIs or small tools where minimalism is key. Experiment with Plug, and you’ll uncover new ways to harness the elegance and power of Elixir!

Have comments or want to discuss this topic?

Send an email to ~bounga/public-inbox@lists.sr.ht