Play Framework: How to implement proper error hand

2019-03-30 07:19发布

问题:

I have a Play application with several modules, each of which has its own exception set. Here are three examples:

Module common:

package services.common

trait CommonErrors {

  final case class NotFound(id: String) extends Exception(s"object $id not found")
  final case class InvalidId(id: String) extends Exception(s"$id is an invalid id")
  ...

  // `toJson` is just an extension method that converts an exception to JSON
  def toResult(e: Exception): Result = e match {
    case NotFound => Results.NotFound(e.toJson)
    case InvalidId => Results.BadRequest(e.toJson)
    case _ => Results.InternalError(e.toJson)
  }
}

Module auth:

package services.auth

trait AuthErrors {

  final case class UserNotFound(e: NotFound) extends Exception(s"user ${e.id} not found")
  final case class UserAlreadyExists(email: String) extends Exception(s"user identified by $email already exists")
  ...

  // `toJson` is just an extension method that converts an exception to JSON
  def toResult(e: Exception): Result = e match {
    case UserNotFound => Results.NotFound(e.toJson)
    case UserAlreadyExists => Results.BadRequest(e.toJson)
    case _ => Results.InternalError(e.toJson)
  }
}

Module other:

trait OtherErrors {

  final case class AnotherError(s: String) extends Exception(s"another error: $s")
  ...

  // `toJson` is just an extension method that converts an exception to JSON
  def toResult(e: Exception): Result = e match {
    case AnotherError => Results.BadRequest(e.toJson)
    ...
    case _ => Results.InternalError(e.toJson)
  }
}

As you can see, each trait defines a set of exceptions and provides a method to convert that exception to a JSON response like this:

{
  "status": 404,
  "code": "not_found",
  "message": "user 123456789123456789123456 not found",
  "request": "https://myhost.com/users/123456789123456789123456"
}

What I'm trying to achieve is to have each module defining its exceptions, reusing the ones defined in the common module, and mixin the exception traits as needed:

object Users extends Controller {

  val errors = new CommonErrors with AuthErrors with OtherErrors {
    // here I have to override `toResult` to make the compiler happy
    override def toResult(e: Exception) = super.toResult
  }

  def find(id: String) = Action { request =>
    userService.find(id).map { user =>
      Ok(success(user.toJson))
    }.recover { case e =>
      errors.toResult(e) // this returns the appropriate result
    }
  }
}

If you look at how I've overridden toResult, I always return super.toResult, which corresponds to the implementation contained in trait OtherErrors... and this implementation might miss some patterns that are expected to be found in CommonErrors.toResult.

For sure I'm missing something... so the question is: what's the design pattern to fix the issue with multiple implementations of toResult?

回答1:

You could use Stackable Trait pattern. I'll replaced your .toJson with .getMessage for simplification reason:

define base trait:

trait ErrorsStack {
  def toResult(e: Exception): Result = e match {
    case _ => Results.InternalServerError(e.getMessage)
  }
}

and stackable traits:

trait CommonErrors extends ErrorsStack {
  case class NotFound(id: String) extends Exception(s"object $id not found")
  case class InvalidId(id: String) extends Exception(s"$id is an invalid id")

  override def toResult(e: Exception): Result = e match {
    case e: NotFound => Results.NotFound(e.getMessage)
    case e: InvalidId => Results.BadRequest(e.getMessage)
    case _ => super.toResult(e)
  }
}

trait AuthErrors extends ErrorsStack {
  case class UserNotFound(id: String) extends Exception(s"user $id not found")
  case class UserAlreadyExists(email: String) extends Exception(s"user identified by $email already exists")

  override def toResult(e: Exception): Result = e match {
    case e: UserNotFound => Results.NotFound(e.getMessage)
    case e: UserAlreadyExists => Results.BadRequest(e.getMessage)
    case _ => super.toResult(e)
  }
}

trait OtherErrors extends ErrorsStack {    
  case class AnotherError(s: String) extends Exception(s"another error: $s")

  override def toResult(e: Exception): Result = e match {
    case e: AnotherError => Results.BadRequest(e.getMessage)

    case _ => super.toResult(e)
  }
}

so if we have some stack

val errors = new CommonErrors with AuthErrors with OtherErrors

and defined some helper

import java.nio.charset.StandardCharsets.UTF_8
import play.api.libs.iteratee.Iteratee
import concurrent.duration._
import scala.concurrent.Await

def getResult(ex: Exception) = {
  val res = errors.toResult(ex)
  val body = new String(Await.result(res.body.run(Iteratee.consume()), 5 seconds), UTF_8)
  (res.header.status, body)
}

following code

import java.security.GeneralSecurityException

getResult(errors.UserNotFound("Riddle"))
getResult(errors.UserAlreadyExists("Weasley"))
getResult(errors.NotFound("Gryffindor sword"))
getResult(errors.AnotherError("Snape's death"))
getResult(new GeneralSecurityException("Marauders's map"))

will produce reasonable output

res0: (Int, String) = (404,user Riddle not found)
res1: (Int, String) = (400,user identified by Weasley already exists)
res2: (Int, String) = (404,object Gryffindor sword not found)
res3: (Int, String) = (400,another error: Snape's death)
res4: (Int, String) = (500,Marauders's map)

also we can refactor this code, pulling case classed from traits, and make function's more composable:

type Resolver = PartialFunction[Exception, Result]

object ErrorsStack {
  val resolver: Resolver = {
    case e => Results.InternalServerError(e.getMessage)
  }
}

trait ErrorsStack {
  def toResult: Resolver = ErrorsStack.resolver
}

object CommonErrors {
  case class NotFound(id: String) extends Exception(s"object $id not found")
  case class InvalidId(id: String) extends Exception(s"$id is an invalid id")
  val resolver: Resolver = {
    case e: NotFound => Results.NotFound(e.getMessage)
    case e: InvalidId => Results.BadRequest(e.getMessage)
  }
}

trait CommonErrors extends ErrorsStack {
  override def toResult = CommonErrors.resolver orElse super.toResult
}

object AuthErrors {
  case class UserNotFound(id: String) extends Exception(s"user $id not found")
  case class UserAlreadyExists(email: String) extends Exception(s"user identified by $email already exists")
  val resolver: Resolver = {
    case e: UserNotFound => Results.NotFound(e.getMessage)
    case e: UserAlreadyExists => Results.BadRequest(e.getMessage)
  }
}

trait AuthErrors extends ErrorsStack {
  override def toResult = AuthErrors.resolver orElse super.toResult
}

object OtherErrors {
  case class AnotherError(s: String) extends Exception(s"another error: $s")

  val resolver: Resolver = {
    case e: AnotherError => Results.BadRequest(e.getMessage)
  }
}

trait OtherErrors extends ErrorsStack {
  override def toResult = OtherErrors.resolver orElse super.toResult
}