Build a simple REST API with Elixir | Part 2

In the previous post, we covered the basics of building a simple HTTP server. If you reached this post directly, you might want to read the last post, as this is a continuation of that post.

Build a simple REST API with Elixir | Part 1
Build a simple, production-ready REST API with MongoDB in Elixir without Phoenix.

In the part of the course we will cover:

  • How to use the Config module to define configurations for different environments
  • Setup MongoDB locally and use a MongoDB client library to connect to it.
  • Update HTTP server to expose a health-check endpoint
GitHub - saswatds/elixir-rest-api at part-2
A simple REST API server is Elixir. Contribute to saswatds/elixir-rest-api development by creating an account on GitHub.

Let's get started

We often do not want to start the web server on the port 80 when developing locally as it is a privileged port. Ideally, we want to run the application locally in a port >1024 and when we are deploying to production, use port 80. We might also want to run our tests in a different port as we don't want to keep shutting down our dev-server whenever we want to test our application.

This all comes down to is that we need different configurations for different environments we want to run our application. We start with just the port, but later we find more use-case like the database configuration, etc.

Adding Config

Elixir provides us with two configuration entry points that it understands.

  • config/config.exs - This file is read at the build time, i.e. before we compile our application and load our dependencies. Protip: We can use this to control how our application is compiled.
  • config/runtime.exs - This file is read after compilation and configures how the application works at runtime. We can read system environment variables (System.get_env/1)  or any external configuration in this file.

Let's add the port configuration to the compile-time config. (It is very tempting to add it as a runtime config, but come on, how many times have you needed to change it?)

import Config

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
config/config.exs - Add new file

So what is happening in this file? We are importing the Config module and then using the import_config function to import an environment-specific configuration file. The config_env function returns the environment this file is being executed on. By default, mix starts with dev environment and test when running tests. The environment can be overridden using the MIX_ENV environment variable or performing a mix release.

So to cover all of our bases, we will create one config file for each of the dev, test and prod environments with the same signature.

import Config

config :rest_api, port: 8080
config/dev.env.exs - Add new file
import Config

config :rest_api, port: 8081
config/test.env.exs - Add new file
import Config

config :rest_api, port: 80
config/prod.env.exs - Add new file

The config function has two variants config/2 and config/3 . We are using the config/3 variant and app atom :rest_api as the root_key for defining the port key-value pair.

# config/2 
config :some_app,
  key1: "value1",
  key2: "value2"
  
# config/3
config :some_app, Key,
  key1: "value1",
  key2: "value2"
Variant of config

Now let's update the application.ex file to use the config instead of using the hardcoded port 8080.

def start(_type, _args) do
    children = [
      {
      	Plug.Cowboy, 
      	scheme: :http, 
        plug: RestApi.Router, 
        options: [port: Application.get_env(:rest_api, :port)]
      }
    ]

    ...
  end
lib/application.ex - update start function

If you have the mix application already running, then just run iex(1)> recompile or else run the application with iex -S mix run. Head over to Postman to check if the endpoint is still responding 🤞. (If you have followed all sets corrects, then it should all be working fine)

Setup MongoDB

No production service is complete without a database to store our data. There are a lot of different kinds of databases like SQL based Relational databases, No-SQL document databases, Timeseries databases, etc.

Over the last few years, MongoDB has become the most popular document-based No-SQL database, easy to set up and scale to production loads. Cloud providers like Amazon AWS also provide managed services like DocumentDB, which expose the same API as MongoDB.

We can always install MongoDB natively for our specific platform, but one of the simplest reproducible ways to set up a dev environment is by using Docker and Docker Compose.

Docker provides an easy-to-use executable to start running Docker on Windows, macOS or Linux.

Install Docker Engine
Lists the installation methods

Once Docker is installed, we want to create the docker-compose.yml configuration file in the root of the application.

💡
The docker-compose we have defined here is a bit unconventional as we are trying to start MongoDB with a replica-set which is close to how MongoDB would be configured for production usage.
version: "3"
services:
  mongodb:
    image: mongo
    container_name: mongodb
    ports:
      - 27017:27017
    restart: unless-stopped
    healthcheck:
      test: test $$(echo "rs.initiate({_id:\"rs0\",members:[{_id:0,host:\"localhost:27017\"}]}).ok || rs.status().ok" | mongo --port 27017 --quiet) -eq 1
      interval: 10s
      start_period: 30s
    command: "mongod --bind_ip_all --replSet rs0"

What we are doing here is telling MongoDB daemon to start with a replica-set rs0 and then using the health check feature provided by docker-compose to wait until all replicas are up and running.

Let's start MongoDB using Docker. Run the following command in your terminal. We are using the -d flag to run the container as a daemon to re-use the terminal.

docker compose up -d
Start MongoDB as a service using Docker.

You should see an output similar to this:

[+] Running 2/2
 ⠿ Network rest_api_default  Created                0.0s
 ⠿ Container mongodb         Started                0.6s


Once you are done with local development, don't forget to bring down the service using docker compose down.

While you are at it, go ahead and install Mongo Compass so that you can use the GUI client to look at your database.

MongoDB Compass Download
MongoDB Compass, the GUI for MongoDB, is the easiest way to explore and manipulate your data. Download for free for dev environments.

The default installation of MongoDB does not have any or username defined password, so you can connect to it using this connection string. mongodb://localhost:27017.

Next, go ahead and create a database rest_api_db and a collection users.

Connect to MongoDB

The MongoDB database is configured and running on our local system now. The only thing left is getting a MongoDB client library for elixir and connecting to the database.

We have to choices for this MongoDB and mongodb_driver. It seems that mongodb_driver is more recent appears to be actively developed; therefore, we will use it.

Let's update the mix.exs file to add it as a dependency. We will also install another dependency called poolboy to manage the connection pool.

defp deps do
  [
    {:plug_cowboy, "~> 2.5"},
    {:jason, "~> 1.3"},
    {:mongodb_driver, "~> 0.8"}
  ]
end
mix.exs - update deps function

Again, go ahead and do a mix deps.get to get the dependencies installed.

Next, we need to update lib/application.ex again to add Mongo to the supervisor.

def start(_type, _args) do
    children = [
      {
      	Plug.Cowboy, 
      	scheme: :http, 
        plug: RestApi.Router, 
        options: [port: Application.get_env(:rest_api, :port)]
      },
      {
        Mongo,
        [
          name: :mongo,
          database: Application.get_env(:rest_api, :database),
          pool_size: Application.get_env(:rest_api, :pool_size)
        ]
      }
    ]

    ...
  end
lib/application.ex - update start function

As you can also see that we have defined two more configurations :database and :pool_size we should add it to the respective config file. For now, let's update all of the env files with the same database name and pool size.

import Config

config :rest_api, port: 8080
config :rest_api, database: "rest_api_db"
config :rest_api, pool_size: 3

config/dev.env.exs - update config
import Config

config :rest_api, port: 8081
config :rest_api, database: "rest_api_db"
config :rest_api, pool_size: 3

config/test.env.exs - update config
import Config

config :rest_api, port: 80
config :rest_api, database: "rest_api_db"
config :rest_api, pool_size: 3

config/prod.env.exs - update config

Go ahead and recompile the application. You will not be able to see any difference yet. Let's now write a simple health check endpoint that will ping the database and tell us if we are connected to the database.

Health check Endpoint

The health check endpoint is very simple. We send a ping command to MongoDB and respond with 200 OK status code on success. In this way, we will know if ever our database goes down and we need to halt operations.

 ...	
  get "/" do
    send_resp(conn, 200, "OK")
  end

  get "/knockknock" do
    case Mongo.command(:mongo, ping: 1) do
      {:ok, _res} -> send_resp(conn, 200, "Who's there?")
      {:error, _err} -> send_resp(conn, 500, "Something went wrong")
    end
  end
  
  ...

lib/router.ex - add new route

Let's go through this line-by-line. First, we add a new endpoint GET /knockknock that will act as our health check endpoint. If everything is ok, the endpoint should return 200 OK with the message `Who's there?`.

Next, use a case statement to pattern match the result of Mongo.command function to respond 200 if success or respond 500 in case of an error.

The first parameter of Mongo.command is the name of our MongoDB GenServer, which we had defined to be the atom :mongo.

Now go ahead and recompile your application again and test out using Postman if the health-check endpoint is working.

Next

Now, this is the end of part 2. To sum up, what we have learned so far, you now know how to define compile-time configuration for your applications, you know how to set up MongoDB locally using Docker, and you can use the Mongo client to talk to the database.

In the next post, we will set up the CRUD endpoints for a resource.

Build a simple REST API with Elixir | Part 3
Build a simple, production-ready REST API with MongoDB in Elixir without Phoenix.
If you liked this tutorial then please support me by sharing this post with your friends and, if you like, consider becoming a member (it's free). 🎉

Disclaimer: The ideas and opinions in this post are my own and not the views of my employer.