Raphaël Fromentin
Posted on July 27, 2021
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":"me@myemail.com",
"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:
- Http4s with Blaze server
- Circe (JSON)
- Iron (Type constraints)
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", "thisismymail@gmail.com", "SafePassword1")
//Invalid(NonEmptyChain("Value should be alphanumeric")))
Account.createAccount("Il_totore", "thisismymail@gmail.com", "SafePassword1")
/*
* Invalid(NonEmptyChain(
* "Value should be alphanumeric"),
* "Value must contain at least an upper, a lower and a number")
* ))
*/
Account.createAccount("Il_totore", "thisismymail@gmail.com", "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":"me@myemail.com",
"password":"Abc123"
}
We will receive
{
"Valid": {
"username": "Iltotore",
"email": "me@myemail.com",
"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 forValidatedNec[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:
- Iron: https://github.com/Iltotore/iron/
- Cats: https://typelevel.org/cats/
- Circe: https://circe.github.io/circe/
- Http4s: https://http4s.org/
Posted on July 27, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.