Flattening Scala Play Controllers with For Comprehensions and Eithers

It's been a little over a year since I've started programming in Scala professionally - so I wanted to come back to this blog and focus on a Scala centric post with some real world application.

I have found that some of the Play controller code I need to write can get very pyramid-shaped in nature, since most of the steps that interact with IO return options, which need to send sensible error messages back to the client in the case of None. Let's take a look at a contrived example function:

def putExample(someId:Long):Action = Action { implicit request: Request[AnyContent] =>
    request.body.asJson.map { json =>
      (json \ "field").validate[String].map { field =>
        FirstModel.findFromId(someId).map { first =>
          if (someCheckOnFirst(first)) {
            SecondModel.findWith(first.property).map { second =>
              Ok(second.update(field)) as JSON
            } getOrElse(NotFound("SecondModel"))
          } else Forbidden("Not allowed to continue")
        } getOrElse(NotFound("FirstModel"))
      } recoverTotal(_ => BadRequest("Sent some bad JSON"))
    } getOrElse(BadRequest("Didn't send any JSON"))
  }

As mentioned - this is not real code, but should give you a general idea of what I'm talking about and will be what we work with when talking about the main ideas/shape of what we are trying to get at.

Now - we could easily flatten this code to make it more readable from a dev perspective simply by putting this into a for comprehension:

def putExample(someId:Long) = Action { implicit request: Request[AnyContent] => {
    val result = for {
      json <- request.body.asJson
      field <- (json \ "field").validate[String]
      first <- FirstModel.findFromId(someId)
      if (someCheckOnFirst(first))
      second <- SecondModel.findWith(first.property)
    } yield Ok(second.update(field)) as JSON
    result getOrElse BadRequest("Something went wrong")
  }
}

If you're not familiar with for comprehensions, I highly recommend reading how they desugar here. In our case, we are essentially calling a series of flatMaps to a final map to start from an Option (request.body.asJson) and end with an Option. You'll notice however, we've now lost all of our error handling. This is because the second we hit a None in our for comprehension, we short circuit out - so the best we can do is have our final BadRequest catch all at the very end - hardly the ideal scenario.

Thankfully we can have our cake and eat it too by converting our Options to Eithers. (Scala also has Try, and which I use liberally when interacting with 3rd party APIs - however, I'm partial to Either namely because Try's Failure can only be of type Throwable)

If you're using a version less than 2.12 you'll need to bring in something like Scalaz or Cats into your project. The reason for this is that Eithers were not Right biased until 2.12, meaning they could not be used in for comprehensions.

So we need to find a way to convert our Option to Either, and ideally to supply an error message as well. Looking at the docs for Option we can find just that:

toRight[X](left: ⇒ X): Either[X, A]
Returns a scala.util.Left containing the given argument left if this scala.Option is empty, or a scala.util.Right containing this scala.Option's value if this is nonempty.

Taking the first line of our for comprehension - we would now have:

json <- request.body.asJson.toRight(BadRequest("Didn't send any JSON"))

Great! We can now get line by line errors for when the for comprehension short circuits. Let's write the whole thing out in the new style:

def putExample(someId:Long):Action = Action { implicit request: Request[AnyContent] => {
  val result = for {
      json <- request.body.asJson.toRight(BadRequest("Didn't send any JSON"))
      field <- (json \ "field").validate[Boolean].asEither.left.map(_:JsError => BadRequest("Sent some bad JSON"))
      first <- FirstModel.findFromId(someId).toRight(NotFound("FirstModel"))
      canContinue <- if (someCheckOnFirst(first)) Right(true) else Left(Forbidden("Not allowed to continue"))
      second <- SecondModel.findWith(first.property).toRight(NotFound("SecondModel"))
  } yield Ok(second.update(field)) as JSON
    result match {
      case Right(result) => result
      case Left(result) => result
    }
  }
}

In order to get this compiling - this doesn't really seem like much of an improvement at all. The JSON validation has some nasty bits, the withFilter has expanded into a mess and the pattern match at the end just to pull out the Result is annoying. Thankfully, we can create some helper functions to clean this up a bit more. First - to clean up the pattern matching at the bottom.

Implicits can be a nice tool when used sparingly. Thankfully, we know there are not many cases in which an Either[Result, Result] are going to show up in the code base, so I'm fine with creating an implicit conversion for this so we dont have to always unwrap the result in our controllers.

implicit def resultFromEither(either: Either[Result, Result]):Result = either match {
    case Left(res) => res
    case Right(res) => res
  }

Next for our validation. We're going to use another for comprehension that takes in a request and a reads and returns either the parsed json or the http error result to return:

def validateAndParseJson[A](request: Request[AnyContent], reads:Reads[A]):Either[Result, A] =
    for {
      json <- request.body.asJson.toRight(BadRequest("Didn't send any JSON"))
      parsed <- json.validate(reads).asEither.left.map(_:JsError => BadRequest("Sent some bad JSON"))
    } yield parsed

Finally for our filter cases, we'll create a simple withFilter function:

def withFilter(predicate: Boolean, error: Result):Either[Result, Boolean] = if (predicate) Right(predicate) else Left(error)

This leaves us with the final (and much cleaner result of):

def putExample(someId:Long):Action = Action { implicit request: Request[AnyContent] =>
  for {
      json <- request.body.asJson.toRight(BadRequest("Didn't send any JSON"))
      field <- validateAndParseJson(request, (__ \ "field").read[Boolean])
      first <- FirstModel.findFromId(someId).toRight(NotFound("FirstModel"))
      canContinue <- withFilter(someCheckOnFirst(first), Forbidden("Not allowed to continue"))
      second <- SecondModel.findWith(first.property).toRight(NotFound("SecondModel"))
  } yield Ok(second.update(field)) as JSON
}