http4s json validation

There are three things a software developer can watch forever: fire, water, and request validators. In the previous blog post I demonstrated how to develop a simple REST API application with http4s. But that app has a drawback — user can send any invalid data to the API and it will be processed with no error messages.

The Problem

The main problem of 99.99% web apps is that they have users (feel free to tweet this). As a result there is a huge human factor, which leads to tremendous amount of inappropriate interactions with web applications. One of my favorites is an invalid data input. For example user may enter “blablabla” in an email field, “MyNickName” in a phone number field or even leave a form field empty when it’s required.

It’s fact that every data model in any application has its own validation rules. Let’s consider a book model:

type Title = String
type Author = String

case class Book(title: Title, author: Author)

For simplicity let’s assume that title and author have only one requirement — not empty field. What does it mean from REST API point of view? In case when any of these fields is empty, a response should be 400 Bad Request, also it should contain explanation of validation errors:

[
    {
        "fieldName": "title",
        "message": "Must not be empty"
    },
    {
        "fieldName": "author",
        "message": "Must not be empty"
    }
]

With such a response, it’s easy to reflect on UI what exactly went wrong.

JSON validation in http4s

I spent plenty of time, while investigating, how its better to organize JSON request validation. In long Gitter discussions and Stackoverflow threads, I came up with the following solution. Firstly we need to declare some data models which describe possible validation errors:

import cats.data.NonEmptyList

type FieldName = String
type Message = String

final case class FieldError(fieldName: FieldName, message: Message)

type ValidationResult = Option[NonEmptyList[FieldError]]

Now we can go ahead and use these models to define two validation abstractions:

trait Validator[T] {
  def validate(target: T): ValidationResult
}

trait FieldValidator[T] {
  def validate(field: T, fieldName: FieldName): ValidationResult
}

Here Validator[T] is needed for describing a certain validator for a particular case class. Whereas FieldValidator[T] aims to describe a certain validation rule for a particular field of a case class. Now we can make a next logical step and create some real field validators. Let’s assume that we want to ensure that a String type field can not be empty:

case object NotEmpty extends FieldValidator[String] {
  def validate(target: String, fieldName: FieldName) =
    if (target.isEmpty) NonEmptyList.of(FieldError(fieldName, "Must not be empty")).some else None
}

As you can see NotEmpty case class describes this rule. What about more flexible example? Let’s say we need to validate a String type field by its length:

case class WithLength(min: Int, max: Int) extends FieldValidator[String] {
  def validate(target: String, fieldName: FieldName) =
    if (target.length < min || target.length > max)
      NonEmptyList.of(FieldError(fieldName, s"Length must be between $min and $max")).some else None
}

By using the same approach you can create any custom rule for an arbitrary type.

Now it’s time to return to the books REST API app and ensure that we don’t allow to process a Book model with empty fields.

object Book {
  import cats.implicits._
  import FieldValidator.strings._
  implicit val validator: Validator[Book] = (target: Book) => {
    NotEmpty.validate(target.title, "title") |+|
    NotEmpty.validate(target.author, "author")
  }
}

From the code snippet you see that I added Validator[Book] to the Book companion object. Therefore the validation rule is always provided with the model itself. Just for demonstration purposes I want to show, how it’s easy to add Book model validation to http4s routes:

...
case req @ POST -> Root / "books" =>
  req.decode[Book] { book =>
    Book.validator.validate(book) match {
      case None =>
        bookRepo.addBook(book).flatMap(id =>
          Created(Json.obj(("id", Json.fromString(id.value))))
        )
      case Some(errors) => BadRequest(errors)
    }
  }
...

Voila! Your dinner is served!
This approach is pretty convenient from a code maintenance point of view, plus it scales well, by introducing reusable FieldValidator[T] implementations.

Summary

In this article I described how you can setup a consistent approach for data validation. It was inspired by conversations with Scala community and may have some disadvantages, which I haven’t notice for some reason. So I’ll be happy to read any feedback.

About The Author

Mathematician, programmer, wrestler, last action hero... Java / Scala architect, trainer, entrepreneur, author of this blog

Close