├── project ├── build.properties └── plugins.sbt ├── .gitignore ├── app ├── wiring │ ├── AppLoader.scala │ └── AppComponents.scala ├── libs │ └── http.scala ├── controllers │ └── UserController.scala └── users │ └── user.scala ├── conf ├── routes ├── application.conf └── logback.xml ├── .scalafmt.conf ├── LICENSE └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.2.3 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | target/ 3 | boot/ 4 | lib_managed/ 5 | src_managed/ 6 | project/plugins/project/ 7 | project/target 8 | logs/ 9 | .DS_STORE 10 | -------------------------------------------------------------------------------- /app/wiring/AppLoader.scala: -------------------------------------------------------------------------------- 1 | package wiring 2 | 3 | import play.api.ApplicationLoader.Context 4 | import play.api._ 5 | 6 | class AppLoader extends ApplicationLoader { 7 | 8 | def load(context: Context) = { 9 | val appComponents = new AppComponents(context) 10 | appComponents.application 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /conf/routes: -------------------------------------------------------------------------------- 1 | 2 | 3 | POST /users controllers.UserController.createUser() 4 | GET /users controllers.UserController.listUser() 5 | GET /users/:id controllers.UserController.getUser(id: String) 6 | PUT /users/:id controllers.UserController.updateUser(id: String) 7 | DELETE /users/:id controllers.UserController.deleteUser(id: String) 8 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | style = defaultWithAlign 2 | 3 | align = most 4 | danglingParentheses = true 5 | docstrings = JavaDoc 6 | indentOperator = spray 7 | maxColumn = 140 8 | rewrite.rules = [RedundantBraces, RedundantParens, SortImports] 9 | unindentTopLevelOperators = true 10 | lineEndings = unix -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // The Play plugin 2 | addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.6.19") 3 | 4 | val neoScalafmtVersion = "1.15" 5 | addSbtPlugin("com.lucidchart" % "sbt-scalafmt" % neoScalafmtVersion) 6 | addSbtPlugin("com.lucidchart" % "sbt-scalafmt-coursier" % neoScalafmtVersion) 7 | addSbtPlugin("io.get-coursier" % "sbt-coursier" % "1.0.2") 8 | addSbtPlugin("org.wartremover" % "sbt-wartremover" % "2.2.1") -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is licensed under the Apache 2 license, quoted below. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project except in compliance with 4 | the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. 5 | 6 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 7 | "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific 8 | language governing permissions and limitations under the License. -------------------------------------------------------------------------------- /app/wiring/AppComponents.scala: -------------------------------------------------------------------------------- 1 | package wiring 2 | 3 | import com.github.ghik.silencer.silent 4 | import controllers.UserController 5 | import com.softwaremill.macwire._ 6 | import play.api.ApplicationLoader.Context 7 | import play.api._ 8 | import router.Routes 9 | import users.{AkkaEventStore, InMemoryUserRepository, UserService} 10 | 11 | @silent("never used") 12 | class AppComponents(context: Context) extends BuiltInComponentsFromContext(context) with NoHttpFiltersComponents { 13 | 14 | private implicit val as = actorSystem 15 | 16 | private lazy val eventStore = wire[AkkaEventStore] 17 | private lazy val userRepository = wire[InMemoryUserRepository] 18 | 19 | private lazy val userService = wire[UserService] 20 | 21 | // Controllers 22 | private lazy val userController = wire[UserController] 23 | 24 | // Router 25 | lazy val router = { 26 | val routePrefix: String = "/" 27 | wire[Routes] 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /app/libs/http.scala: -------------------------------------------------------------------------------- 1 | package libs 2 | 3 | import play.api.mvc.{Action, ActionBuilder, BodyParser, Result} 4 | import zio.{IO, Runtime, UIO} 5 | 6 | object http { 7 | 8 | //TODO use ZIO environment if need to do more complex dependency injection 9 | val runtime = Runtime.default 10 | 11 | implicit class ActionBuilderOps[+R[_], B](actionBuilder: ActionBuilder[R, B]) { 12 | 13 | def zio[E](zioActionBody: R[B] => IO[E, Result]): Action[B] = actionBuilder.async { request => 14 | runtime.unsafeRunToFuture( 15 | ioToTask(zioActionBody(request)) 16 | ) 17 | } 18 | 19 | def zio[E, A](bp: BodyParser[A])(zioActionBody: R[A] => IO[E, Result]): Action[A] = actionBuilder(bp).async { request => 20 | runtime.unsafeRunToFuture( 21 | ioToTask(zioActionBody(request)) 22 | ) 23 | } 24 | 25 | private def ioToTask[E, A](io: IO[E, A]) = 26 | io.mapError { 27 | case t: Throwable => t 28 | case s: String => new Throwable(s) 29 | case e => new Throwable("Error: " + e.toString) 30 | } 31 | } 32 | 33 | implicit class RecoverIO[E, A](io: IO[E, A]) { 34 | def recover(f: E => A): UIO[A] = io.fold(f, identity) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Play / ZIO integration 2 | 3 | This project shows ZIO integration in a playframework application. 4 | 5 | ## Run the project 6 | 7 | ``` 8 | sbt ~run 9 | ``` 10 | 11 | ## Apis 12 | 13 | ### List users 14 | 15 | ``` 16 | curl -XGET http://localhost:9000/users 17 | ``` 18 | 19 | ### Create a user 20 | 21 | ``` 22 | curl -XPOST http://localhost:9000/users -H 'Content-Type: application/json' -d ' 23 | { 24 | "email": "ragnar.lodbrock@gmail.com", 25 | "name": "Ragnar Lodbrock", 26 | "birthDate": "1981-04-01" 27 | }' | jq 28 | ``` 29 | 30 | #### Validation error : 31 | 32 | ``` 33 | curl -XPOST http://localhost:9000/users -H 'Content-Type: application/json' -d ' 34 | { 35 | "email": "ragnar.lodbrock@gmail.com", 36 | "name": "Ragnar Lodbrock", 37 | "birthDate": "1981-04-01", 38 | "drivingLicenceDate": "1995-04-01" 39 | }' | jq 40 | ``` 41 | 42 | 43 | ### Update a user 44 | 45 | ``` 46 | curl -XPUT http://localhost:9000/users/ragnar.lodbrock@gmail.com -H 'Content-Type: application/json' -d ' 47 | { 48 | "email": "ragnar.lodbrock@gmail.com", 49 | "name": "Ragnar Lodbrock", 50 | "birthDate": "1981-04-01" 51 | }' | jq 52 | ``` 53 | 54 | ### Delete a user 55 | 56 | ``` 57 | curl -XDELETE http://localhost:9000/users/ragnar.lodbrock@gmail.com --include 58 | ``` 59 | 60 | ### get a user 61 | 62 | ``` 63 | curl -XGET http://localhost:9000/users/ragnar.lodbrock@gmail.com 64 | ``` 65 | 66 | 67 | -------------------------------------------------------------------------------- /conf/application.conf: -------------------------------------------------------------------------------- 1 | # This is the main configuration file for the application. 2 | # ~~~~~ 3 | 4 | # Secret key 5 | # ~~~~~ 6 | # The secret key is used to secure cryptographics functions. 7 | # 8 | # This must be changed for production, but we recommend not changing it in this file. 9 | # 10 | # See http://www.playframework.com/documentation/latest/ApplicationSecret for more details. 11 | play.http.secret.key = "changeme" 12 | 13 | # The application languages 14 | # ~~~~~ 15 | play.i18n.langs = [ "en" ] 16 | 17 | # Router 18 | # ~~~~~ 19 | # Define the Router object to use for this application. 20 | # This router will be looked up first when the application is starting up, 21 | # so make sure this is the entry point. 22 | # Furthermore, it's assumed your route file is named properly. 23 | # So for an application router like `my.application.Router`, 24 | # you may need to define a router file `conf/my.application.routes`. 25 | # Default to Routes in the root package (and conf/routes) 26 | # play.http.router = my.application.Routes 27 | 28 | # Database configuration 29 | # ~~~~~ 30 | # You can declare as many datasources as you want. 31 | # By convention, the default datasource is named `default` 32 | # 33 | # db.default.driver=org.h2.Driver 34 | # db.default.url="jdbc:h2:mem:play" 35 | # db.default.username=sa 36 | # db.default.password="" 37 | 38 | # Evolutions 39 | # ~~~~~ 40 | # You can disable evolutions if needed 41 | # play.evolutions.enabled=false 42 | 43 | # You can disable evolutions for a specific datasource if necessary 44 | # play.evolutions.db.default.enabled=false 45 | 46 | play.application.loader = wiring.AppLoader -------------------------------------------------------------------------------- /conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ${application.home:-.}/logs/application.log 8 | 9 | %date [%level] from %logger in %thread - %message%n%xException 10 | 11 | 12 | 13 | 14 | 15 | %coloredLevel %logger{15} - %message%n%xException{10} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /app/controllers/UserController.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import play.api.libs.json._ 4 | import play.api.mvc._ 5 | import users.{AppError, DataValidationError, User, UserService} 6 | import zio.IO 7 | 8 | class UserController( 9 | userService: UserService, 10 | controllerComponents: ControllerComponents 11 | ) extends AbstractController(controllerComponents) { 12 | 13 | private def jsonValidation[A](jsValue: JsValue)(implicit reads: Reads[A]) = 14 | IO.fromEither(jsValue.validate[A].asEither).mapError(e => DataValidationError(e.toString)) 15 | 16 | import libs.http._ 17 | 18 | // Action.zio takes a function returning a IO for a given request 19 | def createUser(): Action[JsValue] = Action.zio(parse.json) { req => 20 | val user = for { 21 | toCreate <- jsonValidation[User](req.body) 22 | created <- userService.createUser(toCreate) 23 | } yield created 24 | 25 | // return a Bad Request status if an error is found, else return the user in Json format 26 | user.fold({ 27 | case e => BadRequest(Json.obj("error" -> e.message)) 28 | }, user => Ok(Json.toJson(user))) 29 | 30 | } 31 | 32 | def updateUser(id: String): Action[JsValue] = 33 | Action.zio(parse.json) { req => 34 | val user: IO[AppError, User] = for { 35 | toUpdate <- jsonValidation[User](req.body) 36 | updated <- userService 37 | .updateUser(id, toUpdate) 38 | } yield updated 39 | 40 | // you can also return different http codes depending on the error 41 | user.fold( 42 | { 43 | case e: DataValidationError => BadRequest(Json.obj("error" -> e.message)) 44 | case e => InternalServerError(Json.obj("error" -> e.message)) 45 | }, 46 | user => Ok(Json.toJson(user)) 47 | ) 48 | } 49 | 50 | def deleteUser(id: String): Action[AnyContent] = Action.zio { _ => 51 | userService 52 | .deleteUser(id) 53 | .fold(e => BadRequest(Json.obj("error" -> e.message)), _ => NoContent) 54 | } 55 | 56 | def getUser(id: String): Action[AnyContent] = Action.zio { _ => 57 | userService 58 | .get(id) 59 | .map { 60 | case Some(user) => Ok(Json.toJson(user)) 61 | case None => NotFound(Json.obj()) 62 | } 63 | .recover(e => BadRequest(Json.obj("error" -> e.message))) 64 | } 65 | 66 | def listUser(): Action[AnyContent] = Action.zio { _ => 67 | userService 68 | .list() 69 | .map(users => Ok(Json.toJson(users))) 70 | .recover(e => BadRequest(Json.obj("error" -> e.message))) 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /app/users/user.scala: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import java.time.{LocalDate, Period} 4 | 5 | import akka.actor.ActorSystem 6 | import play.api.libs.json.Json 7 | import users.Event.{UserCreated, UserDeleted, UserUpdated} 8 | import zio.IO 9 | 10 | import scala.collection.concurrent.TrieMap 11 | 12 | case class User(email: String, name: String, birthDate: LocalDate, drivingLicenceDate: Option[LocalDate]) 13 | 14 | object User { 15 | implicit val format = Json.format[User] 16 | } 17 | 18 | sealed trait Event 19 | 20 | object Event { 21 | 22 | case class UserCreated(user: User) extends Event 23 | 24 | case class UserUpdated(id: String, user: User) extends Event 25 | 26 | case class UserDeleted(id: String) extends Event 27 | 28 | } 29 | 30 | abstract class AppError(val message: String) 31 | case class DataValidationError(override val message: String) extends AppError(message) 32 | case class DatabaseAccessError(override val message: String) extends AppError(message) 33 | 34 | trait UserRepository { 35 | 36 | def get(id: String): IO[DatabaseAccessError, Option[User]] 37 | 38 | def set(id: String, user: User): IO[DatabaseAccessError, Unit] 39 | 40 | def delete(id: String): IO[DatabaseAccessError, Unit] 41 | 42 | def list(): IO[DatabaseAccessError, Seq[User]] 43 | 44 | } 45 | 46 | trait EventStore { 47 | def publish(event: Event): IO[DatabaseAccessError, Unit] 48 | } 49 | 50 | class InMemoryUserRepository extends UserRepository { 51 | private val datas = TrieMap.empty[String, User] 52 | 53 | override def get(id: String) = IO.succeed(datas.get(id)) 54 | 55 | override def set(id: String, user: User) = IO.succeed(datas.update(id, user)) 56 | 57 | override def delete(id: String) = IO.succeed(datas.remove(id).fold(())(_ => ())) 58 | 59 | override def list() = IO.succeed(datas.values.toSeq) 60 | } 61 | 62 | class AkkaEventStore(implicit system: ActorSystem) extends EventStore { 63 | override def publish(event: Event) = IO.succeed(system.eventStream.publish(event)) 64 | } 65 | 66 | class UserService(userRepository: UserRepository, eventStore: EventStore) { 67 | 68 | def createUser(user: User): IO[AppError, User] = 69 | for { 70 | _ <- IO.fromEither(validateDrivingLicence(user)) 71 | mayBeUser <- userRepository.get(user.email) 72 | _ <- mayBeUser.map(_ => IO.fail(DataValidationError("User already exist"))).getOrElse(IO.succeed(())) 73 | _ <- userRepository.set(user.email, user) 74 | _ <- eventStore.publish(UserCreated(user)) 75 | } yield user 76 | 77 | def updateUser(id: String, user: User): IO[AppError, User] = 78 | for { 79 | _ <- IO.fromEither(validateDrivingLicence(user)) 80 | mayBeUser <- userRepository.get(user.email) 81 | _ <- mayBeUser.map(IO.succeed(_)).getOrElse(IO.fail(DataValidationError("User not exist, can't be updated"))) 82 | _ <- userRepository.set(user.email, user) 83 | _ <- eventStore.publish(UserUpdated(id, user)) 84 | } yield user 85 | 86 | def deleteUser(id: String): IO[DatabaseAccessError, Unit] = 87 | userRepository 88 | .delete(id) 89 | .flatMap(_ => eventStore.publish(UserDeleted(id))) 90 | 91 | def get(id: String): IO[DatabaseAccessError, Option[User]] = 92 | userRepository.get(id) 93 | 94 | def list(): IO[DatabaseAccessError, Seq[User]] = userRepository.list() 95 | 96 | private def validateDrivingLicence(user: User): Either[DataValidationError, User] = { 97 | val licenceMinimumAge = user.birthDate.plusYears(18) 98 | (user.drivingLicenceDate, user.birthDate) match { 99 | case (Some(licenceDate), birthDate) if age(birthDate) >= 18 && licenceDate.isAfter(licenceMinimumAge) => 100 | Right(user) 101 | case (Some(_), _) => 102 | Left(DataValidationError("Too young to get a licence")) 103 | case (None, _) => 104 | Right(user) 105 | } 106 | } 107 | 108 | private def age(date: LocalDate): Int = Period.between(date, LocalDate.now()).getYears 109 | 110 | } 111 | --------------------------------------------------------------------------------