DEV Community

Milan Pevec
Milan Pevec

Posted on

Understanding Elixir Plug

The goal of today's blog is to understand what is Elixir Plug and how to use it to write REST API in Elixir without the Phoenix framework. Let's do this.

Plug

Following official doc one of the definitions of the Plug is:

"Connection adapters for different web servers in Erlang VM"

Let's try to understand this sentence.
The Connection is a data structure (struct) encapsulating data from a web server that lays beneath. It containing data about the host, method, path, scheme, port, status, etc.

For the webserver in Erlang VM we can use cowboy.

Adapting a connection with adapters means changing its data structure and returning a new version of it (immutability).

Great. All clear now. It's all about adopting/manipulating connections. Let's show with the examples how can we achieve that in the Elixir way.

Plug, as simple as possible

Let's create a new project with supervisor support:

mix new app —sup

and add the dependency named plug_cowboy. This package contains the group of dependencies for a cowboy (webserver), plug and telemetry (for measurements):

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

If we take the Plug example from official pages:

defmodule MyPlug do
  import Plug.Conn

  def init(options) do
    # initialize options
    options
  end

  def call(conn, _opts) do
    conn
    |> put_resp_content_type("text/plain")
    |> send_resp(200, "Hello world")
  end
end

we can see the usage of the so-called Module Plug (there is also a functional version).

If we want a module to act as a plug then we need to implement init/1 and call/2 functions. With init/1 we can set options, but more important with call/2 we perform the adaption of the connection. We use Plug.Conn functions like put_resp_content_type/1 for adaptation.

As final step lets change also the generated application module and we are ready to go:

defmodule App5.Application do
  use Application

  def start(_type, _args) do
    children = [
      {Plug.Cowboy, scheme: :http, plug: MyPlug, options: [port: 4001]}
    ]

    opts = [strategy: :one_for_one, name: App5.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

You can see, that the initial Plug is defined as the MyPlug from above.

If we run this with classical iex -S mix (which compiles and runs our app) you can go on localhost:4001 and see the response "Hello world".
In official docs it's written that we can/should run this without iex process, with mix run —no-halt, but for sake of this blog we did it with iex.

What's also important in the above example is that the plug adapts connection for every request, so even for a request like:

http://localhost:4001/favicon.ico

we have the same "hello world" response, which is actually not what we want.

If only I could match?

So what do we need to do for a matching ? We are still adopting a connection so we still need a plug, but we will use the connection structure for routing.
Based on attributes like request method (GET, POST,..) and request URL, the plug will route incoming requests to different adaption logics:

defmodule MyPlug do
    import Plug.Conn

    def init(options) do
        # initialize options
        options
    end

    def call(conn, _opts) do
        match(conn.method, conn.path_info, conn)
    end

    defp match("GET", ["hello"], conn) do
        send_resp(conn, 200, "world")
    end

    defp match(_, _, conn) do
        send_resp(conn, 404, "oops")
    end
end

What we can see above is that we use method and path_info for Elixir function matching. In case of GET and path hello we route to response "world" otherwise we route to 404.

In practice we don't do this although we could, we rather use provided Plug.Router which offers much more.

Plug.Router

The charm of Plug.Router is, that it provides the DSL for matching/dispatching, which makes our code cleaner and error-prone. Let's rewrite the above example using Router.

First, we will replace the configuration for Plug.Cowboy:

children = [
    {Plug.Cowboy, scheme: :http, plug: AppRouter, options: [port: 4001]}
]

where we replaced MyPlug with AppRouter.

Then we will create AppRouter module with the use of Plug.Router:

defmodule AppRouter do
  use Plug.Router

    plug :match
    plug :dispatch

    match "/hello", via: :get do
      send_resp(conn, 200, "world")
    end

    match _ do
      send_resp(conn, 404, "oops")
    end
end

As you can see, we are using Plug.Router DSL, particularly match function which does all the work.

Give me some routes, please

We can go even further and replace match function with the following route definition:

get "/hello" do
  send_resp(conn, 200, "world")
end

When I first time saw this, I had a big AHA moment, like its usually a case with learning Elixir.

Let's break the route definition into pieces. First, we define the route request method (GET, POST, PUT), then we define route request path (/hello) and the last we define the route do/end block. What happens with those pieces?

Pipeline

We haven't talked yet about the two lines above:

plug :match
plug :dispatch

Every use of Plug.Router needs to have by default those two lines of code - so-called plug pipeline.
Plug pipeline you say? Yes, that means :match and :dispatch are also plugs.
The :match plug is responsible for matching by using method and path of the route. Behind the scenes all routes are compiled to match functions, like we saw above. The do/end block is stored as a function in the connection itself and is retrieved and called then by :dispatch plug.

As a little note: we can already notice another mechanism here - in order to share something between plugs we store information inside the connection.

So all three little pieces of the route are now used, but if by default match/dispatch are always needed, why do we need to write them down? The reason lies in the definition of the pipeline itself! We can add other plugs in the pipeline too and we can add them before or after a specific plug. The typical case is a plug responsible for parsing JSON.

REST API

Now that we understand what plug is, we can show an example of GET request with path parameter, query parameter, and an example of a typical POST request and response with application/json mime type:

defmodule AppRouter do
    use Plug.Router

    if Mix.env == :dev do
      use Plug.Debugger
    end

    import Plug.Conn

    plug :match
    plug Plug.Parsers, parsers: [:json],
                      pass: ["application/json"],
                      json_decoder: Jason
    plug :dispatch

    get "/hello" do
        # query parameter is user like this:
    # http://localhost:4001/hello?name=John
    # which will create %{"name" => "John"} 
      send_resp(conn, 200, "hello #{Map.get(conn.query_params, "name")}")
    end

    get "/hello/:name" do
      send_resp(conn, 200, "hello #{name}")
    end

    post "/hello" do
      # json body of POST request {"name": "John"} is parsed to %{"name" => "John"} 
      # so it can be accesable with e.g. Map.get(conn.body_params, "name") or with pattern matching
      name = 
        case conn.body_params do
          %{"name" => a_name } -> a_name
          _ -> ""
        end

      # returning JSON: {"id":1, "name": name}
      response = %{"id" => 1, "name" => name}
      send_resp(conn |> put_resp_content_type("application/json"), 200, Jason.encode!(response))
    end

    match _ do
      send_resp(conn, 404, "oops")
    end

end

I will not go into details about parsers, you can read about them here: https://hexdocs.pm/plug/Plug.Parsers.html

But I will explain shortly the code:

  1. We are using Plug.Debugger in the router module for cases when an error occurs, like with the error of decoding json, a server responds with a nice error page.

  2. We put Plug.Parsers in the pipeline after :match which will parse query parameters of GET requests and put them into conn.query_params and which will parse the json body of POST requests and put them into conn.body_params.

  3. For decoding JSON we are using Jason, which we had to add to mix deps {:jason, "~> 1.2"},

  4. In the example of POST we are using pattern matching. We could easily change this code to validate json structure and in case of failure return 500. We also have added a response header (again, by manipulating connection) and we send json as a response as well.

Happy coding!

Top comments (1)

Collapse
 
mack_akdas profile image
Mack Akdas

very informative, thank you Milan