Akka HTTP: another one validation directive
The question is how to validate a HTTP request body using Akka HTTP directive? Of course we can use for this reason validate
directive, but it has one drawback which I described in my previous post about a model validation in Akka HTTP. You may want to use require
method, but it is not so functional as well. Today I want to show another way for validation of HTTP request body in Akka.
Validation problem
Let’s assume we have a REST endpoint which works with a following data model:
case class Contact(name: String, email: String, age: Option[Int])
As you see this is a simple case class. Of course we want to be sure that all of data is valid, e.g. age
is not negative.
How to implement constraints for case class fields in a context of Akka HTTP? We just want to have a nice response for situations when some of fields or all of them have invalid values. We need to send back to a client something like this:
[ {"fieldName": "name", "errorMessage": "name length must be between 2 and 30 characters"}, {"fieldName": "email", "errorMessage": "email must be valid"}, {"fieldName": "age", "errorMessage": "age must be between 16 and 99"}, ]
After the problem is highlighted, a solution need to be find for it.
Akka HTTP validation directive
Firstly we need to declare a standard data classes for validation domain:
final case class FieldErrorInfo(name: String, error: String) final case class ModelValidationRejection(invalidFields: Seq[FieldErrorInfo]) extends Rejection
Then we can create a trait
which contains an abstraction of validation logic:
trait Validator[T] extends (T => Seq[FieldErrorInfo]) { protected def validationStage(rule: Boolean, fieldName: String, errorText: String): Option[FieldErrorInfo] = if (rule) Some(FieldErrorInfo(fieldName, errorText)) else None }
The validationStage(rule: Boolean, fieldName: String, errorText: String)
represents the smallest piece of a model validation process. It has 3 arguments. The first one is rule
, it is responsible for some validation constraint which you want to apply to a model field. The second one is fieldName
, it’s needed for a validation response. The third one is errorText
, it’s used for a description of validation error.
Even if not all of the fields are clear for understanding, read further, I’d definitely understand the concept on example.
Finally here is a validation directive:
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.server.Directive1 import akka.http.scaladsl.server.Directives._ import spray.json.DefaultJsonProtocol object ValidationDirective extends SprayJsonSupport with DefaultJsonProtocol { import akka.http.scaladsl.server.Rejection implicit val validatedFieldFormat = jsonFormat2(FieldErrorInfo) def validateModel[T](model: T)(implicit validator: Validator[T]): Directive1[T] = { validator(model) match { case Nil => provide(model) case errors: Seq[FieldErrorInfo] => reject(ModelValidationRejection(errors)) } } }
I use spray-json for marshaling / unmarshaling. You can use any alternative and it will work any way.
Validation Example
The best way to see how the directive works is to write a test for it or a couple of tests. Moreover we will ensure that it works fine. Firstly I suggest to see how a particular validator acts and then inject it in Akka HTTP directives.
case class Contact(name: String, email: String, age: Option[Int]) object ContactValidator extends Validator[Contact] { //Predefined rules. They can be placed somewhere else in order to be accessible for other validators private def nameRule(name: String) = if (name.length < 2 || name.length > 30) true else false private def emailRule(email: String) = if ("""\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z""".r.findFirstMatchIn(email) .isEmpty) true else false private def ageRule(age: Option[Int]) = if (age.isDefined && (age.get < 16 || age.get > 99)) true else false override def apply(model: Contact): Seq[FieldErrorInfo] = { val nameErrorOpt: Option[FieldErrorInfo] = validationStage(nameRule(model.name), "name", "name length must be between 2 and 30 characters") val emailErrorOpt: Option[FieldErrorInfo] = validationStage(emailRule(model.email), "email", "email must be valid") val ageErrorOpt: Option[FieldErrorInfo] = validationStage(ageRule(model.age), "age", "age must be between 16 and 99") (nameErrorOpt :: emailErrorOpt :: ageErrorOpt :: Nil).flatten } }
That’s how you can create a validator for Contact
model. Notice that if you have many models and some of validation rules are common, you’d better to hold them somewhere in a more general place (validation rules trait or object).
What about testing?
import org.scalatest.FunSuite class ContactValidatorSuite extends FunSuite { test("positive validation #1") { val contact = Contact("Jo", "jo@example.com", None) assert(ContactValidator(contact) === Seq()) } test("positive validation #2") { val contact = Contact("PrettyLongNameAsForContactOkOk", "good_email@example.com", Some(16)) assert(ContactValidator(contact) === Seq()) } test("negative validation #1") { val contact = Contact("a", "Aexample.com", Some(15)) assert(ContactValidator(contact) === Seq( FieldErrorInfo("name", "name length must be between 2 and 30 characters"), FieldErrorInfo("email", "email must be valid"), FieldErrorInfo("age", "age must be between 16 and 99") )) } test("negative validation #2") { val contact = Contact("PrettyLongNameAsForContactBadIdea", "A@examplecom", Some(100)) assert(ContactValidator(contact) === Seq( FieldErrorInfo("name", "name length must be between 2 and 30 characters"), FieldErrorInfo("email", "email must be valid"), FieldErrorInfo("age", "age must be between 16 and 99") )) } }
Ok.
Now we see that validator works as expected. It’s time to see it in action in a context of Akka HTTP.
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.model.{HttpResponse, StatusCodes} import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.testkit.ScalatestRouteTest import org.scalatest.FunSuite import spray.json.DefaultJsonProtocol class ContactValidatorAPISuite extends FunSuite with ScalatestRouteTest with SprayJsonSupport with DefaultJsonProtocol { implicit val contactFormatter = jsonFormat3(Contact) import com.vidiq.http.validator.ValidationDirective._ //Required implicit validator declaration implicit val contactValidator = ContactValidator val fakeAPI = pathPrefix("contacts") { post { entity(as[Contact]) { contact => validateModel(contact).apply { validatedContact => complete(HttpResponse(StatusCodes.OK)) } } } } test("positive validation") { Post("/contacts", Contact("Jo", "jo@example.com", None)) ~> fakeAPI ~> check { assert(status === StatusCodes.OK) } } test("negative validation") { Post("/contacts", Contact("a", "Aexample.com", Some(15))) ~> fakeAPI ~> check { assert(rejection === ModelValidationRejection(Seq( FieldErrorInfo("name", "name length must be between 2 and 30 characters"), FieldErrorInfo("email", "email must be valid"), FieldErrorInfo("age", "age must be between 16 and 99") ))) } } }
And do not forget to handle ModelValidationRejection
.
Summary
Well, I presented a solution to handle a model (case classes) in Akka HTTP. Of course it may be not so brilliant as many of you want, but at least it solves the problem. For sure you have own thoughts how the validation should be implemented. I’d be really glad to read your comments regarding this.