├── .scalafmt.conf ├── docker-compose.yml ├── src └── main │ ├── resources │ └── application.conf │ └── scala │ └── com │ └── rockthejvm │ └── bank │ ├── app │ └── BankApp.scala │ ├── http │ ├── Validation.scala │ └── BankRouter.scala │ └── actors │ ├── PersistentBankAccount.scala │ └── Bank.scala ├── README.md └── .gitignore /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 2.7.5 2 | 3 | align.preset = more 4 | maxColumn = 100 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | cassandra: 5 | image: cassandra:4.0.3 6 | ports: 7 | - 9042:9042 8 | environment: 9 | - CASSANDRA_CLUSTER_NAME=akka-cassandra-cluster -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | # Journal 2 | akka.persistence.journal.plugin = "akka.persistence.cassandra.journal" 3 | akka.persistence.cassandra.journal.keyspace-autocreate = true 4 | akka.persistence.cassandra.journal.tables-autocreate = true 5 | datastax-java-driver.advanced.reconnect-on-init = true 6 | 7 | # Snapshot 8 | akka.persistence.snapshot-store.plugin = "akka.persistence.cassandra.snapshot" 9 | akka.persistence.cassandra.snapshot.keyspace-autocreate = true 10 | akka.persistence.cassandra.snapshot.tables-autocreate = true 11 | 12 | akka.actor.allow-java-serialization = on 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Akka Cassandra Demo 2 | 3 | ### Available Routes 4 | 5 | #### Adding a new bank account 6 | 7 | ```shell 8 | curl -v -X POST http://localhost:8080/bank-accounts\ 9 | -H 'Content-Type: application/json'\ 10 | -d '{"user":"rcardin", "currency":"EUR", "balance": 1000.0}' 11 | ``` 12 | 13 | #### Updating the balance of a bank account 14 | 15 | ```shell 16 | curl -v -X PUT http://localhost:8080/bank-accounts/5e36bcd7-dd7d-43d6-90f0-de08cd9f551d\ 17 | -H 'Content-Type: application/json'\ 18 | -d '{"currency":"EUR", "amount": 500.0}' 19 | ``` 20 | 21 | #### Retrieving the details of a bank account 22 | 23 | ```shell 24 | curl -v http://localhost:8080/bank-accounts/ce1f4ac3-f1be-4523-b323-25e81d90322f 25 | ``` -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/bank/app/BankApp.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.bank.app 2 | 3 | import akka.actor.typed.{ActorRef, ActorSystem, Behavior} 4 | import akka.actor.typed.scaladsl.Behaviors 5 | import com.rockthejvm.bank.actors.Bank 6 | import com.rockthejvm.bank.actors.PersistentBankAccount.Command 7 | import akka.actor.typed.scaladsl.AskPattern._ 8 | import akka.http.scaladsl.Http 9 | import akka.util.Timeout 10 | import com.rockthejvm.bank.http.BankRouter 11 | 12 | import scala.concurrent.{ExecutionContext, Future} 13 | import scala.concurrent.duration._ 14 | import scala.util.{Try, Success, Failure} 15 | 16 | object BankApp { 17 | 18 | def startHttpServer(bank: ActorRef[Command])(implicit system: ActorSystem[_]): Unit = { 19 | implicit val ec: ExecutionContext = system.executionContext 20 | val router = new BankRouter(bank) 21 | val routes = router.routes 22 | 23 | val httpBindingFuture = Http().newServerAt("localhost", 8080).bind(routes) 24 | httpBindingFuture.onComplete { 25 | case Success(binding) => 26 | val address = binding.localAddress 27 | system.log.info(s"Server online at http://${address.getHostString}:${address.getPort}") 28 | case Failure(ex) => 29 | system.log.error(s"Failed to bind HTTP server, because: $ex") 30 | system.terminate() 31 | } 32 | } 33 | 34 | def main(args: Array[String]): Unit = { 35 | trait RootCommand 36 | case class RetrieveBankActor(replyTo: ActorRef[ActorRef[Command]]) extends RootCommand 37 | 38 | val rootBehavior: Behavior[RootCommand] = Behaviors.setup { context => 39 | val bankActor = context.spawn(Bank(), "bank") 40 | Behaviors.receiveMessage { 41 | case RetrieveBankActor(replyTo) => 42 | replyTo ! bankActor 43 | Behaviors.same 44 | } 45 | } 46 | 47 | implicit val system: ActorSystem[RootCommand] = ActorSystem(rootBehavior, "BankSystem") 48 | implicit val timeout: Timeout = Timeout(5.seconds) 49 | implicit val ec: ExecutionContext = system.executionContext 50 | 51 | val bankActorFuture: Future[ActorRef[Command]] = system.ask(replyTo => RetrieveBankActor(replyTo)) 52 | bankActorFuture.foreach(startHttpServer) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/bank/http/Validation.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.bank.http 2 | 3 | import cats.data.ValidatedNel 4 | import cats.implicits._ 5 | 6 | object Validation { 7 | 8 | // field must be present 9 | trait Required[A] extends (A => Boolean) 10 | // minimum value 11 | trait Minimum[A] extends ((A, Double) => Boolean) // for numerical fields 12 | trait MinimumAbs[A] extends ((A, Double) => Boolean) // for numerical fields 13 | 14 | // TC instances 15 | implicit val requiredString: Required[String] = _.nonEmpty 16 | implicit val minimumInt: Minimum[Int] = _ >= _ 17 | implicit val minimumDouble: Minimum[Double] = _ >= _ 18 | implicit val minimumIntAbs: MinimumAbs[Int] = Math.abs(_) >= _ 19 | implicit val minimumDoubleAbs: MinimumAbs[Double] = Math.abs(_) >= _ 20 | 21 | // usage 22 | def required[A](value: A)(implicit req: Required[A]): Boolean = req(value) 23 | def minimum[A](value: A, threshold: Double)(implicit min: Minimum[A]): Boolean = min(value, threshold) 24 | def minimumAbs[A](value: A, threshold: Double)(implicit min: MinimumAbs[A]): Boolean = min(value, threshold) 25 | 26 | // Validated 27 | type ValidationResult[A] = ValidatedNel[ValidationFailure, A] 28 | 29 | // validation failures 30 | trait ValidationFailure { 31 | def errorMessage: String 32 | } 33 | 34 | case class EmptyField(fieldName: String) extends ValidationFailure { 35 | override def errorMessage = s"$fieldName is empty" 36 | } 37 | 38 | case class NegativeValue(fieldName: String) extends ValidationFailure { 39 | override def errorMessage = s"$fieldName is negative" 40 | } 41 | 42 | case class BelowMinimumValue(fieldName: String, min: Double) extends ValidationFailure { 43 | override def errorMessage = s"$fieldName is below the minimum threshold $min" 44 | } 45 | 46 | // "main" API 47 | def validateMinimum[A: Minimum](value: A, threshold: Double, fieldName: String): ValidationResult[A] = { 48 | if (minimum(value, threshold)) value.validNel 49 | else if (threshold == 0) NegativeValue(fieldName).invalidNel 50 | else BelowMinimumValue(fieldName, threshold).invalidNel 51 | } 52 | 53 | def validateMinimumAbs[A: MinimumAbs](value: A, threshold: Double, fieldName: String): ValidationResult[A] = { 54 | if (minimumAbs(value, threshold)) value.validNel 55 | else BelowMinimumValue(fieldName, threshold).invalidNel 56 | } 57 | 58 | def validateRequired[A: Required](value: A, fieldName: String): ValidationResult[A] = 59 | if (required(value)) value.validNel 60 | else EmptyField(fieldName).invalidNel 61 | 62 | // general TC for requests 63 | trait Validator[A] { 64 | def validate(value: A): ValidationResult[A] 65 | } 66 | 67 | def validateEntity[A](value: A)(implicit validator: Validator[A]): ValidationResult[A] = 68 | validator.validate(value) 69 | } 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | .bsp/ 4 | .idea/ 5 | project/ 6 | 7 | 8 | # Created by https://www.toptal.com/developers/gitignore/api/scala,sbt,intellij 9 | # Edit at https://www.toptal.com/developers/gitignore?templates=scala,sbt,intellij 10 | 11 | ### Intellij ### 12 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 13 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 14 | 15 | # User-specific stuff 16 | .idea/**/workspace.xml 17 | .idea/**/tasks.xml 18 | .idea/**/usage.statistics.xml 19 | .idea/**/dictionaries 20 | .idea/**/shelf 21 | 22 | # AWS User-specific 23 | .idea/**/aws.xml 24 | 25 | # Generated files 26 | .idea/**/contentModel.xml 27 | 28 | # Sensitive or high-churn files 29 | .idea/**/dataSources/ 30 | .idea/**/dataSources.ids 31 | .idea/**/dataSources.local.xml 32 | .idea/**/sqlDataSources.xml 33 | .idea/**/dynamic.xml 34 | .idea/**/uiDesigner.xml 35 | .idea/**/dbnavigator.xml 36 | 37 | # Gradle 38 | .idea/**/gradle.xml 39 | .idea/**/libraries 40 | 41 | # Gradle and Maven with auto-import 42 | # When using Gradle or Maven with auto-import, you should exclude module files, 43 | # since they will be recreated, and may cause churn. Uncomment if using 44 | # auto-import. 45 | # .idea/artifacts 46 | # .idea/compiler.xml 47 | # .idea/jarRepositories.xml 48 | # .idea/modules.xml 49 | # .idea/*.iml 50 | # .idea/modules 51 | # *.iml 52 | # *.ipr 53 | 54 | # CMake 55 | cmake-build-*/ 56 | 57 | # Mongo Explorer plugin 58 | .idea/**/mongoSettings.xml 59 | 60 | # File-based project format 61 | *.iws 62 | 63 | # IntelliJ 64 | out/ 65 | 66 | # mpeltonen/sbt-idea plugin 67 | .idea_modules/ 68 | 69 | # JIRA plugin 70 | atlassian-ide-plugin.xml 71 | 72 | # Cursive Clojure plugin 73 | .idea/replstate.xml 74 | 75 | # SonarLint plugin 76 | .idea/sonarlint/ 77 | 78 | # Crashlytics plugin (for Android Studio and IntelliJ) 79 | com_crashlytics_export_strings.xml 80 | crashlytics.properties 81 | crashlytics-build.properties 82 | fabric.properties 83 | 84 | # Editor-based Rest Client 85 | .idea/httpRequests 86 | 87 | # Android studio 3.1+ serialized cache file 88 | .idea/caches/build_file_checksums.ser 89 | 90 | ### Intellij Patch ### 91 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 92 | 93 | # *.iml 94 | # modules.xml 95 | # .idea/misc.xml 96 | # *.ipr 97 | 98 | # Sonarlint plugin 99 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 100 | .idea/**/sonarlint/ 101 | 102 | # SonarQube Plugin 103 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 104 | .idea/**/sonarIssues.xml 105 | 106 | # Markdown Navigator plugin 107 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 108 | .idea/**/markdown-navigator.xml 109 | .idea/**/markdown-navigator-enh.xml 110 | .idea/**/markdown-navigator/ 111 | 112 | # Cache file creation bug 113 | # See https://youtrack.jetbrains.com/issue/JBR-2257 114 | .idea/$CACHE_FILE$ 115 | 116 | # CodeStream plugin 117 | # https://plugins.jetbrains.com/plugin/12206-codestream 118 | .idea/codestream.xml 119 | 120 | ### SBT ### 121 | # Simple Build Tool 122 | # http://www.scala-sbt.org/release/docs/Getting-Started/Directories.html#configuring-version-control 123 | 124 | dist/* 125 | target/ 126 | lib_managed/ 127 | src_managed/ 128 | project/boot/ 129 | project/plugins/project/ 130 | .history 131 | .cache 132 | .lib/ 133 | 134 | ### Scala ### 135 | *.class 136 | *.log 137 | 138 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 139 | hs_err_pid* 140 | 141 | # End of https://www.toptal.com/developers/gitignore/api/scala,sbt,intellij 142 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/bank/actors/PersistentBankAccount.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.bank.actors 2 | 3 | import akka.actor.typed.{ActorRef, Behavior} 4 | import akka.persistence.typed.PersistenceId 5 | import akka.persistence.typed.scaladsl.{Effect, EventSourcedBehavior} 6 | 7 | import scala.util.{Failure, Success, Try} 8 | 9 | // a single bank account 10 | object PersistentBankAccount { 11 | 12 | /* 13 | - fault tolerance 14 | - auditing 15 | */ 16 | 17 | // commands = messages 18 | sealed trait Command 19 | object Command { 20 | case class CreateBankAccount(user: String, currency: String, initialBalance: Double, replyTo: ActorRef[Response]) extends Command 21 | case class UpdateBalance(id: String, currency: String, amount: Double /* can be < 0*/, replyTo: ActorRef[Response]) extends Command 22 | case class GetBankAccount(id: String, replyTo: ActorRef[Response]) extends Command 23 | } 24 | 25 | // events = to persist to Cassandra 26 | trait Event 27 | case class BankAccountCreated(bankAccount: BankAccount) extends Event 28 | case class BalanceUpdated(amount: Double) extends Event 29 | 30 | // state 31 | case class BankAccount(id: String, user: String, currency: String, balance: Double) 32 | 33 | // responses 34 | sealed trait Response 35 | object Response { 36 | case class BankAccountCreatedResponse(id: String) extends Response 37 | case class BankAccountBalanceUpdatedResponse(maybeBankAccount: Try[BankAccount]) extends Response 38 | case class GetBankAccountResponse(maybeBankAccount: Option[BankAccount]) extends Response 39 | } 40 | 41 | import Command._ 42 | import Response._ 43 | 44 | // command handler = message handler => persist an event 45 | // event handler => update state 46 | // state 47 | 48 | val commandHandler: (BankAccount, Command) => Effect[Event, BankAccount] = (state, command) => 49 | command match { 50 | case CreateBankAccount(user, currency, initialBalance, bank) => 51 | val id = state.id 52 | /* 53 | - bank creates me 54 | - bank sends me CreateBankAccount 55 | - I persist BankAccountCreated 56 | - I update my state 57 | - reply back to bank with the BankAccountCreatedResponse 58 | - (the bank surfaces the response to the HTTP server) 59 | */ 60 | Effect 61 | .persist(BankAccountCreated(BankAccount(id, user, currency, initialBalance))) // persisted into Cassandra 62 | .thenReply(bank)(_ => BankAccountCreatedResponse(id)) 63 | case UpdateBalance(_, _, amount, bank) => 64 | val newBalance = state.balance + amount 65 | // check here for withdrawal 66 | if (newBalance < 0) // illegal 67 | Effect.reply(bank)(BankAccountBalanceUpdatedResponse(Failure(new RuntimeException("Cannot withdraw more than available")))) 68 | else 69 | Effect 70 | .persist(BalanceUpdated(amount)) 71 | .thenReply(bank)(newState => BankAccountBalanceUpdatedResponse(Success(newState))) 72 | case GetBankAccount(_, bank) => 73 | Effect.reply(bank)(GetBankAccountResponse(Some(state))) 74 | } 75 | 76 | val eventHandler: (BankAccount, Event) => BankAccount = (state, event) => 77 | event match { 78 | case BankAccountCreated(bankAccount) => 79 | bankAccount 80 | case BalanceUpdated(amount) => 81 | state.copy(balance = state.balance + amount) 82 | } 83 | 84 | def apply(id: String): Behavior[Command] = 85 | EventSourcedBehavior[Command, Event, BankAccount]( 86 | persistenceId = PersistenceId.ofUniqueId(id), 87 | emptyState = BankAccount(id, "", "", 0.0), // unused 88 | commandHandler = commandHandler, 89 | eventHandler = eventHandler 90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/bank/actors/Bank.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.bank.actors 2 | 3 | import akka.NotUsed 4 | import akka.actor.typed.scaladsl.{ActorContext, Behaviors} 5 | import akka.actor.typed.{ActorRef, ActorSystem, Behavior, Scheduler} 6 | import akka.persistence.typed.PersistenceId 7 | import akka.persistence.typed.scaladsl.{Effect, EventSourcedBehavior} 8 | import akka.util.Timeout 9 | 10 | import java.util.UUID 11 | import scala.concurrent.ExecutionContext 12 | import scala.util.Failure 13 | 14 | 15 | object Bank { 16 | 17 | // commands = messages 18 | import PersistentBankAccount.Command._ 19 | import PersistentBankAccount.Response._ 20 | import PersistentBankAccount.Command 21 | 22 | // events 23 | sealed trait Event 24 | case class BankAccountCreated(id: String) extends Event 25 | 26 | // state 27 | case class State(accounts: Map[String, ActorRef[Command]]) 28 | 29 | // command handler 30 | def commandHandler(context: ActorContext[Command]): (State, Command) => Effect[Event, State] = (state, command) => 31 | command match { 32 | case createCommand @ CreateBankAccount(_, _, _, _) => 33 | val id = UUID.randomUUID().toString 34 | val newBankAccount = context.spawn(PersistentBankAccount(id), id) 35 | Effect 36 | .persist(BankAccountCreated(id)) 37 | .thenReply(newBankAccount)(_ => createCommand) 38 | case updateCmd @ UpdateBalance(id, _, _, replyTo) => 39 | state.accounts.get(id) match { 40 | case Some(account) => 41 | Effect.reply(account)(updateCmd) 42 | case None => 43 | Effect.reply(replyTo)(BankAccountBalanceUpdatedResponse(Failure(new RuntimeException("Bank account cannot be found")))) // failed account search 44 | } 45 | case getCmd @ GetBankAccount(id, replyTo) => 46 | state.accounts.get(id) match { 47 | case Some(account) => 48 | Effect.reply(account)(getCmd) 49 | case None => 50 | Effect.reply(replyTo)(GetBankAccountResponse(None)) // failed search 51 | } 52 | } 53 | 54 | // event handler 55 | def eventHandler(context: ActorContext[Command]): (State, Event) => State = (state, event) => 56 | event match { 57 | case BankAccountCreated(id) => 58 | val account = context.child(id) // exists after command handler, 59 | .getOrElse(context.spawn(PersistentBankAccount(id), id)) // does NOT exist in the recovery mode, so needs to be created 60 | .asInstanceOf[ActorRef[Command]] 61 | state.copy(state.accounts + (id -> account)) 62 | } 63 | 64 | // behavior 65 | def apply(): Behavior[Command] = Behaviors.setup { context => 66 | EventSourcedBehavior[Command, Event, State]( 67 | persistenceId = PersistenceId.ofUniqueId("bank"), 68 | emptyState = State(Map()), 69 | commandHandler = commandHandler(context), 70 | eventHandler = eventHandler(context) 71 | ) 72 | } 73 | } 74 | 75 | object BankPlayground { 76 | import PersistentBankAccount.Command._ 77 | import PersistentBankAccount.Response._ 78 | import PersistentBankAccount.Response 79 | 80 | def main(args: Array[String]): Unit = { 81 | val rootBehavior: Behavior[NotUsed] = Behaviors.setup { context => 82 | val bank = context.spawn(Bank(), "bank") 83 | val logger = context.log 84 | 85 | val responseHandler = context.spawn(Behaviors.receiveMessage[Response]{ 86 | case BankAccountCreatedResponse(id) => 87 | logger.info(s"successfully created bank account $id") 88 | Behaviors.same 89 | case GetBankAccountResponse(maybeBankAccount) => 90 | logger.info(s"Account details: $maybeBankAccount") 91 | Behaviors.same 92 | }, "replyHandler") 93 | 94 | // ask pattern 95 | import akka.actor.typed.scaladsl.AskPattern._ 96 | import scala.concurrent.duration._ 97 | implicit val timeout: Timeout = Timeout(2.seconds) 98 | implicit val scheduler: Scheduler = context.system.scheduler 99 | implicit val ec: ExecutionContext = context.executionContext 100 | 101 | // bank ! CreateBankAccount("daniel", "USD", 10, responseHandler) 102 | // bank ! GetBankAccount("deda8465-ddc3-4988-a584-4019d55a3045", responseHandler) 103 | 104 | Behaviors.empty 105 | } 106 | 107 | val system = ActorSystem(rootBehavior, "BankDemo") 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/bank/http/BankRouter.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.bank.http 2 | 3 | import akka.http.scaladsl.server.Directives._ 4 | import akka.actor.typed.{ActorRef, ActorSystem} 5 | import akka.http.scaladsl.model.StatusCodes 6 | import akka.http.scaladsl.model.headers.Location 7 | import com.rockthejvm.bank.actors.PersistentBankAccount.Command 8 | import com.rockthejvm.bank.actors.PersistentBankAccount.Command._ 9 | import com.rockthejvm.bank.actors.PersistentBankAccount.Response 10 | import com.rockthejvm.bank.actors.PersistentBankAccount.Response._ 11 | import io.circe.generic.auto._ 12 | import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._ 13 | import akka.actor.typed.scaladsl.AskPattern._ 14 | import akka.util.Timeout 15 | 16 | import scala.concurrent.Future 17 | import scala.concurrent.duration._ 18 | import scala.util.{Success, Failure} 19 | import Validation._ 20 | import akka.http.scaladsl.server.Route 21 | import cats.data.Validated.{Invalid, Valid} 22 | import cats.implicits._ 23 | 24 | case class BankAccountCreationRequest(user: String, currency: String, balance: Double) { 25 | def toCommand(replyTo: ActorRef[Response]): Command = CreateBankAccount(user, currency, balance, replyTo) 26 | } 27 | 28 | object BankAccountCreationRequest { 29 | implicit val validator: Validator[BankAccountCreationRequest] = new Validator[BankAccountCreationRequest] { 30 | override def validate(request: BankAccountCreationRequest): ValidationResult[BankAccountCreationRequest] = { 31 | val userValidation = validateRequired(request.user, "user") 32 | val currencyValidation = validateRequired(request.currency, "currency") 33 | val balanceValidation = validateMinimum(request.balance, 0, "balance") 34 | .combine(validateMinimumAbs(request.balance, 0.01, "balance")) 35 | 36 | (userValidation, currencyValidation, balanceValidation).mapN(BankAccountCreationRequest.apply) 37 | } 38 | } 39 | } 40 | 41 | 42 | case class BankAccountUpdateRequest(currency: String, amount: Double) { 43 | def toCommand(id: String, replyTo: ActorRef[Response]): Command = UpdateBalance(id, currency, amount, replyTo) 44 | } 45 | 46 | object BankAccountUpdateRequest { 47 | implicit val validator: Validator[BankAccountUpdateRequest] = new Validator[BankAccountUpdateRequest] { 48 | override def validate(request: BankAccountUpdateRequest): ValidationResult[BankAccountUpdateRequest] = { 49 | val currencyValidation = validateRequired(request.currency, "currency") 50 | val amountValidation = validateMinimumAbs(request.amount, 0.01, "amount") 51 | 52 | (currencyValidation, amountValidation).mapN(BankAccountUpdateRequest.apply) 53 | } 54 | } 55 | } 56 | 57 | case class FailureResponse(reason: String) 58 | 59 | class BankRouter(bank: ActorRef[Command])(implicit system: ActorSystem[_]) { 60 | implicit val timeout: Timeout = Timeout(5.seconds) 61 | 62 | def createBankAccount(request: BankAccountCreationRequest): Future[Response] = 63 | bank.ask(replyTo => request.toCommand(replyTo)) 64 | 65 | def getBankAccount(id: String): Future[Response] = 66 | bank.ask(replyTo => GetBankAccount(id, replyTo)) 67 | 68 | def updateBankAccount(id: String, request: BankAccountUpdateRequest): Future[Response] = 69 | bank.ask(replyTo => request.toCommand(id, replyTo)) 70 | 71 | def validateRequest[R: Validator](request: R)(routeIfValid: Route): Route = 72 | validateEntity(request) match { 73 | case Valid(_) => 74 | routeIfValid 75 | case Invalid(failures) => 76 | complete(StatusCodes.BadRequest, FailureResponse(failures.toList.map(_.errorMessage).mkString(", "))) 77 | } 78 | /* 79 | POST /bank/ 80 | Payload: bank account creation request as JSON 81 | Response: 82 | 201 Created 83 | Location: /bank/uuid 84 | 85 | GET /bank/uuid 86 | Response: 87 | 200 OK 88 | JSON repr of bank account details 89 | 90 | 404 Not found 91 | 92 | PUT /bank/uuid 93 | Payload: (currency, amount) as JSON 94 | Response: 95 | 1) 200 OK 96 | Payload: new bank details as JSON 97 | 2) 404 Not found 98 | */ 99 | val routes = 100 | pathPrefix("bank") { 101 | pathEndOrSingleSlash { 102 | post { 103 | // parse the payload 104 | entity(as[BankAccountCreationRequest]) { request => 105 | // validation 106 | validateRequest(request) { 107 | /* 108 | - convert the request into a Command for the bank actor 109 | - send the command to the bank 110 | - expect a reply 111 | */ 112 | onSuccess(createBankAccount(request)) { 113 | // send back an HTTP response 114 | case BankAccountCreatedResponse(id) => 115 | respondWithHeader(Location(s"/bank/$id")) { 116 | complete(StatusCodes.Created) 117 | } 118 | } 119 | } 120 | } 121 | } 122 | } ~ 123 | path(Segment) { id => 124 | get { 125 | /* 126 | - send command to the bank 127 | - expect a reply 128 | */ 129 | onSuccess(getBankAccount(id)) { 130 | // - send back the HTTP response 131 | case GetBankAccountResponse(Some(account)) => 132 | complete(account) // 200 OK 133 | case GetBankAccountResponse(None) => 134 | complete(StatusCodes.NotFound, FailureResponse(s"Bank account $id cannot be found.")) 135 | } 136 | } ~ 137 | put { 138 | entity(as[BankAccountUpdateRequest]) { request => 139 | // validation 140 | validateRequest(request) { 141 | /* 142 | - transform the request to a Command 143 | - send the command to the bank 144 | - expect a reply 145 | */ 146 | onSuccess(updateBankAccount(id, request)) { 147 | // send HTTP response 148 | case BankAccountBalanceUpdatedResponse(Success(account)) => 149 | complete(account) 150 | case BankAccountBalanceUpdatedResponse(Failure(ex)) => 151 | complete(StatusCodes.BadRequest, FailureResponse(s"${ex.getMessage}")) 152 | } 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | } 160 | --------------------------------------------------------------------------------