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:
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.
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.
For decoding JSON we are using Jason, which we had to add to mix deps {:jason, "~> 1.2"},
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)
very informative, thank you Milan