Greg Beech wrote an article on Akka HTTP Entity Validation. This post is on the same topic, except we are going to use typeclass instances, as opposed to subtyping, for form validations.
To start with, let us suppose that we are working on some sort of user management service using Scala/Akka. However, here we are only going to focus on the registration part, in which the client is expected to send a user data, in JSON format; and the server needs to validate them, and save the user if the validations succeed, or reject the request with all the errors detected. That said, we should keep in mind that our validations may be used through out the entire codebase, not just in the user registration endpoint.
The rules for our validation are as follows:
- All fields are required.
- Each field must have the correct type.
- All strings must be non-empty.
- Classic password validations (which we are not going to emphasize much).
- A user should have at least one hobby (see #1).
- Only people of ages 18 or above are allowed to register (it's not for kids).
Note that in a real-world project, registration validations are usually more complicated than this list, but for our current purposes, this should do.
Our user model has the usual attributes present in most registration forms:
final case class User(
username: String,
password: String,
firstName: String,
lastName: String,
age: Int,
hobbies: Vector[String])
In order to parse the JSON data and convert it into a User
instance and vice-versa, we need to tell Akka-HTTP how to (un)marshall between JSON and User
:
object RegistrationJsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
implicit val userFormat = jsonFormat6(User)
// ... more formats here ...
}
Then we bring that into the scope and use it in our route:
import RegistrationJsonSupport._
val route = post {
entity(as[User]) { user =>
// For simplicity, let us agree that this operation never fails.
saveUser(user)
complete(s"User ${user.username} has been successfully registered.")
}
}
This has added advantages: Akka-HTTP ensures that non-optional fields are provided by the client, and each field will have the correct type as specified in the model. Any requests that do not comply with these structural rules will be rejected. This means our current code already got the first 2 validation rules above covered.
The next thing we need to do is implement the different validations for the fields.
If you are interested only in the form validations and how to integrate them with Akka-HTTP, you can skip this section.
The remaining validation rules are all about checking for non-emptiness and minimum values, and password validations. We can write our own utilities to generalize some of these things:
trait FieldValidator {
trait Required[F] extends (F => Boolean)
trait Minimum[F] extends ((F, Int) => Boolean)
def required[F](field: F)(implicit req: Required[F]): Boolean =
req(field)
def minimum[F](field: F, limit: Int)(implicit min: Minimum[F]): Boolean =
min(field, limit)
implicit val minimumStringLength: Minimum[String] = _.length >= _
implicit val minimumInteger: Minimum[Int] = _ >= _
implicit val requiredString: Required[String] = _.nonEmpty
// Warning: I haven't tried compiling this part.
implicit val requireVector: Required[Vector[String]] = _.forall(required[String])
}
If that looks like a bunch of typeclasses and their instances, that's because it is. I've got two main motivations for suggesting this approach:
- Type-safety. Having
minimum
andrequired
takeAny
values on which to pattern match to determine the specific type of input is not type-safe. - Ad-hoc Polymorphism. You do not have to invoke different functions for the same type of validations (
minimumInt
,minimumString
, etc.). If you want to check for, say, a minimum, you can just call theminimum
function regardless of the type of field, providing, of course, that the typeclass has an (implicit) instance for that field in scope. Sometimes you might need to specify the type of the field to check (e.g.mimimum[Int]
,required[String]
).
We can then use these utilities in our field validation functions:
import cats.data.ValidatedNec
import cats.implicits._
trait FieldValidator {
// previous code goes here...
def validateRequired[F: Required](field: F, fieldName: String): ValidationResult[F] =
Either.cond(
required(field),
field,
EmptyField(fieldName)).toValidatedNec
def validateMinimum[F: Minimum](field: F, fieldName: String, limit: Int): ValidationResult[F] =
Either.cond(
minimum(field, limit),
field,
BelowMinimum(fieldName, limit)).toValidatedNec
def validatePassword(password: String): ValidationResult[String] = ...
// more validations here
}
In case you didn't know what [F: Required]
indicates, it's a Scala feature known as context bounds. Put simply, the signatures desugar to:
def validateRequired[F](field: F, fieldName: String)(implicit required: Required[F]): ValidationResult[F] = ???
def validateMinimum[F](field: F, fieldName: String, limit: Int)(implicit min: Minimum[F]): ValidationResult[F] = ???
We use Either.cond
to handle the results of the validations. EmptyField
and BelowMinimum
are case classes representing errors:
sealed trait RequestValidation {
def errorMessage: String
}
object RequestValidation {
final case class EmptyField(fieldName: String) extends RequestValidation {
override def errorMessage = s"$fieldName is empty"
}
final case class BelowMinimum(fieldName: String, limit: Int) extends RequestValidation {
override def errorMessage = s"$fieldName is below the minimum of $limit"
}
// ... more error messages here...
}
Let's go back to our field validation functions. You can see that the resulting Either
s from the Either.cond
s call toValidatedNec
. This method turns an Either into a ValidatedNec
from Cats. ValidatedNec
is just like Either
, but far more useful when accumulating multiple errors. However, if you look at the signature of the functions, the results are actually VaidationResult
s, a type that we'll talk about in the next section.
Our form validator will try to validate all the fields in a given request. It can produce two possible outcomes: the validated object indicating that the validation went successfully, or an accumulation of errors. For this, we've got two options: use Either
or Cats's ValidatedNec
. Cats documentation for Validated recommends to use Validated
(and consequently, ValidatedNec
) for the accumulation of errors. In this post, we are going for ValidatedNec
.
Just like Either
, ValidatedNec
has left and right projections, with left used to represent errors. We already know the type of errors we want, and we don't want to keep on specifying it, so we'll make a type alias that will fill in ValidatedNec
's left projection with RequestValidation
.
And that's where the ValidationResult
we saw in the previous section comes in:
type ValidationResult[A] = ValidatedNec[RequestValidation, A]
Let's also add a FormValidation
type alias for a function that lifts a form (of any type) to ValidationResult
(which would allow us to do point-free style programming later on):
type FormValidation[F] = F => ValidationResult[F]
Now that we have the types in place, we can write a generic form validator:
def validateForm[F, A](form: F)(f: ValidationResult[F] => A)
(implicit formValidation: FormValidation[F]): A =
f(formValidation(form))
I forgot to tell you that FormValidation
can be treated as a typeclass. And that means we can apply validateForm
to any form
of type F
as long as there is a FormValidation
instance for that form in scope.
Here's our form validator, with the typeclass instances:
trait FormValidator {
type ValidationResult[A] = ValidatedNec[RequestValidation, A]
type FormValidation[F] = F => ValidationResult[F]
def validateForm[F, A](form: F)(f: ValidationResult[F] => A)
(implicit formValidation: FormValidation[F]): A =
f(formValidation(form))
implicit lazy val registrationFormValidation: FormValidation[User] = {
case User(username, password, firstName, lastName, address, age) => (
validateRequired(username, "Username"),
validatePassword(password, "Password"),
validateRequired(firstName, "First Name",
validateRequired(lastName, "Last Name"),
validateRequired(age, "Age"),
validateRequired(hobbies, "Hobbies")
validateMinimum(age, "Age", 18))).mapN(User)
}
// ...other implicit typeclass instances here...
implicit lazy val userUpdateFormValidation: FormValidation[User] = ???
implicit lazy val userDeletionFormValidation: FormValdiation[String] = ???
}
mapN
would take our chain of validations and accumulate all the errors found in the process, wrapping them all in the Invalid
projection (ValidatedNec
's equivalent to Either
's Left
). If no errors are found, it will return a Valid
result (ValidatedNec
's equivalent to Either
's Right
) with a user instance as the content.
We can then incorporate it into our route:
val route = post {
entity(as[User]) { user =>
validateForm(user) {
case Valid(_) =>
saveUser(user)
complete(s"User ${user.username} has been successfully registered.")
case Invalid(failures) => complete(internalError(
failures.iterator.map(_.errorMessage).mkString("\n")))
}
}
}
The only remaining thing to do is the removal of that extra call to validateForm
, which adds another layer of indentation, and explicit handling of the validation results.
To do that we may have to implement our custom unmashaller that does the validation. The problem is, Greg's custom unmarshaller, implemented using implicit conversions, wouldn't work in our case because our validations are not part of the entity. Also we will need to declare implicit parameters for the instances of our FormValidation
typeclasses.
Fortunately, there is another way to circumvent the issue, and that is to add extension methods to the default unmarshaller:
implicit class ValidationRequestMarshaller[A](um: FromRequestUnmarshaller[A]) {
def validate(implicit validation: FormValidation[A]) = um.flatMap { _ => _ => entity =>
validateForm(entity) {
case Valid(_) => Future.successful(entity)
case Invalid(failures) =>
Future.failed(new IllegalArgumentException(
failures.iterator.map(_.errorMessage).mkString("\n")))
}
}
}
We are using implicit class instead of direct implicit conversion (implicit def). This implicit class effectively adds a validate
method to a FromRequestUnmarshaller
. Note that validate
also contains the implicit parameter I mentioned above.
Here's the final, much better version of our route:
import validators._ // perhaps a package object that extends both FieldValidator and FormValidator
...
val route = post {
entity(as[User].validate) { user =>
saveUser(user)
complete(s"User ${user.username} has been successfully registered.")
}
}
@rcardin Yes. For compile-time values, refined types are even better because your code won't compile if the values are invalid. For runtime values, you might want to use
refineV
, which returns anEither
and can be integrated with Cats validation.