alexitc / playsonify   2.3.0

MIT License GitHub

An opinionated micro-framework to help you build practical JSON APIs with Play Framework (or akka-http)

Scala versions: 2.13

playsonify

Build Status Codacy Badge Join the chat at https://gitter.im/playsonify/Lobby Maven Central

An opinionated micro-framework that helps you to build JSON APIs in a practical way, currently supporting Play Framework, also, there is experimental support for akka-http.

Table of Contents

State

This library has been used for a year on the Crypto Coin Alerts project project.

Support

The library has been tested with the following versions, it might work with other versions that are not officially supported.

  • Scala 2.12
  • Play Framework 2.6
  • akka-http 10.1.5

Please notice that the documentation is specific to play-framework, while most concepts apply, you can look into the akka-http tests to see what's different:

Add these lines to your build.sbt file to get the integration with akka-http:

val playsonifyVersion = "2.0.0"

libraryDependencies ++= Seq(
  "com.alexitc" %% "playsonify-core" % playsonifyVersion,
  "com.alexitc" %% "playsonify-akka-http" % playsonifyVersion,
  "com.alexitc" %% "playsonify-sql" % playsonifyVersion
)

Name

The name playsonify was inspired by mixing the JSON.stringify function from JavaScript and the Play Framework which is what it is built for (it might be worth considering another name now that akka-http is supported).

Features

  • Validate, deserialize and map the incoming request body to a model class automatically.
  • Serialize the result automatically.
  • Automatically map errors to the proper HTTP status code (OK, BAD_REQUEST, etc).
  • Support i18n easily.
  • Render several errors instead of just the first one.
  • Keeps error responses consistent.
  • Authenticate requests easily.
  • HTTP Idiomatic controller tests.
  • Primitives for offset-based pagination.
  • Primitives for sorting results.

What can playsonify do?

Try it by yourself with this simple-app.

Let's define an input model:

case class Person(name: String, age: Int)

object Person {
  implicit val reads: Reads[Person] = Json.reads[Person]
}

Define an output model:

case class HelloMessage(message: String)

object HelloMessage {
  implicit val writes: Writes[HelloMessage] = Json.writes[HelloMessage]
}

Define a controller:

class HelloWorldController @Inject() (components: MyJsonControllerComponents)
    extends MyJsonController(components) {

  import Context._

  def hello = publicInput { context: HasModel[Person] =>
    val msg = s"Hello ${context.model.name}, you are ${context.model.age} years old"
    val helloMessage = HelloMessage(msg)
    val goodResult = Good(helloMessage)

    Future.successful(goodResult)
  }

  def authenticatedHello = authenticated { context: Authenticated =>
    val msg = s"Hello user with id ${context.auth}"
    val helloMessage = HelloMessage(msg)
    val goodResult = Good(helloMessage)

    Future.successful(goodResult)
  }

  def failedHello = public[HelloMessage] { context: Context =>
    val errors = Every(
      UserError.UserEmailIncorrect,
      UserError.UserAlreadyExist,
      UserError.UserNotFound)

    val badResult = Bad(errors)
    Future.successful(badResult)
  }

  def exceptionHello = public[HelloMessage] { context: Context =>
    Future.failed(new RuntimeException("database unavailable"))
  }
}

Last, define the routes file (conf/routes):

POST /hello         controllers.HelloWorldController.hello()
GET  /auth          controllers.HelloWorldController.authenticatedHello()
GET  /errors        controllers.HelloWorldController.failedHello()
GET  /exception     controllers.HelloWorldController.exceptionHello()

These are some of the features that you get automatically:

Deserialization and serialization

Request:

curl -H "Content-Type: application/json" \
  -X POST -d \
  '{"name":"Alex","age":18}' \
   localhost:9000/hello

Response:

{"message":"Hello Alex, you are 18 years old"}

Simple authentication

Request:

curl -H "Authorization: 13" localhost:9000/auth

Response:

{"message":"Hello user with id 13"}

Automatic exception handling

Request:

curl -v localhost:9000/exception

Response:

< HTTP/1.1 500 Internal Server Error
{
    "errors": [
        {
            "errorId": "ab5beaf9307a4e1ab90d242786a84b29", 
            "message": "Internal error", 
            "type": "server-error"
        }
    ]
}

Simple error accumulation

Request:

curl -v localhost:9000/errors

Response:

< HTTP/1.1 400 Bad Request
{
   "errors":[
      {
         "type":"field-validation-error",
         "field":"email",
         "message":"The email format is incorrect"
      },
      {
         "type":"field-validation-error",
         "field":"email",
         "message":"The user already exist"
      },
      {
         "type":"field-validation-error",
         "field":"userId",
         "message":"The user was not found"
      }
   ]
}

Controller testing is simple

class HelloWorldControllerSpec extends MyPlayAPISpec {

  override val application = guiceApplicationBuilder.build()

  "POST /hello" should {
    "succeed" in {
      val name = "Alex"
      val age = 18
      val body =
        s"""
           |{
           |  "name": "$name",
           |  "age": $age
           |}
         """.stripMargin

      val response = POST("/hello", Some(body))
      status(response) mustEqual OK

      val json = contentAsJson(response)
      (json \ "message").as[String] mustEqual "Hello Alex, you are 18 years old"
    }
  }
}

Usage

The documentation assumes that you are already familiar with play-framework and it might be incomplete, you can always look into these applications:

Add dependencies

Add these lines to your build.sbt file:

val playsonifyVersion = "2.0.0"

libraryDependencies ++= Seq(
  "com.alexitc" %% "playsonify-core" % playsonifyVersion,
  "com.alexitc" %% "playsonify-play" % playsonifyVersion,
  "com.alexitc" %% "playsonify-sql" % playsonifyVersion,
  "com.alexitc" %% "playsonify-play-test" % playsonifyVersion % Test // optional, useful for testing
)

Familiarize with scalactic Or and Every

Playsonify uses scalactic Or and Every a lot, in summary, we have replaced Either[L, R] with Or[G, B], it allow us to construct value having a Good or a Bad result. Also, Every is a non-empty list which gives some compile time guarantees.

As you might have noted, the use of scalactic could be easily replaced with scalaz or cats, in the future, we might add support to let you choose which library to use.

Familiarize with our type aliases

There are some type aliases that are helpful to not be nesting a lot of types on the method signatures, see the core package, it looks like this:

type ApplicationErrors = Every[ApplicationError]
type ApplicationResult[+A] = A Or ApplicationErrors
type FutureApplicationResult[+A] = Future[ApplicationResult[A]]
type FuturePaginatedResult[+A] = FutureApplicationResult[PaginatedResult[A]]
  • ApplicationErrors represents a non-empty list of errors.
  • ApplicationResult represents a result or a non-empty list of errors.
  • FutureApplicationResult represents a result or a non-empty list of error that will be available in the future (asynchronous result).

Create your application specific errors

We have already defined some top-level application errors, you are required to extend them in your error classes, this is crucial to get the correct mapping from an error to the HTTP status.

trait InputValidationError extends ApplicationError
trait ConflictError extends ApplicationError
trait NotFoundError extends ApplicationError
trait AuthenticationError extends ApplicationError
trait ServerError extends ApplicationError {
  // contains data private to the server
  def cause: Option[Throwable]
}

For example, let's say that we want to define the possible errors related to a user, we could define some errors:

sealed trait UserError

object UserError {

  case object UserAlreadyExist extends UserError with ConflictError {
    override def toPublicErrorList[L](i18nService: I18nService[L])(implicit lang: L): List[PublicError] = {
      val message = i18nService.render("user.error.alreadyExist")
      val error = FieldValidationError("email", message)
      List(error)
    }
  }

  case object UserNotFound extends UserError with NotFoundError {
    override def toPublicErrorList[L](i18nService: I18nService[L])(implicit lang: L): List[PublicError] = {
      val message = i18nService.render("user.error.notFound")
      val error = FieldValidationError("userId", message)
      List(error)
    }
  }

  case object UserEmailIncorrect extends UserError with InputValidationError {
    override def toPublicErrorList[L](i18nService: I18nService[L])(implicit lang: L): List[PublicError] = {
      val message = i18nService.render("user.error.incorrectEmail")
      val error = FieldValidationError("email", message)
      List(error)
    }
  }
}

Then, when playsonify detects a Bad result, it will map the error to an HTTP status code in the following way:

  • InputValidationError -> 404 (BAD_REQUEST).
  • ConflictError -> 409 (CONFLICT).
  • NotFoundError -> 404 (NOT_FOUND).
  • AuthenticationError -> 401 (UNAUTHORIZED).
  • ServerError -> 500 (INTERNAL_SERVER_ERROR).

Hence, your task is to tag your error types with these top-level errors properly and implement the toPublicErrorList to get an error that would be rendered to the user.

Here you have a real example: errors package.

Notice that the you have the preferred user language to render errors in that language when possible.

Define your authentication mechanism

You are required to define your own AbstractAuthenticatorService, this service have the responsibility to decide which requests are authenticated and which ones are not, you first task is to define a model to represent an authenticated request, it is common to take the user or the user id for this, this model will be available in your controllers while dealing with authenticated requests.

For example, suppose that we'll use an Int to represent the id of the user performing the request, at first, define the errors that represents that a request wasn't authenticated, like this:

sealed trait SimpleAuthError

object SimpleAuthError {

  case object InvalidAuthorizationHeader extends SimpleAuthError with AuthenticationError {

    override def toPublicErrorList[L](i18nService: I18nService[L])(implicit lang: L): List[PublicError] = {
      val message = i18nService.render("auth.error.invalidToken")
      val error = HeaderValidationError("Authorization", message)
      List(error)
    }
  }
}

You could have defined the errors without the SimpleAuthError trait, I prefer to define a parent trait just in case that I need to use the errors in another part of the application.

Then, create your authenticator service, in this case, we'll a create a dummy authenticator which takes the value from the Authorization header and tries to convert it to an Int which we would be used as the user id (please, never use this unsecure approach):

class DummyAuthenticatorService extends AbstractAuthenticatorService[Int] {

  override def authenticate(request: Request[JsValue]): FutureApplicationResult[Int] = {
    val userIdMaybe = request
      .headers
      .get(HeaderNames.AUTHORIZATION)
      .flatMap { header => Try(header.toInt).toOption }

    val result = Or.from(userIdMaybe, One(SimpleAuthError.InvalidAuthorizationHeader))
    Future.successful(result)
  }
}

Note that you might want to use a specific error when the header is not present, also, while the Future is not required in this specific case, it allow us to implement different approaches, like calling an external web service in this step.

Here you have a real example: JWTAuthenticatorService.

Define your JsonControllerComponents

In order to provide your custom components, we'll create a custom JsonControllerComponents, here you'll wire what you have just defined, for example:

class MyJsonControllerComponents @Inject() (
    override val messagesControllerComponents: MessagesControllerComponents,
    override val executionContext: ExecutionContext,
    override val publicErrorRenderer: PublicErrorRenderer,
    override val i18nService: I18nPlayService,
    override val authenticatorService: DummyAuthenticatorService)
    extends JsonControllerComponents[Int]

Here you have a real example: MyJsonControllerComponents.

Define your AbstractJsonController

Last, we need to define your customized AbstractJsonController, using guice dependency injection could lead us to this example:

abstract class MyJsonController(components: MyJsonControllerComponents) extends AbstractJsonController(components) {

  protected val logger = LoggerFactory.getLogger(this.getClass)

  override protected def onServerError(error: ServerError): Unit = {
    error.cause match {
      case Some(cause) =>
        logger.error(s"Unexpected internal error, id = ${error.id.string}, error = $error", cause)

      case None =>
        logger.error(s"Unexpected internal error, id = ${error.id.string}, error = $error}")
    }
  }
}

Here you have a real example: MyJsonController.

Create your controllers

It is time to create your own controllers, let's define an input model for the request body:

case class Person(name: String, age: Int)

object Person {
  implicit val reads: Reads[Person] = Json.reads[Person]
}

Now, define the output response:

case class HelloMessage(message: String)

object HelloMessage {
  implicit val writes: Writes[HelloMessage] = Json.writes[HelloMessage]
}

And the controller:

class HelloWorldController @Inject() (components: MyJsonControllerComponents)
    extends MyJsonController(components) {

  import Context._

  def hello = publicInput { context: HasModel[Person] =>
    val msg = s"Hello ${context.model.name}, you are ${context.model.age} years old"
    val helloMessage = HelloMessage(msg)
    val goodResult = Good(helloMessage)

    Future.successful(goodResult)
  }
}

What about authenticating the request?

...
  def authenticatedHello = authenticated { context: Authenticated =>
    val msg = s"Hello user with id ${context.auth}"
    val helloMessage = HelloMessage(msg)
    val goodResult = Good(helloMessage)

    Future.successful(goodResult)
  }
...

Here you have a real example: controllers package.

Development

The project is built using the mill build tool instead of sbt, hence, you need to install mill in order to build the project.

The project has been built using mill 0.2.8.

Compile

mill playsonify.compile

Test

mill playsonify.test

Integrate with IntelliJ

This step should be run everytime build.sc is modified:

  • mill mill.scalalib.GenIdea/idea