Building a REST API in Scala 3 using Iron and Cats

Overview

Cats is a good ecosystem to create concurrent REST APIs, thanks to various cats-based libraries: http4s, circe, cats-effect etc...

In this tutorial, we're going to show the synergy between these libraries and Iron, a type constraint system for Scala.

Simple Cats/Http4s API

We're going to build a minimal example of a REST API using the Cats ecosystem and Iron.

This API will provide a pseudo register system via /register asking for a JSON body

{
  "_comment": "Example request body",
  "username":"Iltotore",
  "email":"[email protected]",
  "password":"Abc123"
}

and returning the newly created Account as JSON.

Setup the project

Firstly, we need to setup our dependencies. In this tutorial, I will use the Mill build tool.

We start with a simple Scala module in our build.sc:

import mill._, scalalib._

object main extends ScalaModule {

  def scalaVersion = "3.0.0"
}

In this tutorial we will use the following libraries:

Our build.sc should now look like this:

import mill._, scalalib._

object main extends ScalaModule {

  def scalaVersion = "3.0.0"

  def http4sVersion = "0.23.0-RC1"

  //Http4s dependencies
  ivy"org.http4s::http4s-core:$http4sVersion",
  ivy"org.http4s::http4s-dsl:$http4sVersion",
  ivy"org.http4s::http4s-blaze-server:$http4sVersion",
  ivy"org.http4s::http4s-circe:$http4sVersion",

  //Circe dependencies
  ivy"io.circe::circe-core:0.14.1",
  ivy"io.circe::circe-generic:0.14.1",

  //Iron with String, Cats and Circe modules
  ivy"io.github.iltotore::iron:1.1",
  ivy"io.github.iltotore::iron-string:1.1-0.1.0",
  ivy"io.github.iltotore::iron-cats:1.1-0.1.0",
  ivy"io.github.iltotore::iron-circe:1.1-0.1.0"
}

Setup the http4s server

Before focusing on our Account system, we need to setup an http server first. Http4s and Cats offer a simple way to do this.

Firstly create a service:

//Basic http4s imports
import org.http4s._, org.http4s.dsl.io._, org.http4s.implicits._

object HttpServer {

  val service = HttpRoutes.of[IO] {

    case _ => Ok("Hello World")
  }
}

In the HttpRoutes.of block, we can pattern match on the input request (http method, route...). We will come back to our service later.

Now, we need to create a Blaze server. We will use the example of Http4s service's documentation:

import cats.effect._
import org.http4s.implicits._
import org.http4s.blaze.server.BlazeServerBuilder
import scala.concurrent.ExecutionContext

//An IOApp will handle shutdown gracefully for us when receiving the SIGTERM signal
object Main extends IOApp {

  override def run(args: List[String]): IO[ExitCode] = BlazeServerBuilder[IO](ExecutionContext.global)
    .bindHttp(8080, "localhost")
    .withHttpApp(HttpServer.service.orNotFound)
    .serve
    .compile //Allow conversion to IO
    .drain //Remove output
    .as(ExitCode.Success) //Set the output as Success

}

We can now run our Main. But our service in HttpServer is currently not that useful: it only returns Ok - "Hello World".

Account creation

Before modifying our service, we're going to create an Account case class:

case class Account(username: String, email: String, password: String)

Now, we need to check inputs when creating a new Account:

  • Is the username alphanumeric ?
  • Is the email a valid email scheme ?
  • Does the password contain at least an upper, a lower and a number ?

Let's create these validators using Cats' Validated:

import cats.data._, cats.implicits._, cats.syntax.apply._

case class Account(username: String, email: String, password: String)

object Account {

  def validateUsername(username: String): ValidatedNec[String, String] =
    Validated.condNec(username.matches("^[a-zA-Z0-9]+"), username, s"$username should be alphanumeric")

  def validateEmail(email: String): ValidatedNec[String, String] =
    Validated.condNec(email.matches("^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"), email, s"$email should be a valid email")

  def validatePassword(password: String): ValidatedNec[String, String] =
    Validated.condNec(password.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]+$"), password, s"$password should contain at least an upper, a lower and a number")

  def createAccount(username: String, email: String, password: String): ValidatedNec[String, Account] = (
    validateUsername(username),
    validateEmail(email),
    validatePassword(password)
  ).parMapN(Account.apply)
}

We can now create an account using Account.createAccount:

//Valid(Account(...))
Account.createAccount("Iltotore", "[email protected]", "SafePassword1")

//Invalid(NonEmptyChain("Value should be alphanumeric")))
Account.createAccount("Il_totore", "[email protected]", "SafePassword1")

/* 
 * Invalid(NonEmptyChain(
 *   "Value should be alphanumeric"),
 *   "Value must contain at least an upper, a lower and a number")
 * ))
 */
Account.createAccount("Il_totore", "[email protected]", "this_is_not_fine")

Serialization

Before finishing our service, we need to deserialize the request and serialize the response. Fortunately, circe provides Decoders/Encoders for String and Cats' Validated. However, we need to create a Decoder and an Encoder for our Account. Circe offers a simple way to do it:

import cats.implicits._, cats.syntax.apply._

import io.circe.{Decoder, Encoder}
import io.circe.generic.semiauto._

case class Account(username: String, email: String, password: String)

object Account {

  //... (see code above)

  //Get the fields `username`, `email` and `password` and pass them into Account.createAccount
  inline given Decoder[ValidatedNec[String, Account]] =
    Decoder.forProduct3("username", "email", "password")(createAccount)

  //Automatically creates an Encoder from the case class Account
  inline given Encoder[Account] = deriveEncoder
}

Now, we can use these codecs in our service.

Finishing our service

In this example, we will just return the created Account as JSON. Here is the full code:

//Circe imports
import io.circe.syntax._, io.circe.disjunctionCodecs._, io.circe.Encoder._, io.circe.Encoder

//Http4s imports
import org.http4s._, org.http4s.dsl.io._, org.http4s.implicits._

object HttpServer {

  //Create an Http4s decoder from our Decoder[ValidatedNec[String, Account]]
  given EntityDecoder[IO, ValidatedNec[String, Account]] = accumulatingJsonOf[IO, ValidatedNec[String, Account]]

  val service = HttpRoutes.of[IO] {

    case request =>
      request.as[ValidatedNec[String, Account]] //Convert our request into a ValidatedNec[String, Account]
        .handleErrorWith(IO.raiseError) //Raise eventual exceptions
        .map(_.asJson) //Convert the result into JSON
        .flatMap(Ok(_)) //Create a "OK" request from our JSON
  }
}

You now can test it

Our example is now functional. If we send

{
  "username":"Iltotore",
  "email":"[email protected]",
  "password":"Abc123"
}

We will receive

{
  "Valid": {
    "username": "Iltotore",
    "email": "[email protected]",
    "password": "Abc123"
  }
}

And if we illegal values:

{
  "username": "Iltotore",
  "email": "memyemail.com",
  "password": "abc123"
}

We'll receive:

{
  "Invalid": [
    "memyemail.com should be an email",
    "abc123 should contain at least an upper, a lower and a number"
  ]
}

Routing

We can match on a specific request/route in the HttpRoutes.io block in our service. Let's only accept POST requests to /register using Http4s' DSL:

val service = HttpRoutes.of[IO] {
  case request@POST -> Root / "register" =>
    request.as[RefinedFieldNec[Account]]
      .handleErrorWith(IO.raiseError)
      .map(_.asJson)
      .flatMap(Ok(_))

  case unknown =>
    NotFound()
}

Use Iron in the project

We created a functional API but everytime we need to parse a specific value (like an email), we have to apply the validation method and this quickly become boilerplaty.

Iron allows to attach constraints to types. The value returned is an Either[IllegalValueError[A], A] where A is the original type.

Iron comes with a support for Cats and Circe by providing Cats Validated support and Circe Decoders/Encoders for constraints.

Let's see how our createAccount method looks like with Iron.

Firstly, we add type aliases to make our constraints more readable:

type Username = String ==> Alphanumeric
type Email = String ==> (Match["^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"] DescribedAs "Value should be an email")
//At least one upper, one lower and one number
type Password = String ==> (Match["^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]+$"] DescribedAs
  "Value should contain at least an upper, a lower and a number")

and now, here our new createAccount method:

def createAccount(username: Username, email: Email, password: Password): RefinedFieldNec[Account] = (
  username.toField("username").toValidatedNec,
  email.toField("email").toValidatedNec,
  password.toField("password").toValidatedNec
).mapN(Account.apply)
  • RefinedFieldNec[A] is an alias for ValidatedNec[IllegalValueError.Field, A]
  • toField(String) converts the potential value-based IllegalValueError[A] contained by our constrained type to a field-based IllegalValueError.Field.
  • toValidatedNec (provided by cats) converts our constrained type to an accumulative Validated. See Cats page on Validated for further information.

For more information about Iron, check the Github page

And that's all! There is no other required step to include Iron in your project as it is easily translatable into Either or Validated.

We've seen in this tutorial the extreme readability of our small API offered by Iron and Cats can consult the code and test instructions of the finished example on https://github.com/Iltotore/iron-cats-example

Links:

30