Building HTTP/JSON API In Gleam: Introduction

I previously built software using C# and .NET technologies, before switching to fullstack JavaScript some years back. That means I’m not scared about trying something new, and sticking to it if it appeals to me. After hearing two people speak passionately about Elixir and the robustness of the Erlang Virtual Machine (BEAM), my curiosity about this ecosystem was piqued. I was intrigued by the possibilities of the BEAM's concurrency model and its fault-tolerant design. Wanting to explore this further, I chose to try Gleam — a statically typed language that runs on the BEAM and promises the safety and reliability of a strong type system.

In this blog post, I want to share my experience building a basic REST API in Gleam, that accepts and returns JSON. This is the first of several experiments where I explore Gleam's capabilities and see how they into modern web development.

Pre-requisite

If you're interested in following along or trying it for yourself, you should have Gleam and Erlang installed on your system. Follow the installation instructions in the docs, if you use macOS, run the set of commands below:

brew update
brew install gleam

Project Set Up

Starting a new Gleam project is as simple as running the command gleam new todo_api. It creates a directory with a set of files and sub-directories needed to run the app.

We need an HTTP server and a framework that provides a nice abstraction for building web apps/APIs. For that, we’re going to add a couple of packages to the project:

  • Gleam HTTP: which contains types and functions for HTTP clients and servers.

  • mist: an HTTP web server written in Gleam.

  • Wisp: a web framework built on the concept of handlers and middleware.

  • gleam_json: a library for working with JSON data structure.

  • Gleam Erlang: a library for working with Erlang-specific code.

You may have realised that I said mist is a web server written in Gleam. That’s because you can use web servers (literally any code) written in Erlang or Elixir, in Gleam. There’s Gleam Cowboy, which is an adapter for the cowboy server that’s written in Erlang.

The Gleam Erlang library is used to create Erlang processes and send messages to them. Gleam’s concurrency model uses lightweight processes to manage asynchronous or long-running tasks. Each request is handled in its own process, unlike the event-loop model used in JavaScript runtimes.

I’m not particularly sure how it differs from the Gleam OTP package, but I think they’re related, and you’d need both if you’re writing Gleam OTP code. Perhaps I’m confused because of the gap in my knowledge about the Erlang VM and its APIs.

When I have a better understanding of Gleam and the BEAM’s concurrency model, I’ll write another blog post or make a YouTube video (Subscribe 😉).

Without further ado, we’ll add those packages to the project using the command below:

gleam add gleam_http@3.7.0 gleam_json@1.0.1 gleam_erlang@0.26.0 mist@2.0.0 wisp@1.1.0

Configure the HTTP Server

Let’s add code to start the web server when the application executes. Open the src/todo_api.gleam file and replace its content with this code:

import app/router
import gleam/erlang/process
import mist
import wisp
import wisp/wisp_mist

pub fn main() {
  // This sets the logger to print INFO level logs, and other sensible defaults
  // for a web application.
  wisp.configure_logger()

  // Here we generate a secret key, but in a real application you would want to
  // load this from somewhere so that it is not regenerated on every restart.
  let secret_key_base = wisp.random_string(64)

  // Start the Mist web server.
  let assert Ok(_) =
    wisp_mist.handler(router.handle_request, secret_key_base)
    |> mist.new
    |> mist.port(8000)
    |> mist.start_http

  // The web server runs in new Erlang process, so put this one to sleep while
  // it works concurrently.
  process.sleep_forever()
}

The code comments explain what each step of the code does. We configure a log level using wisp.configure_logger(), then start the server by calling wisp_mist.handler() which needs a request handler and a secret key (secret_key_base). We’ll create the router.handle_request() function later, but the secret key is a random string for this example.

I don’t know what the secret key is used for and why it’s required, the documentation doesn’t give any clue. When I figure out what it’s used for, I’ll update this post or write about it in one of my future blog post.

wisp_mist handler function definition

The rest of that call chain uses the pipe operator to call functions to start the server. At the end, we call process.sleep_forever() on the current process, so that the application keeps running, while the web server listens and handles requests in another process.

Create the Application’s Middleware Stack

Let’s create a middleware that will log request info, automatically handle requests for the HEAD method, and automatically return a 500 status code when a request handler process crashes. For that, create a new file app/web.gleam and paste the code snippet below into it:

import wisp

pub fn middleware(
  req: wisp.Request,
  handle_request: fn(wisp.Request) -> wisp.Response,
) -> wisp.Response {
  // Log information about the request and response.
  use <- wisp.log_request(req)

  // Return a default 500 response if the request handler crashes.
  use <- wisp.rescue_crashes

  // Rewrite HEAD requests to GET requests and return an empty body.
  use req <- wisp.handle_head(req)

  // Handle the request!
  handle_request(req)
}

Add Router and Request handlers

Finally, we’re going to write request handlers for various routes that we want to have in the application. Here’s the code to do just that:

import wisp.{type Request, type Response}
import gleam/string_builder
import gleam/http.{Get, Post, Put, Delete}
import app/web

pub fn handle_request(req: Request) -> Response {
  use req <- web.middleware(req)

  // Wisp doesn't have a special router abstraction, instead we recommend using
  // regular old pattern matching. This is faster than a router, is type safe,
  // and means you don't have to learn or be limited by a special DSL.
  //
  case wisp.path_segments(req) {
    // This matches `/`.
    [] -> home_page(req)

    // This matches `/todos`.
    ["todos"] -> todos(req)

    // This matches `/todos/:id`.
    // The `id` segment is bound to a variable and passed to the handler.
    ["todos", id] -> handle_todo(req, id)

    // This matches all other paths.
    _ -> wisp.not_found()
  }
}

fn home_page(req: Request) -> Response {
  // The home page can only be accessed via GET requests, so this middleware is
  // used to return a 405: Method Not Allowed response for all other methods.
  use <- wisp.require_method(req, Get)
  let html = string_builder.from_string("Welcome to Gleam! Let's build with wisp.")

  wisp.ok()
  |> wisp.html_body(html)
}

fn todos(req: Request) -> Response {
  // This handler for `/todos` can respond to both GET and POST requests,
  // so we pattern match on the method here.
  case req.method {
    Get -> list_todos()
    Post -> create_todo(req)
    _ -> wisp.method_not_allowed([Get, Post])
  }

}

fn list_todos() -> Response {
  // In a later example we'll show how to read from a database.
  let html = string_builder.from_string("todos!")
  wisp.ok()
  |> wisp.html_body(html)
}

fn create_todo(_req: Request) -> Response {
  // In a later example we'll show how to parse data from the request body.
  let html = string_builder.from_string("Created")
  wisp.created()
  |> wisp.html_body(html)
}

fn handle_todo(req: Request, id: String) -> Response {
  case req.method {
    Get -> show_todo(req, id)
    Put -> update_todo(req, id)
    Delete -> delete_todo(req, id)
    _ -> wisp.method_not_allowed([Get, Put, Delete])
  }
}

fn update_todo(_req: Request, id: String) -> Response {
  let html = string_builder.from_string("Updated todo with id " <> id)
  wisp.created()
  |> wisp.html_body(html)
}

fn delete_todo(_req: Request, id: String) -> Response {
  let html = string_builder.from_string("Deleted todo with id " <> id)
  wisp.created()
  |> wisp.html_body(html)
}

fn show_todo(_req: Request, id: String) -> Response {
  let html = string_builder.from_string("todo with id " <> id)
  wisp.ok()
  |> wisp.html_body(html)
}

Now that’s a lot of code! Let’s break it down.

Wisp doesn’t have a router. Instead, you use pattern matching to decide which request handler function needs to handle the request for specific paths. We do pattern matching on the value returned from wisp.path_segments(req), when the request doesn’t match any of the expected routes, we return 404 using wisp.not_found() utility function.

Pattern matching is vital when using Gleam, because there’s no if/else or switch statements which most of us are used to.

The request handler for the various paths returns plain HTML text, but you’ll see how to return a JSON response later. There are a few utility functions used in the code, and here’s what they’re for:

  • The home_page() request handler is expected to only process GET requests, therefore, using wisp.require_method(req, Get) checks the request’s method and returns a "405 Method Not Allowed " response if it’s not a GET request.

  • The utility function wisp.method_not_allowed([Get, Post]) is used to return a 405 response, with the allowed methods passed as arguments.

  • Response helpers like wisp.created() and wisp.ok() create Empty responses with 201 and 200 status codes respectively.

Summary

You’ve seen how to create a web server and handle various routes using wisp. I kept it small so that it’s easy to read and digest, but also easier to write, without filling this post with about 5,000 words. The next post will focus on data access and working with JSON data.

Subscribe to this blog 👇🏽 if you want to get notified when the follow-up post is published. You can find the code for this post on GitHub.

Did you find this article valuable?

Support Peter's Blog by becoming a sponsor. Any amount is appreciated!