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.
This library has been used for a year on the Crypto Coin Alerts project project.
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
)
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).
- 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.
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:
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"}
Request:
curl -H "Authorization: 13" localhost:9000/auth
Response:
{"message":"Hello user with id 13"}
Request:
curl -v localhost:9000/exception
Response:
< HTTP/1.1 500 Internal Server Error
{
"errors": [
{
"errorId": "ab5beaf9307a4e1ab90d242786a84b29",
"message": "Internal error",
"type": "server-error"
}
]
}
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"
}
]
}
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"
}
}
}
The documentation assumes that you are already familiar with play-framework and it might be incomplete, you can always look into these applications:
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
)
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.
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).
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.
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.
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.
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.
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.
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
.
mill playsonify.compile
mill playsonify.test
This step should be run everytime build.sc
is modified:
mill mill.scalalib.GenIdea/idea