Build a simple REST API with Elixir | Part 3

Build a simple REST API with Elixir | Part 3

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

In the previous post, we covered how to define compile-time configuration, set up MongoDB locally using Docker, and configure the Mongo client to talk to the database. 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 2
Build a simple, production-ready REST API with MongoDB in Elixir without Phoenix.

In this part of the course we will cover:

  • Handling JSON data
  • Setting up CRUD endpoint for a post.
GitHub - saswatds/elixir-rest-api at part-3
A simple REST API server is Elixir. Contribute to saswatds/elixir-rest-api development by creating an account on GitHub.

Let's get started

For the sake of simplicity, our application will expose APIs with which we will be able to perform the following actions:

  • Create a Post
  • View the Post
  • Update the Post
  • Delete the Post
  • List all the Posts

The first order of business will be to design these endpoints using Postman. I have created a public workspace that contains the specification of the API in form of a collection.

Elixir Posts API
The Postman Documenter generates and maintains beautiful, live documentation for your collections. Never worry about maintaining API documentation again.

Now that we have a clear idea about what our interface looks like, we start the implementation and add code to our application.

The content type of data that we send across in our REST API calls is consistently JSON (Javascript Object Notation)[1].  

Trivia - JSON has been derived from Javascript language which is the defacto standard for scripting on the web for over two decades now. Although JSON is supposed to be language-agnostic it is not a first-class citizen in most languages, including Elixir. In elixir, we use the Jason library to be able to serialise and deserialize the JSON string.

The default Jayson encoder implementation is very simple and encodes the different data structures with a predefined algorithm. This behaviour is acceptable for the majority of the use cases, but there are certain cases where we might want to customize the serialisation algorithm.

An example of such a case where encoding customisation is required is during the encoding of MongoId.

Encoding MongoDB ObjectIds

MongoDB stores data in its database in the form of BSON (Binary JSON) which is a custom format designed by the MongoDB team back in 2009. Like all databases, the data in the database need to be indexed using a primary key.

The primary key of every collection in MongoDB is a special binary value called the ObjectID. It is so special that Jayson has no clue how to encode the ObjectId to JSON. So let's do that first.

defmodule RestApi.JSONUtils do
  @moduledoc """
  JSON Utilities
  """

  @doc """
  Extend BSON to encode MongoDB ObjectIds to string
  """
  # Defining a implementation for the Jason.Encode for BSON.ObjectId
  defimpl Jason.Encoder, for: BSON.ObjectId do
    # Implementing a custom encode function
    def encode(id, options) do
      BSON.ObjectId.encode!(id)  # Converting the binary id to a string
      |> Jason.Encoder.encode(options) # Encoding the string to JSON
    end
  end
end

lib/rest_api/json_utils.ex - create new file

💡Concept - Protocols

A few questions that might be coming to your curious mind could be

  • What just happened here?
  • What does defimp mean?
  • How are we able to change encoding behaviour with this?

The answer to all the questions is a special mechanism provided by Elixir called Protocols.

Protocols
Website for Elixir
Protocols are a mechanism to achieve polymorphism in Elixir when you want behavior to vary depending on the data type.

If you are familiar with Object-Oriented language, then such behaviour is usually implemented using Interfaces. Different data types defined a method enforced by an Interface to handle the polymorphism.

Let's take an example in typescript and contrast it with Elixir.

// Lets consider we have two vehicles a Car and Train for which we want to ensure we can locate them.

interface Locability {
 location: () => [longitude: number, latitude: number]
}

class Car {
 location () {/* Using the GPS */}
}

class Train {
 location () {/* From transponder or something */}
} 

The same implementation in Elixir would look something like this.

# Equivalent to an interface
defprotocol Locability do
  @spec type() :: {integer, integer}
  def location()
end

defimpl Locability, for: Car do
  def location(), do: # Using the GPS
end

defimpl Locability, for: Train do
  def location(), do: # For transponder or something
end

We observe that they are semantically the same but syntactically different.


Converting _id to id

Another small but important aspect that we need to handle is converting the _id key in a document returned by MongoDB to id. In MongoDB, the primary key is stored as _id  but we want the id to be id when we send the response in JSON.

 defmodule RestApi.JSONUtils do
  ...
  defimpl Jason.Encoder, for: BSON.ObjectID do ...
  end

  def normaliseMongoId(doc) do
    doc
    |> Map.put('id', doc["_id"]) # Set the id property to the value of _id
    |> Map.delete("_id") # Delete the _id property
  end
end

lib/rest_api/json_utils.ex - insert after defimpl Jason.Encoder ...

For the sake of brevity, let's alias RestApi.JSONUtils to just JSON so that it's for our eyes to read during it's usage.

 defmodule RestApi.Router do
   alias RestApi.JSONUtils, as: JSON

   use Plug.Router
   ...

lib/rest_api/router.ex - insert after defmodule RestApi.Router ...

Endpoints

Get all Posts

The HTTP endpoint to get all posts will be  GET /posts

 defmodule RestApi.Router do
  ...
  get "/knockknock" do ...
  end

  get "/posts" do
    posts =
      Mongo.find(:mongo, "Posts", %{}) # Find all the posts in the database
      |> Enum.map(&JSON.normaliseMongoId/1) # For each of the post normalise the id
      |> Enum.to_list() # Convert the records to a list
      |> Jason.encode!() # Encode the list to a JSON string

    conn
    |> put_resp_content_type("application/json") 
    |> send_resp(200, posts) # Send a 200 OK response with the posts in the body
  end

   match _ do
    send_resp(conn, 404, "Not Found")
  end

  ...

lib/rest_api/router.ex - insert after get "/knockknock"

Create a Post

The HTTP endpoint for creating a post will be POST /posts

defmodule RestApi.Router do
  get "/posts" do ...
  end

  post "/post" do
    case conn.body_params do
      %{"name" => name, "content" => content} ->
        case Mongo.insert_one(:mongo, "Posts", %{"name" => name, "content" => content}) do
          {:ok, user} ->
            doc = Mongo.find_one(:mongo, "Posts", %{_id: user.inserted_id})

            post =
              JSON.normaliseMongoId(doc)
              |> Jason.encode!()

            conn
            |> put_resp_content_type("application/json")
            |> send_resp(200, post)

          {:error, _} ->
            send_resp(conn, 500, "Something went wrong")
        end

      _ ->
        send_resp(conn, 400, '')
    end
  end

  match _ do
    send_resp(conn, 404, "Not Found")
  end
  ...

lib/rest_api/router.ex - insert after get "/posts"

Get a Post by Id

The HTTP endpoint for getting a post by id will be GET /post/:id

defmodule RestApi.Router do
  post "/post" do ...
  end

  get "/post/:id" do
    doc = Mongo.find_one(:mongo, "Posts", %{_id: BSON.ObjectId.decode!(id)})

    case doc do
      nil ->
        send_resp(conn, 404, "Not Found")

      %{} ->
        post =
          JSON.normaliseMongoId(doc)
          |> Jason.encode!()

        conn
        |> put_resp_content_type("application/json")
        |> send_resp(200, post)

      {:error, _} ->
        send_resp(conn, 500, "Something went wrong")
    end
  end

  match _ do
    send_resp(conn, 404, "Not Found")
  end
  ...

lib/rest_api/router.ex - insert after post "/post"

Update a Post by Id

The HTTP endpoint for updating a post by id will be PUT /post/:id

defmodule RestApi.Router do
  get "/post/:id" do ...
  end

  put "post/:id" do
    case Mongo.find_one_and_update(
           :mongo,
           "Posts",
           %{_id: BSON.ObjectId.decode!(id)},
           %{
             "$set":
               conn.body_params
               |> Map.take(["name", "content"])
               |> Enum.into(%{}, fn {key, value} -> {"#{key}", value} end)
           },
           return_document: :after
         ) do
      {:ok, doc} ->
        case doc do
          nil ->
            send_resp(conn, 404, "Not Found")

          _ ->
            post =
              JSON.normaliseMongoId(doc)
              |> Jason.encode!()

            conn
            |> put_resp_content_type("application/json")
            |> send_resp(200, post)
        end

      {:error, _} ->
        send_resp(conn, 500, "Something went wrong")
    end
  end

  match _ do
    send_resp(conn, 404, "Not Found")
  end
  ...

lib/rest_api/router.ex - insert after get "/post/:id"

Delete a Post by Id

The HTTP endpoint for deleting a post by id will be DELETE /post/:id

defmodule RestApi.Router do
  put "post/:id" do ...
  end
  
  delete "post/:id" do
    Mongo.delete_one!(:mongo, "Posts", %{_id: BSON.ObjectId.decode!(id)})

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(200, Jason.encode!(%{id: id}))
  end

  match _ do
    send_resp(conn, 404, "Not Found")
  end
  ...

lib/rest_api/router.ex - insert after put "/post/:id"

Run the server

I know we have not written any tests yet, but let's first manually test our application. To start running the application execute the following command on your terminal $iex -S mix run . If your application is already running, then just enter the command recompile.

The next step is to again use Postman to test out the endpoints with some sample data to see how it works. The collection already has a few examples that you can use to test out the endpoints.

Create a Post
List all Posts
Get the Post
Update the Post
Delete the Post

Next

This will be the end of part 3. To sum up, what we have learned so far,

  • You know how to configure Jason to work with MongoDB.
  • You know how to create REST endpoints with Plug.
  • You know how to query MongoDB to perform CRUD operations.
  • You know how to manually test your endpoints using Postman collections.

In the next post, we will add tests for our endpoints and understand what we can do to protect our endpoints from unauthorized access.

Build a simple REST API with Elixir | Part 4
In the previous, we created all of our required endpoints, understood how to work with MongoDB and did some manual testing. 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
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). 🎉

References

  1. JSON - RFC 7159 - https://datatracker.ietf.org/doc/html/rfc7159
  2. Protocol - https://hexdocs.pm/elixir/1.12.3/Protocol.html
  3. MongoDB Functions - https://hexdocs.pm/mongodb_driver/Mongo.html#functions

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

Great! Next, complete checkout for full access to saswat.dev.
Welcome back! You've successfully signed in.
You've successfully subscribed to saswat.dev.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info has been updated.
Your billing was not updated.