Skip to content
This repository was archived by the owner on Aug 26, 2025. It is now read-only.

Repository files navigation

playsonify

Build StatusCodacy BadgeJoin the chat at https://gitter.im/playsonify/LobbyMaven 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:

valplaysonifyVersion="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:

caseclassPerson(name: String, age: Int)
objectPerson {
implicitvalreads:Reads[Person] =Json.reads[Person]
}

Define an output model:

caseclassHelloMessage(message: String)
objectHelloMessage {
implicitvalwrites:Writes[HelloMessage] =Json.writes[HelloMessage]
}

Define a controller:

classHelloWorldController@Inject() (components: MyJsonControllerComponents)
extendsMyJsonController(components) {
importContext._defhello= publicInput { context: HasModel[Person] =>valmsg=s"Hello ${context.model.name}, you are ${context.model.age} years old"valhelloMessage=HelloMessage(msg)
valgoodResult=Good(helloMessage)
Future.successful(goodResult)
}
defauthenticatedHello= authenticated { context: Authenticated=>valmsg=s"Hello user with id ${context.auth}"valhelloMessage=HelloMessage(msg)
valgoodResult=Good(helloMessage)
Future.successful(goodResult)
}
deffailedHello= public[HelloMessage] { context: Context=>valerrors=Every(
UserError.UserEmailIncorrect,
UserError.UserAlreadyExist,
UserError.UserNotFound)
valbadResult=Bad(errors)
Future.successful(badResult)
}
defexceptionHello= public[HelloMessage] { context: Context=>Future.failed(newRuntimeException("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

classHelloWorldControllerSpecextendsMyPlayAPISpec {
overridevalapplication= guiceApplicationBuilder.build()
"POST /hello" should {
"succeed" in {
valname="Alex"valage=18valbody=s""" |{ | "name": "$name", | "age": $age
 |}""".stripMargin
valresponse=POST("/hello", Some(body))
status(response) mustEqual OKvaljson= 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:

valplaysonifyVersion="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:

typeApplicationErrors=Every[ApplicationError]
typeApplicationResult[+A] =AOrApplicationErrorstypeFutureApplicationResult[+A] =Future[ApplicationResult[A]]
typeFuturePaginatedResult[+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.

traitInputValidationErrorextendsApplicationErrortraitConflictErrorextendsApplicationErrortraitNotFoundErrorextendsApplicationErrortraitAuthenticationErrorextendsApplicationErrortraitServerErrorextendsApplicationError {
// contains data private to the serverdefcause:Option[Throwable]
}

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

sealedtraitUserErrorobjectUserError {
caseobjectUserAlreadyExistextendsUserErrorwithConflictError {
overridedeftoPublicErrorList[L](i18nService: I18nService[L])(implicitlang: L):List[PublicError] = {
valmessage= i18nService.render("user.error.alreadyExist")
valerror=FieldValidationError("email", message)
List(error)
}
}
caseobjectUserNotFoundextendsUserErrorwithNotFoundError {
overridedeftoPublicErrorList[L](i18nService: I18nService[L])(implicitlang: L):List[PublicError] = {
valmessage= i18nService.render("user.error.notFound")
valerror=FieldValidationError("userId", message)
List(error)
}
}
caseobjectUserEmailIncorrectextendsUserErrorwithInputValidationError {
overridedeftoPublicErrorList[L](i18nService: I18nService[L])(implicitlang: L):List[PublicError] = {
valmessage= i18nService.render("user.error.incorrectEmail")
valerror=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:

sealedtraitSimpleAuthErrorobjectSimpleAuthError {
caseobjectInvalidAuthorizationHeaderextendsSimpleAuthErrorwithAuthenticationError {
overridedeftoPublicErrorList[L](i18nService: I18nService[L])(implicitlang: L):List[PublicError] = {
valmessage= i18nService.render("auth.error.invalidToken")
valerror=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):

classDummyAuthenticatorServiceextendsAbstractAuthenticatorService[Int] {
overridedefauthenticate(request: Request[JsValue]):FutureApplicationResult[Int] = {
valuserIdMaybe= request
.headers
.get(HeaderNames.AUTHORIZATION)
.flatMap { header =>Try(header.toInt).toOption }
valresult=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:

classMyJsonControllerComponents@Inject() (
overridevalmessagesControllerComponents:MessagesControllerComponents,
overridevalexecutionContext:ExecutionContext,
overridevalpublicErrorRenderer:PublicErrorRenderer,
overridevali18nService:I18nPlayService,
overridevalauthenticatorService:DummyAuthenticatorService)
extendsJsonControllerComponents[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:

abstractclassMyJsonController(components: MyJsonControllerComponents) extendsAbstractJsonController(components) {
protectedvallogger=LoggerFactory.getLogger(this.getClass)
overrideprotecteddefonServerError(error: ServerError):Unit= {
error.cause match {
caseSome(cause) =>
logger.error(s"Unexpected internal error, id = ${error.id.string}, error = $error", cause)
caseNone=>
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:

caseclassPerson(name: String, age: Int)
objectPerson {
implicitvalreads:Reads[Person] =Json.reads[Person]
}

Now, define the output response:

caseclassHelloMessage(message: String)
objectHelloMessage {
implicitvalwrites:Writes[HelloMessage] =Json.writes[HelloMessage]
}

And the controller:

classHelloWorldController@Inject() (components: MyJsonControllerComponents)
extendsMyJsonController(components) {
importContext._defhello= publicInput { context: HasModel[Person] =>valmsg=s"Hello ${context.model.name}, you are ${context.model.age} years old"valhelloMessage=HelloMessage(msg)
valgoodResult=Good(helloMessage)
Future.successful(goodResult)
}
}

What about authenticating the request?

...
defauthenticatedHello= authenticated { context: Authenticated=>valmsg=s"Hello user with id ${context.auth}"valhelloMessage=HelloMessage(msg)
valgoodResult=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

About

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

Topics

Resources

Stars

42 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages