How to build a lightweight webhook or JSON API endpoint in Elixir

Sometimes you just want a simple base for a webhook or JSON API in Elixir, e.g. for a small microservice.

Phoenix is nice, but much like Rails it comes with a lot of extra baggage that you may not need such as templating, database drivers, handling CSRF and other such web-frameworky things.

There are many cases where it makes more sense to start with a lightweight, barebones endpoint and build from there. Here I’ll walk through the process of creating a simple Hello World app using Plug, which is a bit like a combination of Rack/Sinatra for Elixir.

The endpoint will receive a JSON payload containing your name and return a response saying hello. You can adapt and extend this basic template for your own purposes.

Let’s get started. Firstly you’ll want to create a regular mix app:

$ mix new hello_webhook
$ cd hello_webhook

Now we’ll need the cowboy HTTP server and Plug framework. So make sure your mix.exs file looks like this. Most of the hard setup work is handled for us by the application/0 function provided to us by Mix.

# ./mix.exs
defmodule HelloWebhook.Mixfile do
  use Mix.Project

  def project do
    [app: :hello_webhook,
     version: "0.1.0",
     elixir: "~> 1.4", # yours may differ
     build_embedded: Mix.env == :prod,
     start_permanent: Mix.env == :prod,
     deps: deps()]
  end

  def application do
    [extra_applications: [:logger],
     mod: {HelloWebhook, []}] # This tells OTP which module contains our main application, and any arguments we want to pass to it
  end

  # The version numbers listed here are latest at the time of writing, you
  # should check each project and use the latest version in your code.
  defp deps do
    [
      {:cowboy, "~> 1.1"},
      {:plug, "~> 1.3"},
      {:poison, "~> 3.0"}, # NOTE: Poison is necessary only if you care about parsing/generating JSON
    ]
  end
end

Make sure to install the new dependencies.

$ mix deps.get

Now we need to implement our application. This is a bit of boilerplate that goes in ./lib/hello_webhook.ex and implements the standard OTP application behaviour. This behaviour defines two callbacks, start/2 and stop/1. For our purposes we only really care about start/2 so let’s implement that and point it to the HelloWebhook.Endpoint module which we shall create shortly.

#./lib/hello_webhook.exs
defmodule HelloWebhook do
  @moduledoc "The main OTP application for HelloWebhook"

  use Application

  def start(_type, _args) do
    import Supervisor.Spec, warn: false

    children = [
      worker(HelloWebhook.Endpoint, [])
    ]

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

We’re almost done at this point, if you can believe it. All that remains now is to actually create our endpoints and routes. Create a new directory called ./lib/hello_webhook and a new file ./lib/hello_webhook/endpoint.ex.

Here’s the code for our Hello Webhook application:

# ./lib/hello_webhook/endpoint.ex
defmodule HelloWebhook.Endpoint do
  use Plug.Router
  require Logger

  plug Plug.Logger
  # NOTE: The line below is only necessary if you care about parsing JSON
  plug Plug.Parsers, parsers: [:json], json_decoder: Poison
  plug :match
  plug :dispatch

  def init(options) do
    options
  end

  def start_link do
    # NOTE: This starts Cowboy listening on the default port of 4000
    {:ok, _} = Plug.Adapters.Cowboy.http(__MODULE__, [])
  end

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

  post "/hello" do
    {status, body} =
      case conn.body_params do
        %{"name" => name} -> {200, say_hello(name)}
        _ -> {422, missing_name()}
      end
    send_resp(conn, status, body)
  end

  defp say_hello(name) do
    Poison.encode!(%{response: "Hello, #{name}!"})
  end

  defp missing_name do
    Poison.encode!(%{error: "Expected a \"name\" key"})
  end
end

Now visit http://localhost:4000/hello in your browser, you should see your hello message.

Alright! Let’s quickly test this with curl. Start your server with iex -S mix.

Or you can use curl:

$ curl http://localhost:4000/hello
Hello, world!

Great. Now what if we want to supply our own name?

$ curl -H "Content-Type: application/json" -X POST -d '{}' http://localhost:4000/hello
{"error":"Expected a \"name\" key"}

Oops, better send a correctly formatted request.

$ curl -H "Content-Type: application/json" -X POST -d '{"name":"Sam"}' http://localhost:4000/hello
{"response":"Hello, Sam!"}

Hooray! It works. One thing to note is that unlike Phoenix, this app will not auto-reload when you change your code files. You must restart your iex -S mix process to see the new changes take effect.

That’s pretty much it for this simple Hello World app, you could take this skeleton template and build your own perfectly functional webhook endpoint using it.

But there are a couple more things we can do to improve it, namely setting up environment-specific configuration and adding some tests.

We’ll add an ExUnit test for both the success and fail cases of POST /hello.

# ./test/hello_webhook_test.exs
defmodule HelloWebhookTest do
  use ExUnit.Case, async: true
  use Plug.Test
  doctest HelloWebhook

  @opts HelloWebhook.Endpoint.init([])

  test "GET /hello" do
    # Create a test connection
    conn = conn(:get, "/hello")

    # Invoke the plug
    conn = HelloWebhook.Endpoint.call(conn, @opts)

    # Assert the response and status
    assert conn.state == :sent
    assert conn.status == 200
    assert conn.resp_body == "Hello, world!"
  end

  test "POST /hello with valid payload" do
    body = Poison.encode!(%{name: "Sam"})

    conn = conn(:post, "/hello", body)
      |> put_req_header("content-type", "application/json")

    conn = HelloWebhook.Endpoint.call(conn, @opts)

    assert conn.state == :sent
    assert conn.status == 200
    assert Poison.decode!(conn.resp_body) == %{"response" => "Hello, Sam!"}
  end

  test "POST /hello with invalid payload" do
    body = Poison.encode!(%{namu: "Samu"})

    conn = conn(:post, "/hello", body)
      |> put_req_header("content-type", "application/json")

    conn = HelloWebhook.Endpoint.call(conn, @opts)

    assert conn.state == :sent
    assert conn.status == 422
    assert Poison.decode!(conn.resp_body) == %{"error" => "Expected a \"name\" key"}
  end
end

Assuming you left your original server running, when you try to run these tests you might see the following error:

$ mix test

=INFO REPORT==== 29-Apr-2017::10:46:58 ===
    application: logger
    exited: stopped
    type: temporary
** (Mix) Could not start application hello_webhook: HelloWebhook.start(:normal, []) returned an error: shutdown: failed to start child: HelloWebhook.Endpoint
    ** (EXIT) an exception was raised:
        ** (MatchError) no match of right hand side value: {:error, :eaddrinuse}
            (hello_webhook) lib/hello_webhook/endpoint.ex:16: HelloWebhook.Endpoint.start_link/0

This is because our test server is trying to run on the same port as our development server (which is port 4000 by default). We can fix this by adding some enviroment-speciic configuration using Mix.Config which is the canonical way to configure your mix app.

Let’s dive into our config file and uncomment the bottom line so we can add environment-specific configuration:

# ./config/config.exs`
# This file is responsible for configuring your application
# and its dependencies with the aid of the Mix.Config module.
use Mix.Config

# This configuration is loaded before any dependency and is restricted
# to this project. If another project depends on this project, this
# file won't be loaded nor affect the parent project. For this reason,
# if you want to provide default values for your application for
# 3rd-party users, it should be done in your "mix.exs" file.

# You can configure for your application as:
#
#     config :hello_webhook, key: :value
#
# And access this configuration in your application as:
#
#     Application.get_env(:hello_webhook, :key)
#
# Or configure a 3rd-party app:
#
#     config :logger, level: :info
#

# It is also possible to import configuration files, relative to this
# directory. For example, you can emulate configuration per environment
# by uncommenting the line below and defining dev.exs, test.exs and such.
# Configuration from the imported file will override the ones defined
# here (which is why it is important to import them last).
#
import_config "#{Mix.env}.exs" # NOTE: uncomment this line

Note that Mix.Config overwrites previous values with new ones, so any configuration specified in one of your env files will override the main configuration in config.exs.

You will need to add three config files, each corresponding to a Mix env.

# ./config/dev.exs
use Mix.Config

config :hello_webhook, port: 4000

# ./config/prod.exs
use Mix.Config

# NOTE: Use $PORT environment variable if specified, otherwise fallback to port 80
port =
  case System.get_env("PORT") do
    port when is_binary(port) -> String.to_integer(port)
    nil -> 80 # default port
  end

config :hello_webhook, port: port

# ./config/test.exs
use Mix.Config

config :hello_webhook, port: 4001

We have to tell our application server that it should use the port specified in configuration, so modify your HelloWebhook.Endpoint.start_link/0 function to look like this:

# ./lib/hello_webhook/endpoint.ex
# ...
def start_link do
  port = Application.fetch_env!(:hello_webhook, :port)
  {:ok, _} = Plug.Adapters.Cowboy.http(__MODULE__, [], port: port)
end
# ...

Now let’s try running mix test again. You should see green dots:

$ mix test
12:18:56.712 [info]  GET /hello
12:18:56.716 [info]  Sent 200 in 4ms
.
12:18:56.721 [info]  POST /hello
12:18:56.725 [info]  Sent 200 in 4ms
.
12:18:56.725 [info]  POST /hello
.
12:18:56.725 [info]  Sent 422 in 50µs

Finished in 0.06 seconds
3 tests, 0 failures

That’s pretty much it. As you can see, it’s extremely straightforward to set up a basic Plug app using Elixir that could be the basis for a JSON API, or any number of web microservices. Phoenix is nice but you can get a lot done without it.

I describe how to deploy your Plug app to Heroku here.

PS. In case you run into any trouble, the full source for this app is available on github. Feel free to clone it and use it as a template to build your own microservices in Elixir.