Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/alexitc/playsonify
An opinionated micro-framework to help you build practical JSON APIs with Play Framework (or akka-http)
https://github.com/alexitc/playsonify
akka-http functional-programming hacktoberfest json-api library mill playframework scala scalactic testing webservices
Last synced: about 3 hours ago
JSON representation
An opinionated micro-framework to help you build practical JSON APIs with Play Framework (or akka-http)
- Host: GitHub
- URL: https://github.com/alexitc/playsonify
- Owner: AlexITC
- License: mit
- Created: 2018-02-25T06:32:07.000Z (over 6 years ago)
- Default Branch: master
- Last Pushed: 2023-12-03T13:32:04.000Z (11 months ago)
- Last Synced: 2023-12-07T17:01:03.325Z (11 months ago)
- Topics: akka-http, functional-programming, hacktoberfest, json-api, library, mill, playframework, scala, scalactic, testing, webservices
- Language: Scala
- Homepage:
- Size: 166 KB
- Stars: 40
- Watchers: 4
- Forks: 7
- Open Issues: 15
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# playsonify
[![Build Status](https://travis-ci.org/AlexITC/playsonify.svg?branch=master)](https://travis-ci.org/AlexITC/playsonify)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/e09923e277df4191ab2a1c3a4953ce41)](https://www.codacy.com/app/AlexITC/playsonify?utm_source=github.com&utm_medium=referral&utm_content=AlexITC/playsonify&utm_campaign=Badge_Grade)
[![Join the chat at https://gitter.im/playsonify/Lobby](https://badges.gitter.im/playsonify/Lobby.svg)](https://gitter.im/playsonify/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
[![Maven Central](https://img.shields.io/maven-central/v/com.alexitc/playsonify_2.12.svg)](https://maven-badges.herokuapp.com/maven-central/com.alexitc/playsonify_2.12)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](http://tableofcontent.eu)**
- [State](#state)
- [Support](#support)
- [Name](#name)
- [Features](#features)
- [What can playsonify do?](#what-can-playsonify-do)
- [Deserialization and serialization](#deserialization-and-serialization)
- [Simple authentication](#simple-authentication)
- [Automatic exception handling](#automatic-exception-handling)
- [Simple error accumulation](#simple-error-accumulation)
- [Controller testing is simple](#controller-testing-is-simple)
- [Usage](#usage)
- [Add dependencies](#add-dependencies)
- [Familiarize with scalactic Or and Every](#familiarize-with-scalactic-or-and-every)
- [Familiarize with our type aliases](#familiarize-with-our-type-aliases)
- [Create your application specific errors](#create-your-application-specific-errors)
- [Define your authentication mechanism](#define-your-authentication-mechanism)
- [Define your JsonControllerComponents](#define-your-jsoncontrollercomponents)
- [Define your AbstractJsonController](#define-your-abstractjsoncontroller)
- [Create your controllers](#create-your-controllers)
- [Development](#development)
- [Compile](#compile)
- [Test](#test)
- [Integrate with IntelliJ](#integrate-with-intellij)````## State
This library has been used for a year on the [Crypto Coin Alerts project](https://github.com/AlexITC/crypto-coin-alerts) 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.5Please 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:
- [akka-http TestController](playsonify-akka-http/test/src/com/alexitc/playsonify/akka/controllers/TestController.scala)Add these lines to your `build.sbt` file to get the integration with akka-http:
```scala
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](examples/simple-app).
Let's define an input model:
```scala
case class Person(name: String, age: Int)object Person {
implicit val reads: Reads[Person] = Json.reads[Person]
}
```Define an output model:
```scala
case class HelloMessage(message: String)object HelloMessage {
implicit val writes: Writes[HelloMessage] = Json.writes[HelloMessage]
}
```Define a controller:
```scala
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:
```bash
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:
```bash
curl -H "Authorization: 13" localhost:9000/auth
```Response:
```
{"message":"Hello user with id 13"}
```### Automatic exception handling
Request:
```bash
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:
```bash
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
```scala
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
|}
""".stripMarginval response = POST("/hello", Some(body))
status(response) mustEqual OKval 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:
- The [example application](examples/simple-app).
- The [Crypto Coin Alerts project](https://github.com/AlexITC/crypto-coin-alerts/tree/master/alerts-server/app/controllers).
- The [XSN Block Explorer project](https://github.com/X9Developers/block-explorer/tree/master/server/app/controllers)### Add dependencies
Add these lines to your `build.sbt` file:
```scala
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](http://www.scalactic.org/user_guide/OrAndEvery) 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](playsonify-core/src/com/alexitc/playsonify/core/package.scala), it looks like this:```scala
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](playsonify-core/src/com/alexitc/playsonify/models/applicationErrors.scala), 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.
```scala
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:
```scalasealed 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](https://github.com/AlexITC/crypto-coin-alerts/tree/master/alerts-server/app/com/alexitc/coinalerts/errors).
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](playsonify-play/src/com/alexitc/playsonify/play/AbstractAuthenticatorService.scala), 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:
```scala
sealed trait SimpleAuthErrorobject 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):
```scala
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](https://github.com/AlexITC/crypto-coin-alerts/blob/master/alerts-server/app/com/alexitc/coinalerts/services/JWTAuthenticatorService.scala).
### Define your JsonControllerComponents
In order to provide your custom components, we'll create a custom [JsonControllerComponents](playsonify/src/com/alexitc/playsonify/JsonControllerComponents.scala), here you'll wire what you have just defined, for example:```scala
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](https://github.com/AlexITC/crypto-coin-alerts/blob/master/alerts-server/app/controllers/MyJsonControllerComponents.scala).
### Define your AbstractJsonController
Last, we need to define your customized [AbstractJsonController](playsonify-play/src/com/alexitc/playsonify/play/JsonControllerComponents.scala), using guice dependency injection could lead us to this example:```scala
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](https://github.com/AlexITC/crypto-coin-alerts/blob/master/alerts-server/app/controllers/MyJsonController.scala).
### Create your controllers
It is time to create your own controllers, let's define an input model for the request body:
```scala
case class Person(name: String, age: Int)object Person {
implicit val reads: Reads[Person] = Json.reads[Person]
}
```Now, define the output response:
```scala
case class HelloMessage(message: String)object HelloMessage {
implicit val writes: Writes[HelloMessage] = Json.writes[HelloMessage]
}
```And the controller:
```scala
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?
```scala
...
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](https://github.com/AlexITC/crypto-coin-alerts/blob/master/alerts-server/app/controllers).
## Development
The project is built using the [mill](https://github.com/lihaoyi/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`