├── .gitignore ├── .sbtopts ├── .travis.yml ├── LICENSE ├── README.md ├── build.sbt ├── deploy ├── inventory.yaml ├── kafka-single.yaml ├── kafka.yaml └── shopping-cart.yaml ├── docker-compose.yml ├── inventory-api └── src │ └── main │ └── scala │ └── com │ └── example │ └── inventory │ └── api │ └── InventoryService.scala ├── inventory └── src │ └── main │ ├── resources │ ├── application.conf │ └── prod-application.conf │ └── scala │ └── com │ └── example │ └── inventory │ └── impl │ ├── InventoryLoader.scala │ └── InventoryServiceImpl.scala ├── project ├── build.properties └── plugins.sbt ├── schemas └── shopping-cart.sql ├── shopping-cart-api └── src │ └── main │ └── scala │ └── com │ └── example │ └── shoppingcart │ └── api │ └── ShoppingCartService.scala └── shopping-cart └── src ├── main ├── resources │ ├── application.conf │ └── prod-application.conf └── scala │ └── com │ └── example │ └── shoppingcart │ └── impl │ ├── ShoppingCartEntity.scala │ ├── ShoppingCartLoader.scala │ └── ShoppingCartServiceImpl.scala └── test ├── resources └── logback.xml └── scala └── com └── example └── shoppingcart └── impl └── ShoppingCartEntitySpec.scala /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | ideatarget 3 | .target 4 | bin 5 | .cache 6 | .cache-main 7 | .cache-tests 8 | .classpath 9 | .project 10 | .tmpBin 11 | .factorypath 12 | .settings 13 | logs 14 | -------------------------------------------------------------------------------- /.sbtopts: -------------------------------------------------------------------------------- 1 | -J-Xms512M 2 | -J-Xmx4096M 3 | -J-Xss2M 4 | -J-XX:MaxMetaspaceSize=1024M 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | before_install: curl -Ls https://git.io/jabba | bash && . ~/.jabba/jabba.sh && jabba install "$TRAVIS_JDK" && jabba use $_ && java -version 3 | script: sbt test 4 | 5 | env: 6 | - TRAVIS_JDK=adopt@1.8.192-12 7 | - TRAVIS_JDK=adopt@1.11.0-1 8 | 9 | matrix: 10 | fast_finish: true 11 | allow_failures: 12 | - env: TRAVIS_JDK=adopt@1.11.0-1 # not fully supported but allows problem discovery 13 | 14 | cache: 15 | directories: 16 | - $HOME/.ivy2/cache 17 | - $HOME/.jabba/jdk 18 | - $HOME/.sbt 19 | 20 | before_cache: 21 | - find $HOME/.ivy2 -name "ivydata-*.properties" -delete 22 | - find $HOME/.sbt -name "*.lock" -delete 23 | -------------------------------------------------------------------------------- /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 4 | use this file except in compliance with the License. You may obtain a copy of 5 | the License at 6 | 7 | [http://www.apache.org/licenses/LICENSE-2.0] 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | License for the specific language governing permissions and limitations under 13 | the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This sample application is now maintained at . 2 | 3 | # Shopping Cart 4 | 5 | This sample application demonstrates a simple shopping cart built with Lagom. It contains two services, a shopping cart service, for managing shopping carts, and an inventory service, for tracking inventory. 6 | 7 | The shopping cart service persists its data to a relational database using Lagom's persistence API, and is intended to demonstrate how to persist state using Lagom. 8 | 9 | The inventory service consumes a stream of events published to Kafka by the shopping cart service, and is intended to demonstrate how Kafka event streams can be consumed in Lagom. However, it doesn't persist its state to a database, it just stores it in memory, and this memory is not shared across nodes. Hence, it should not be used as an example of how to store state in Lagom. 10 | 11 | ## Setup 12 | 13 | To run this application locally you will need access to a Postgres database. We suggest you run it on a docker container but a local or remote native instance will also work. 14 | 15 | We provide a `docker-compose.yml` file that you can use to run a Postgres database already configured for this application. The docker container will be exposed on port 5432. 16 | 17 | To create the image and start the container, run the command below at the root of this project. 18 | 19 | ```bash 20 | docker-compose up -d 21 | ``` 22 | 23 | If you prefer to run Postgres natively on your machine, you need to create the database, the user and password yourself. The application expects it to be running on localhost on the default port (5432), and it expects there to be a database called `shopping_cart`, with a user called `shopping_cart` with password `shopping_cart` that has full access to it. This can be created using the following SQL: 24 | 25 | ```sql 26 | CREATE DATABASE shopping_cart; 27 | CREATE USER shopping_cart WITH PASSWORD 'shopping_cart'; 28 | GRANT ALL PRIVILEGES ON DATABASE shopping_cart TO shopping_cart; 29 | ``` 30 | 31 | Once PostgreSQL is setup, you can start the system by running: 32 | 33 | ``` 34 | sbt runAll 35 | ``` 36 | 37 | ## Shopping cart service 38 | 39 | The shopping cart service offers three REST endpoints: 40 | 41 | * Get the current contents of the shopping cart: 42 | ``` 43 | curl http://localhost:9000/shoppingcart/123 44 | ``` 45 | * Update the quantity of an item in the shopping cart: 46 | ``` 47 | curl -H "Content-Type: application/json" -d '{"productId": "456", "quantity": 2}' -X POST http://localhost:9000/shoppingcart/123 48 | ``` 49 | * Checkout the shopping cart (ie, complete the transaction) 50 | ``` 51 | curl -X POST http://localhost:9000/shoppingcart/123/checkout 52 | ``` 53 | 54 | For simplicity, no authentication is implemented, shopping cart IDs are arbitrary and whoever makes the request can use whatever ID they want, and product IDs are also arbitrary and trusted. An a real world application, the shopping cart IDs would likely be random UUIDs to ensure uniqueness, and product IDs would be validated against a product database. 55 | 56 | When the shopping cart is checked out, an event is published to the Kafka called `shopping-cart` by the shopping cart service, such events look like this: 57 | 58 | ```json 59 | { 60 | "id": "123", 61 | "items": [ 62 | {"productId": "456", "quantity": 2}, 63 | {"productId": "789", "quantity": 1} 64 | ], 65 | "checkedOut": true 66 | } 67 | ``` 68 | 69 | ## Inventory service 70 | 71 | The inventory service offers two REST endpoints: 72 | 73 | * Get the inventory of an item: 74 | ``` 75 | curl http://localhost:9000/inventory/456 76 | ``` 77 | * Add to the inventory of an item: 78 | ``` 79 | curl -H "Content-Type: application/json" -d 4 -X POST http://localhost:9000/inventory/456 80 | ``` 81 | 82 | The inventory service consumes the `shopping-cart` topic from Kafka, and decrements the inventory according to the events. 83 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import com.lightbend.lagom.core.LagomVersion 2 | 3 | organization in ThisBuild := "com.example" 4 | 5 | // the Scala version that will be used for cross-compiled libraries 6 | scalaVersion in ThisBuild := "2.12.8" 7 | 8 | val postgresDriver = "org.postgresql" % "postgresql" % "42.2.5" 9 | val macwire = "com.softwaremill.macwire" %% "macros" % "2.3.2" % "provided" 10 | val scalaTest = "org.scalatest" %% "scalatest" % "3.0.7" % Test 11 | val akkaDiscoveryKubernetesApi = "com.lightbend.akka.discovery" %% "akka-discovery-kubernetes-api" % "1.0.0" 12 | val lagomScaladslAkkaDiscovery = "com.lightbend.lagom" %% "lagom-scaladsl-akka-discovery-service-locator" % LagomVersion.current 13 | 14 | ThisBuild / scalacOptions ++= List("-encoding", "utf8", "-deprecation", "-feature", "-unchecked", "-Xfatal-warnings") 15 | 16 | def dockerSettings = Seq( 17 | dockerUpdateLatest := true, 18 | dockerBaseImage := "adoptopenjdk/openjdk8", 19 | dockerUsername := sys.props.get("docker.username"), 20 | dockerRepository := sys.props.get("docker.registry") 21 | ) 22 | 23 | // Update the version generated by sbt-dynver to remove any + characters, since these are illegal in docker tags 24 | version in ThisBuild ~= (_.replace('+', '-')) 25 | dynver in ThisBuild ~= (_.replace('+', '-')) 26 | 27 | lazy val `shopping-cart-scala` = (project in file(".")) 28 | .aggregate(`shopping-cart-api`, `shopping-cart`, `inventory-api`, inventory) 29 | 30 | lazy val `shopping-cart-api` = (project in file("shopping-cart-api")) 31 | .settings( 32 | libraryDependencies ++= Seq( 33 | lagomScaladslApi 34 | ) 35 | ) 36 | 37 | lazy val `shopping-cart` = (project in file("shopping-cart")) 38 | .enablePlugins(LagomScala) 39 | .settings( 40 | libraryDependencies ++= Seq( 41 | lagomScaladslPersistenceJdbc, 42 | lagomScaladslKafkaBroker, 43 | lagomScaladslTestKit, 44 | macwire, 45 | scalaTest, 46 | postgresDriver, 47 | lagomScaladslAkkaDiscovery, 48 | akkaDiscoveryKubernetesApi 49 | ) 50 | ) 51 | .settings(dockerSettings) 52 | .settings(lagomForkedTestSettings) 53 | .dependsOn(`shopping-cart-api`) 54 | 55 | lazy val `inventory-api` = (project in file("inventory-api")) 56 | .settings( 57 | libraryDependencies ++= Seq( 58 | lagomScaladslApi 59 | ) 60 | ) 61 | 62 | lazy val inventory = (project in file("inventory")) 63 | .enablePlugins(LagomScala) 64 | .settings( 65 | libraryDependencies ++= Seq( 66 | lagomScaladslKafkaClient, 67 | lagomScaladslTestKit, 68 | macwire, 69 | scalaTest, 70 | lagomScaladslAkkaDiscovery 71 | ) 72 | ) 73 | .settings(dockerSettings) 74 | .dependsOn(`inventory-api`, `shopping-cart-api`) 75 | 76 | lagomCassandraEnabled in ThisBuild := false 77 | -------------------------------------------------------------------------------- /deploy/inventory.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "apps/v1beta2" 2 | kind: Deployment 3 | metadata: 4 | name: inventory 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: inventory 10 | 11 | template: 12 | metadata: 13 | labels: 14 | app: inventory 15 | spec: 16 | containers: 17 | - name: inventory 18 | image: "inventory:latest" 19 | env: 20 | - name: JAVA_OPTS 21 | value: "-Xms256m -Xmx256m -Dconfig.resource=prod-application.conf" 22 | - name: APPLICATION_SECRET 23 | valueFrom: 24 | secretKeyRef: 25 | name: inventory-application-secret 26 | key: secret 27 | - name: KAFKA_SERVICE_NAME 28 | value: "_clients._tcp.strimzi-kafka-brokers" 29 | resources: 30 | limits: 31 | memory: 512Mi 32 | requests: 33 | cpu: 0.25 34 | memory: 512Mi 35 | --- 36 | apiVersion: v1 37 | kind: Service 38 | metadata: 39 | name: inventory 40 | spec: 41 | ports: 42 | - name: http 43 | port: 80 44 | targetPort: 9000 45 | selector: 46 | app: inventory 47 | type: LoadBalancer 48 | -------------------------------------------------------------------------------- /deploy/kafka-single.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kafka.strimzi.io/v1alpha1 2 | kind: Kafka 3 | metadata: 4 | name: strimzi 5 | spec: 6 | kafka: 7 | replicas: 1 8 | listeners: 9 | plain: {} 10 | tls: {} 11 | config: 12 | offsets.topic.replication.factor: 1 13 | transaction.state.log.replication.factor: 1 14 | transaction.state.log.min.isr: 1 15 | storage: 16 | type: ephemeral 17 | zookeeper: 18 | replicas: 1 19 | storage: 20 | type: ephemeral 21 | entityOperator: 22 | topicOperator: {} 23 | userOperator: {} -------------------------------------------------------------------------------- /deploy/kafka.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kafka.strimzi.io/v1alpha1 2 | kind: Kafka 3 | metadata: 4 | name: strimzi 5 | spec: 6 | kafka: 7 | replicas: 3 8 | listeners: 9 | plain: {} 10 | tls: {} 11 | config: 12 | offsets.topic.replication.factor: 3 13 | transaction.state.log.replication.factor: 3 14 | transaction.state.log.min.isr: 2 15 | storage: 16 | type: ephemeral 17 | zookeeper: 18 | replicas: 1 19 | storage: 20 | type: ephemeral 21 | entityOperator: 22 | topicOperator: {} 23 | userOperator: {} -------------------------------------------------------------------------------- /deploy/shopping-cart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "apps/v1" 2 | kind: Deployment 3 | metadata: 4 | name: shopping-cart 5 | spec: 6 | replicas: 3 7 | selector: 8 | matchLabels: 9 | app: shopping-cart 10 | 11 | template: 12 | metadata: 13 | labels: 14 | app: shopping-cart 15 | spec: 16 | containers: 17 | - name: shopping-cart 18 | image: "shopping-cart:latest" 19 | env: 20 | - name: JAVA_OPTS 21 | value: "-Xms256m -Xmx256m -Dconfig.resource=prod-application.conf" 22 | - name: APPLICATION_SECRET 23 | valueFrom: 24 | secretKeyRef: 25 | name: shopping-cart-application-secret 26 | key: secret 27 | - name: POSTGRESQL_URL 28 | value: "jdbc:postgresql://postgresql/shopping_cart" 29 | - name: POSTGRESQL_USERNAME 30 | valueFrom: 31 | secretKeyRef: 32 | name: postgres-shopping-cart 33 | key: username 34 | - name: POSTGRESQL_PASSWORD 35 | valueFrom: 36 | secretKeyRef: 37 | name: postgres-shopping-cart 38 | key: password 39 | - name: KAFKA_SERVICE_NAME 40 | value: "_clients._tcp.strimzi-kafka-brokers" 41 | - name: REQUIRED_CONTACT_POINT_NR 42 | value: "3" 43 | ports: 44 | - name: management 45 | containerPort: 8558 46 | readinessProbe: 47 | httpGet: 48 | path: "/ready" 49 | port: management 50 | periodSeconds: 10 51 | failureThreshold: 10 52 | initialDelaySeconds: 20 53 | livenessProbe: 54 | httpGet: 55 | path: "/alive" 56 | port: management 57 | periodSeconds: 10 58 | failureThreshold: 10 59 | initialDelaySeconds: 20 60 | resources: 61 | limits: 62 | memory: 512Mi 63 | requests: 64 | cpu: 0.25 65 | memory: 512Mi 66 | --- 67 | apiVersion: v1 68 | kind: Service 69 | metadata: 70 | name: shopping-cart 71 | spec: 72 | ports: 73 | - name: http 74 | port: 80 75 | targetPort: 9000 76 | selector: 77 | app: shopping-cart 78 | type: LoadBalancer 79 | --- 80 | kind: Role 81 | apiVersion: rbac.authorization.k8s.io/v1 82 | metadata: 83 | name: pod-reader 84 | rules: 85 | - apiGroups: [""] 86 | resources: ["pods"] 87 | verbs: ["get", "watch", "list"] 88 | --- 89 | kind: RoleBinding 90 | apiVersion: rbac.authorization.k8s.io/v1 91 | metadata: 92 | name: read-pods 93 | subjects: 94 | - kind: User 95 | name: system:serviceaccount:myproject:default 96 | roleRef: 97 | kind: Role 98 | name: pod-reader 99 | apiGroup: rbac.authorization.k8s.io 100 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | postgres: 2 | image: postgres:latest 3 | container_name: shopping_cart_postgres 4 | environment: 5 | - "TZ=Europe/Amsterdam" 6 | - "POSTGRES_USER=shopping_cart" 7 | - "POSTGRES_PASSWORD=shopping_cart" 8 | ports: 9 | - "5432:5432" # credentials (shopping_cart:shopping_cart) 10 | -------------------------------------------------------------------------------- /inventory-api/src/main/scala/com/example/inventory/api/InventoryService.scala: -------------------------------------------------------------------------------- 1 | package com.example.inventory.api 2 | 3 | import akka.{Done, NotUsed} 4 | import com.lightbend.lagom.scaladsl.api.transport.Method 5 | import com.lightbend.lagom.scaladsl.api.{Service, ServiceCall} 6 | 7 | /** 8 | * The inventory service interface. 9 | * 10 | * This describes everything that Lagom needs to know about how to serve and 11 | * consume the inventory service. 12 | */ 13 | trait InventoryService extends Service { 14 | 15 | /** 16 | * Get the inventory level for the given product id. 17 | */ 18 | def get(productId: String): ServiceCall[NotUsed, Int] 19 | 20 | /** 21 | * Add inventory to the given product id. 22 | */ 23 | def add(productId: String): ServiceCall[Int, Done] 24 | 25 | override final def descriptor = { 26 | import Service._ 27 | 28 | named("inventory") 29 | .withCalls( 30 | restCall(Method.GET, "/inventory/:productId", get _), 31 | restCall(Method.POST, "/inventory/:productId", add _) 32 | ).withAutoAcl(true) 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /inventory/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | play.application.loader = com.example.inventory.impl.InventoryLoader 2 | 3 | -------------------------------------------------------------------------------- /inventory/src/main/resources/prod-application.conf: -------------------------------------------------------------------------------- 1 | include "application" 2 | 3 | play { 4 | server { 5 | pidfile.path = "/dev/null" 6 | } 7 | 8 | http.secret.key = "${APPLICATION_SECRET}" 9 | } 10 | 11 | akka { 12 | discovery.method = akka-dns 13 | } -------------------------------------------------------------------------------- /inventory/src/main/scala/com/example/inventory/impl/InventoryLoader.scala: -------------------------------------------------------------------------------- 1 | package com.example.inventory.impl 2 | 3 | import com.lightbend.lagom.scaladsl.server._ 4 | import com.lightbend.lagom.scaladsl.devmode.LagomDevModeComponents 5 | import play.api.libs.ws.ahc.AhcWSComponents 6 | import com.example.inventory.api.InventoryService 7 | import com.example.shoppingcart.api.ShoppingCartService 8 | import com.lightbend.lagom.scaladsl.akka.discovery.AkkaDiscoveryComponents 9 | import com.lightbend.lagom.scaladsl.broker.kafka.LagomKafkaClientComponents 10 | import com.softwaremill.macwire._ 11 | 12 | class InventoryLoader extends LagomApplicationLoader { 13 | override def load(context: LagomApplicationContext): LagomApplication = 14 | new InventoryApplication(context) with AkkaDiscoveryComponents 15 | 16 | override def loadDevMode(context: LagomApplicationContext): LagomApplication = 17 | new InventoryApplication(context) with LagomDevModeComponents 18 | 19 | override def describeService = Some(readDescriptor[InventoryService]) 20 | } 21 | 22 | abstract class InventoryApplication(context: LagomApplicationContext) 23 | extends LagomApplication(context) 24 | with LagomKafkaClientComponents 25 | with AhcWSComponents { 26 | 27 | // Bind the service that this server provides 28 | override lazy val lagomServer = serverFor[InventoryService](wire[InventoryServiceImpl]) 29 | 30 | // Bind the ShoppingcartService client 31 | lazy val shoppingCartService = serviceClient.implement[ShoppingCartService] 32 | } 33 | -------------------------------------------------------------------------------- /inventory/src/main/scala/com/example/inventory/impl/InventoryServiceImpl.scala: -------------------------------------------------------------------------------- 1 | package com.example.inventory.impl 2 | 3 | import java.util.concurrent.atomic.AtomicInteger 4 | 5 | import akka.stream.scaladsl.Flow 6 | import akka.{Done, NotUsed} 7 | import com.lightbend.lagom.scaladsl.api.ServiceCall 8 | import com.example.inventory.api.InventoryService 9 | import com.example.shoppingcart.api.{ShoppingCart, ShoppingCartService} 10 | 11 | import scala.collection.concurrent.TrieMap 12 | import scala.concurrent.Future 13 | 14 | /** 15 | * Implementation of the inventory service. 16 | * 17 | * This just stores the inventory in memory, so it will be lost on restart, and also won't work 18 | * with more than one replicas, but it's enough to demonstrate things working. 19 | */ 20 | class InventoryServiceImpl(shoppingCartService: ShoppingCartService) extends InventoryService { 21 | 22 | private val inventory = TrieMap.empty[String, AtomicInteger] 23 | 24 | private def getInventory(productId: String) = inventory.getOrElseUpdate(productId, new AtomicInteger) 25 | 26 | shoppingCartService.shoppingCartTopic.subscribe.atLeastOnce(Flow[ShoppingCart].map { cart => 27 | // Since this is at least once event handling, we really should track by shopping cart, and 28 | // not update inventory if we've already seen this shopping cart. But this is an in memory 29 | // inventory tracker anyway, so no need to be that careful. 30 | cart.items.foreach { item => 31 | getInventory(item.productId).addAndGet(-item.quantity) 32 | } 33 | Done 34 | }) 35 | 36 | override def get(productId: String): ServiceCall[NotUsed, Int] = ServiceCall { _ => 37 | Future.successful(inventory.get(productId).fold(0)(_.get())) 38 | } 39 | 40 | override def add(productId: String): ServiceCall[Int, Done] = ServiceCall { quantity => 41 | getInventory(productId).addAndGet(quantity) 42 | Future.successful(Done) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.2.8 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // The Lagom plugin 2 | addSbtPlugin("com.lightbend.lagom" % "lagom-sbt-plugin" % "1.5.1") 3 | // Set the version dynamically to the git hash 4 | addSbtPlugin("com.dwijnand" % "sbt-dynver" % "3.3.0") 5 | // Not needed once upgraded to Play 2.7.1 6 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.19") 7 | 8 | -------------------------------------------------------------------------------- /schemas/shopping-cart.sql: -------------------------------------------------------------------------------- 1 | -- From https://github.com/dnvriend/akka-persistence-jdbc/blob/master/src/test/resources/schema/postgres/postgres-schema.sql 2 | 3 | CREATE TABLE IF NOT EXISTS journal ( 4 | ordering BIGSERIAL, 5 | persistence_id VARCHAR(255) NOT NULL, 6 | sequence_number BIGINT NOT NULL, 7 | deleted BOOLEAN DEFAULT FALSE, 8 | tags VARCHAR(255) DEFAULT NULL, 9 | message BYTEA NOT NULL, 10 | PRIMARY KEY(persistence_id, sequence_number) 11 | ); 12 | 13 | CREATE UNIQUE INDEX journal_ordering_idx ON journal(ordering); 14 | 15 | CREATE TABLE IF NOT EXISTS snapshot ( 16 | persistence_id VARCHAR(255) NOT NULL, 17 | sequence_number BIGINT NOT NULL, 18 | created BIGINT NOT NULL, 19 | snapshot BYTEA NOT NULL, 20 | PRIMARY KEY(persistence_id, sequence_number) 21 | ); 22 | 23 | CREATE TABLE read_side_offsets ( 24 | read_side_id VARCHAR(255), tag VARCHAR(255), 25 | sequence_offset bigint, time_uuid_offset char(36), 26 | PRIMARY KEY (read_side_id, tag) 27 | ) 28 | -------------------------------------------------------------------------------- /shopping-cart-api/src/main/scala/com/example/shoppingcart/api/ShoppingCartService.scala: -------------------------------------------------------------------------------- 1 | package com.example.shoppingcart.api 2 | 3 | import akka.{Done, NotUsed} 4 | import com.lightbend.lagom.scaladsl.api.broker.Topic 5 | import com.lightbend.lagom.scaladsl.api.broker.kafka.{KafkaProperties, PartitionKeyStrategy} 6 | import com.lightbend.lagom.scaladsl.api.transport.Method 7 | import com.lightbend.lagom.scaladsl.api.{Service, ServiceCall} 8 | import play.api.libs.json.{Format, Json} 9 | 10 | object ShoppingCartService { 11 | val TOPIC_NAME = "shopping-cart" 12 | } 13 | 14 | /** 15 | * The ShoppingCart service interface. 16 | *

17 | * This describes everything that Lagom needs to know about how to serve and 18 | * consume the ShoppingCartService. 19 | */ 20 | trait ShoppingCartService extends Service { 21 | 22 | /** 23 | * Get a shopping cart. 24 | * 25 | * Example: curl http://localhost:9000/shoppingcart/123 26 | */ 27 | def get(id: String): ServiceCall[NotUsed, ShoppingCart] 28 | 29 | /** 30 | * Update an items quantity in the shopping cart. 31 | * 32 | * Example: curl -H "Content-Type: application/json" -X POST -d '{"productId": 456, "quantity": 2}' http://localhost:9000/shoppingcart/123 33 | */ 34 | def updateItem(id: String): ServiceCall[ShoppingCartItem, Done] 35 | 36 | /** 37 | * Checkout the shopping cart. 38 | * 39 | * Example: curl -X POST http://localhost:9000/shoppingcart/123/checkout 40 | */ 41 | def checkout(id: String): ServiceCall[NotUsed, Done] 42 | 43 | /** 44 | * This gets published to Kafka. 45 | */ 46 | def shoppingCartTopic: Topic[ShoppingCart] 47 | 48 | override final def descriptor = { 49 | import Service._ 50 | // @formatter:off 51 | named("shopping-cart") 52 | .withCalls( 53 | restCall(Method.GET, "/shoppingcart/:id", get _), 54 | restCall(Method.POST, "/shoppingcart/:id", updateItem _), 55 | restCall(Method.POST, "/shoppingcart/:id/checkout", checkout _) 56 | ) 57 | .withTopics( 58 | topic(ShoppingCartService.TOPIC_NAME, shoppingCartTopic) 59 | // Kafka partitions messages, messages within the same partition will 60 | // be delivered in order, to ensure that all messages for the same user 61 | // go to the same partition (and hence are delivered in order with respect 62 | // to that user), we configure a partition key strategy that extracts the 63 | // name as the partition key. 64 | .addProperty( 65 | KafkaProperties.partitionKeyStrategy, 66 | PartitionKeyStrategy[ShoppingCart](_.id) 67 | ) 68 | ) 69 | .withAutoAcl(true) 70 | // @formatter:on 71 | } 72 | } 73 | 74 | /** 75 | * A shopping cart item. 76 | * 77 | * @param productId The ID of the product. 78 | * @param quantity The quantity of the product. 79 | */ 80 | case class ShoppingCartItem(productId: String, quantity: Int) 81 | 82 | object ShoppingCartItem { 83 | /** 84 | * Format for converting the shopping cart item to and from JSON. 85 | * 86 | * This will be picked up by a Lagom implicit conversion from Play's JSON format to Lagom's message serializer. 87 | */ 88 | implicit val format: Format[ShoppingCartItem] = Json.format 89 | } 90 | 91 | /** 92 | * A shopping cart. 93 | * 94 | * @param id The id of the shopping cart. 95 | * @param items The items in the shopping cart. 96 | * @param checkedOut Whether the shopping cart has been checked out (submitted). 97 | */ 98 | case class ShoppingCart(id: String, items: Seq[ShoppingCartItem], checkedOut: Boolean) 99 | 100 | object ShoppingCart { 101 | 102 | implicit val format: Format[ShoppingCart] = Json.format 103 | } -------------------------------------------------------------------------------- /shopping-cart/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | play.application.loader = com.example.shoppingcart.impl.ShoppingCartLoader 4 | 5 | db.default { 6 | driver = "org.postgresql.Driver" 7 | url = "jdbc:postgresql://localhost/shopping_cart" 8 | username = "shopping_cart" 9 | password = "shopping_cart" 10 | } 11 | 12 | jdbc-defaults.slick.profile = "slick.jdbc.PostgresProfile$" 13 | 14 | # The properties below override Lagom default configuration with the recommended values for new projects. 15 | # 16 | # Lagom has not yet made these settings the defaults for backward-compatibility reasons. 17 | 18 | # Prefer 'ddata' over 'persistence' to share cluster sharding state for new projects. 19 | # See https://doc.akka.io/docs/akka/current/cluster-sharding.html#distributed-data-vs-persistence-mode 20 | akka.cluster.sharding.state-store-mode = ddata 21 | 22 | # Enable the serializer provided in Akka 2.5.8+ for akka.Done and other internal 23 | # messages to avoid the use of Java serialization. 24 | akka.actor.serialization-bindings { 25 | "akka.Done" = akka-misc 26 | "akka.actor.Address" = akka-misc 27 | "akka.remote.UniqueAddress" = akka-misc 28 | } 29 | -------------------------------------------------------------------------------- /shopping-cart/src/main/resources/prod-application.conf: -------------------------------------------------------------------------------- 1 | include "application" 2 | 3 | play { 4 | server { 5 | pidfile.path = "/dev/null" 6 | } 7 | 8 | http.secret.key = "${APPLICATION_SECRET}" 9 | } 10 | 11 | db.default { 12 | url = ${POSTGRESQL_URL} 13 | username = ${POSTGRESQL_USERNAME} 14 | password = ${POSTGRESQL_PASSWORD} 15 | } 16 | 17 | lagom.persistence.jdbc.create-tables.auto = false 18 | 19 | akka { 20 | discovery.method = akka-dns 21 | 22 | cluster { 23 | shutdown-after-unsuccessful-join-seed-nodes = 60s 24 | } 25 | 26 | management { 27 | cluster.bootstrap { 28 | contact-point-discovery { 29 | discovery-method = kubernetes-api 30 | service-name = "shopping-cart" 31 | required-contact-point-nr = ${REQUIRED_CONTACT_POINT_NR} 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /shopping-cart/src/main/scala/com/example/shoppingcart/impl/ShoppingCartEntity.scala: -------------------------------------------------------------------------------- 1 | package com.example.shoppingcart.impl 2 | 3 | import akka.Done 4 | import com.lightbend.lagom.scaladsl.persistence.{AggregateEvent, AggregateEventTag, PersistentEntity} 5 | import com.lightbend.lagom.scaladsl.persistence.PersistentEntity.ReplyType 6 | import com.lightbend.lagom.scaladsl.playjson.{JsonSerializer, JsonSerializerRegistry} 7 | import play.api.libs.json._ 8 | 9 | import scala.collection.immutable.Seq 10 | 11 | /** 12 | * This is an event sourced entity. It has a state, [[ShoppingCartState]], which 13 | * stores the current shopping cart items and whether it's checked out. 14 | * 15 | * Event sourced entities are interacted with by sending them commands. This 16 | * entity supports three commands, an [[UpdateItem]] crommand, which is used to 17 | * update the quantity of an item in the cart, a [[Checkout]] command which is 18 | * used to set checkout the shopping cart, and a [[Get]] command, which is a read 19 | * only command which returns the current shopping cart state. 20 | * 21 | * Commands get translated to events, and it's the events that get persisted by 22 | * the entity. Each event will have an event handler registered for it, and an 23 | * event handler simply applies an event to the current state. This will be done 24 | * when the event is first created, and it will also be done when the entity is 25 | * loaded from the database - each event will be replayed to recreate the state 26 | * of the entity. 27 | * 28 | * This entity defines two events, the [[ItemUpdated]] event, which is emitted 29 | * when a [[UpdateItem]] command is received, and a [[CheckedOut]] event, which 30 | * is emitted when a [[Checkout]] command is received. 31 | */ 32 | class ShoppingCartEntity extends PersistentEntity { 33 | 34 | import play.api.libs.functional.syntax._ 35 | def naStringSerializer: Format[Option[String]] = 36 | implicitly[Format[String]].inmap( 37 | str => Some(str).filterNot(_ == "N/A"), 38 | maybeStr => maybeStr.getOrElse("N/A") 39 | ) 40 | 41 | override type Command = ShoppingCartCommand[_] 42 | override type Event = ShoppingCartEvent 43 | override type State = ShoppingCartState 44 | 45 | /** 46 | * The initial state. This is used if there is no snapshotted state to be found. 47 | */ 48 | override def initialState: ShoppingCartState = ShoppingCartState(Map.empty, false) 49 | 50 | /** 51 | * An entity can define different behaviours for different states, so the behaviour 52 | * is a function of the current state to a set of actions. 53 | */ 54 | override def behavior: Behavior = { 55 | case ShoppingCartState(items, false) => openShoppingCart 56 | case ShoppingCartState(items, true) => checkedOut 57 | } 58 | 59 | def openShoppingCart = { 60 | Actions().onCommand[UpdateItem, Done] { 61 | 62 | // Command handler for the UpdateItem command 63 | case (UpdateItem(_, quantity), ctx, _) if quantity < 0 => 64 | ctx.commandFailed(ShoppingCartException("Quantity must be greater than zero")) 65 | ctx.done 66 | case (UpdateItem(productId, 0), ctx, state) if !state.items.contains(productId) => 67 | ctx.commandFailed(ShoppingCartException("Cannot delete item that is not already in cart")) 68 | ctx.done 69 | case (UpdateItem(productId, quantity), ctx, _) => 70 | // In response to this command, we want to first persist it as a 71 | // ItemUpdated event 72 | ctx.thenPersist( 73 | ItemUpdated(productId, quantity) 74 | ) { _ => 75 | // Then once the event is successfully persisted, we respond with done. 76 | ctx.reply(Done) 77 | } 78 | 79 | }.onCommand[Checkout.type, Done] { 80 | 81 | // Command handler for the Checkout command 82 | case (Checkout, ctx, state) if state.items.isEmpty => 83 | ctx.commandFailed(ShoppingCartException("Cannot checkout empty cart")) 84 | ctx.done 85 | case (Checkout, ctx, _) => 86 | // In response to this command, we want to first persist it as a 87 | // CheckedOut event 88 | ctx.thenPersist( 89 | CheckedOut 90 | ) { _ => 91 | // Then once the event is successfully persisted, we respond with done. 92 | ctx.reply(Done) 93 | } 94 | 95 | }.onReadOnlyCommand[Get.type, ShoppingCartState] { 96 | 97 | // Command handler for the Hello command 98 | case (Get, ctx, state) => 99 | // Reply with the current state. 100 | ctx.reply(state) 101 | 102 | }.onEvent(eventHandlers) 103 | } 104 | 105 | def checkedOut = { 106 | Actions().onReadOnlyCommand[Get.type, ShoppingCartState] { 107 | 108 | // Command handler for the Hello command 109 | case (Get, ctx, state) => 110 | // Reply with the current state. 111 | ctx.reply(state) 112 | 113 | }.onCommand[UpdateItem, Done] { 114 | 115 | // Not valid when checked out 116 | case (_, ctx, _) => 117 | ctx.commandFailed(ShoppingCartException("Can't update item on already checked out shopping cart")) 118 | ctx.done 119 | 120 | }.onCommand[Checkout.type, Done] { 121 | 122 | // Not valid when checked out 123 | case (_, ctx, _) => 124 | ctx.commandFailed(ShoppingCartException("Can't checkout on already checked out shopping cart")) 125 | ctx.done 126 | 127 | }.onEvent(eventHandlers) 128 | } 129 | 130 | def eventHandlers: EventHandler = { 131 | // Event handler for the ItemUpdated event 132 | case (ItemUpdated(productId: String, quantity: Int), state) => state.updateItem(productId, quantity) 133 | 134 | // Event handler for the checkout event 135 | case (CheckedOut, state) => state.checkout 136 | } 137 | } 138 | 139 | /** 140 | * The current state held by the persistent entity. 141 | */ 142 | case class ShoppingCartState(items: Map[String, Int], checkedOut: Boolean) { 143 | 144 | def updateItem(productId: String, quantity: Int) = { 145 | quantity match { 146 | case 0 => copy(items = items - productId) 147 | case _ => copy(items = items + (productId -> quantity)) 148 | } 149 | } 150 | 151 | def checkout = copy(checkedOut = true) 152 | } 153 | 154 | object ShoppingCartState { 155 | /** 156 | * Format for the hello state. 157 | * 158 | * Persisted entities get snapshotted every configured number of events. This 159 | * means the state gets stored to the database, so that when the entity gets 160 | * loaded, you don't need to replay all the events, just the ones since the 161 | * snapshot. Hence, a JSON format needs to be declared so that it can be 162 | * serialized and deserialized when storing to and from the database. 163 | */ 164 | implicit val format: Format[ShoppingCartState] = Json.format 165 | } 166 | 167 | /** 168 | * This interface defines all the events that the ShoppingCartEntity supports. 169 | */ 170 | sealed trait ShoppingCartEvent extends AggregateEvent[ShoppingCartEvent] { 171 | def aggregateTag = ShoppingCartEvent.Tag 172 | } 173 | 174 | object ShoppingCartEvent { 175 | val Tag = AggregateEventTag[ShoppingCartEvent] 176 | } 177 | 178 | /** 179 | * An event that represents a item updated event. 180 | */ 181 | case class ItemUpdated(productId: String, quantity: Int) extends ShoppingCartEvent 182 | 183 | object ItemUpdated { 184 | 185 | /** 186 | * Format for the item updated event. 187 | * 188 | * Events get stored and loaded from the database, hence a JSON format 189 | * needs to be declared so that they can be serialized and deserialized. 190 | */ 191 | implicit val format: Format[ItemUpdated] = Json.format 192 | } 193 | 194 | /** 195 | * An event that represents a checked out event. 196 | */ 197 | case object CheckedOut extends ShoppingCartEvent { 198 | 199 | /** 200 | * Format for the checked out event. 201 | * 202 | * Events get stored and loaded from the database, hence a JSON format 203 | * needs to be declared so that they can be serialized and deserialized. 204 | */ 205 | implicit val format: Format[CheckedOut.type] = Format( 206 | Reads(_ => JsSuccess(CheckedOut)), 207 | Writes(_ => Json.obj()) 208 | ) 209 | } 210 | 211 | /** 212 | * This interface defines all the commands that the ShoppingCartEntity supports. 213 | */ 214 | sealed trait ShoppingCartCommand[R] extends ReplyType[R] 215 | 216 | /** 217 | * A command to update an item. 218 | * 219 | * It has a reply type of [[Done]], which is sent back to the caller 220 | * when all the events emitted by this command are successfully persisted. 221 | */ 222 | case class UpdateItem(productId: String, quantity: Int) extends ShoppingCartCommand[Done] 223 | 224 | object UpdateItem { 225 | 226 | /** 227 | * Format for the update item command. 228 | * 229 | * Persistent entities get sharded across the cluster. This means commands 230 | * may be sent over the network to the node where the entity lives if the 231 | * entity is not on the same node that the command was issued from. To do 232 | * that, a JSON format needs to be declared so the command can be serialized 233 | * and deserialized. 234 | */ 235 | implicit val format: Format[UpdateItem] = Json.format 236 | } 237 | 238 | /** 239 | * A command to get the current state of the shopping cart. 240 | * 241 | * The reply type is the ShoppingCart, and will contain the message to say to that 242 | * person. 243 | */ 244 | case object Get extends ShoppingCartCommand[ShoppingCartState] { 245 | 246 | /** 247 | * Format for the Get command. 248 | * 249 | * Persistent entities get sharded across the cluster. This means commands 250 | * may be sent over the network to the node where the entity lives if the 251 | * entity is not on the same node that the command was issued from. To do 252 | * that, a JSON format needs to be declared so the command can be serialized 253 | * and deserialized. 254 | */ 255 | implicit val format: Format[Get.type] = Format( 256 | Reads(_ => JsSuccess(Get)), 257 | Writes(_ => Json.obj()) 258 | ) 259 | } 260 | 261 | /** 262 | * A command to checkout the shopping cart. 263 | * 264 | * The reply type is the Done, which will be returned when the events have been 265 | * emitted. 266 | */ 267 | case object Checkout extends ShoppingCartCommand[Done] { 268 | 269 | /** 270 | * Format for the Checkout command. 271 | * 272 | * Persistent entities get sharded across the cluster. This means commands 273 | * may be sent over the network to the node where the entity lives if the 274 | * entity is not on the same node that the command was issued from. To do 275 | * that, a JSON format needs to be declared so the command can be serialized 276 | * and deserialized. 277 | */ 278 | implicit val format: Format[Checkout.type] = Format( 279 | Reads(_ => JsSuccess(Checkout)), 280 | Writes(_ => Json.obj()) 281 | ) 282 | } 283 | 284 | /** 285 | * An exception thrown by the shopping cart validation 286 | * 287 | * @param message The message 288 | */ 289 | case class ShoppingCartException(message: String) extends RuntimeException(message) 290 | 291 | object ShoppingCartException { 292 | 293 | /** 294 | * Format for the ShoppingCartException. 295 | * 296 | * When a command fails, the error needs to be serialized and sent back to 297 | * the node that requested it, this is used to do that. 298 | */ 299 | implicit val format: Format[ShoppingCartException] = Json.format[ShoppingCartException] 300 | } 301 | 302 | /** 303 | * Akka serialization, used by both persistence and remoting, needs to have 304 | * serializers registered for every type serialized or deserialized. While it's 305 | * possible to use any serializer you want for Akka messages, out of the box 306 | * Lagom provides support for JSON, via this registry abstraction. 307 | * 308 | * The serializers are registered here, and then provided to Lagom in the 309 | * application loader. 310 | */ 311 | object ShoppingCartSerializerRegistry extends JsonSerializerRegistry { 312 | override def serializers: Seq[JsonSerializer[_]] = Seq( 313 | JsonSerializer[ItemUpdated], 314 | JsonSerializer[CheckedOut.type], 315 | JsonSerializer[UpdateItem], 316 | JsonSerializer[Checkout.type], 317 | JsonSerializer[Get.type], 318 | JsonSerializer[ShoppingCartState], 319 | JsonSerializer[ShoppingCartException] 320 | ) 321 | } 322 | -------------------------------------------------------------------------------- /shopping-cart/src/main/scala/com/example/shoppingcart/impl/ShoppingCartLoader.scala: -------------------------------------------------------------------------------- 1 | package com.example.shoppingcart.impl 2 | 3 | import com.example.shoppingcart.api.ShoppingCartService 4 | import com.lightbend.lagom.scaladsl.akka.discovery.AkkaDiscoveryComponents 5 | import com.lightbend.lagom.scaladsl.broker.kafka.LagomKafkaComponents 6 | import com.lightbend.lagom.scaladsl.devmode.LagomDevModeComponents 7 | import com.lightbend.lagom.scaladsl.persistence.jdbc.JdbcPersistenceComponents 8 | import com.lightbend.lagom.scaladsl.server._ 9 | import com.softwaremill.macwire._ 10 | import play.api.db.HikariCPComponents 11 | import play.api.libs.ws.ahc.AhcWSComponents 12 | 13 | class ShoppingCartLoader extends LagomApplicationLoader { 14 | 15 | override def load(context: LagomApplicationContext): LagomApplication = 16 | new ShoppingCartApplication(context) with AkkaDiscoveryComponents 17 | 18 | override def loadDevMode(context: LagomApplicationContext): LagomApplication = 19 | new ShoppingCartApplication(context) with LagomDevModeComponents 20 | 21 | override def describeService = Some(readDescriptor[ShoppingCartService]) 22 | } 23 | 24 | abstract class ShoppingCartApplication(context: LagomApplicationContext) 25 | extends LagomApplication(context) 26 | with JdbcPersistenceComponents 27 | with HikariCPComponents 28 | with LagomKafkaComponents 29 | with AhcWSComponents { 30 | 31 | // Bind the service that this server provides 32 | override lazy val lagomServer = serverFor[ShoppingCartService](wire[ShoppingCartServiceImpl]) 33 | 34 | // Register the JSON serializer registry 35 | override lazy val jsonSerializerRegistry = ShoppingCartSerializerRegistry 36 | 37 | // Register the ShoppingCart persistent entity 38 | persistentEntityRegistry.register(wire[ShoppingCartEntity]) 39 | } 40 | -------------------------------------------------------------------------------- /shopping-cart/src/main/scala/com/example/shoppingcart/impl/ShoppingCartServiceImpl.scala: -------------------------------------------------------------------------------- 1 | package com.example.shoppingcart.impl 2 | 3 | import akka.{Done, NotUsed} 4 | import com.example.shoppingcart.api.{ShoppingCart, ShoppingCartItem, ShoppingCartService} 5 | import com.lightbend.lagom.scaladsl.api.ServiceCall 6 | import com.lightbend.lagom.scaladsl.api.broker.Topic 7 | import com.lightbend.lagom.scaladsl.api.transport.BadRequest 8 | import com.lightbend.lagom.scaladsl.broker.TopicProducer 9 | import com.lightbend.lagom.scaladsl.persistence.{EventStreamElement, PersistentEntityRegistry} 10 | 11 | import scala.concurrent.ExecutionContext 12 | 13 | /** 14 | * Implementation of the [[ShoppingCartService]]. 15 | */ 16 | class ShoppingCartServiceImpl(persistentEntityRegistry: PersistentEntityRegistry)(implicit ec: ExecutionContext) extends ShoppingCartService { 17 | 18 | /** 19 | * Looks up the shopping cart entity for the given ID. 20 | */ 21 | private def entityRef(id: String) = 22 | persistentEntityRegistry.refFor[ShoppingCartEntity](id) 23 | 24 | override def get(id: String): ServiceCall[NotUsed, ShoppingCart] = ServiceCall { _ => 25 | entityRef(id) 26 | .ask(Get) 27 | .map(cart => convertShoppingCart(id, cart)) 28 | } 29 | 30 | override def updateItem(id: String): ServiceCall[ShoppingCartItem, Done] = ServiceCall { update => 31 | entityRef(id) 32 | .ask(UpdateItem(update.productId, update.quantity)) 33 | .recover { 34 | case ShoppingCartException(message) => throw BadRequest(message) 35 | } 36 | } 37 | 38 | override def checkout(id: String): ServiceCall[NotUsed, Done] = ServiceCall { _ => 39 | entityRef(id) 40 | .ask(Checkout) 41 | .recover { 42 | case ShoppingCartException(message) => throw BadRequest(message) 43 | } 44 | } 45 | 46 | override def shoppingCartTopic: Topic[ShoppingCart] = TopicProducer.singleStreamWithOffset { fromOffset => 47 | persistentEntityRegistry.eventStream(ShoppingCartEvent.Tag, fromOffset) 48 | .filter(_.event.isInstanceOf[CheckedOut.type]) 49 | .mapAsync(4) { 50 | case EventStreamElement(id, _, offset) => 51 | entityRef(id) 52 | .ask(Get) 53 | .map(cart => convertShoppingCart(id, cart) -> offset) 54 | } 55 | } 56 | 57 | private def convertShoppingCart(id: String, cart: ShoppingCartState) = { 58 | ShoppingCart(id, cart.items.map((ShoppingCartItem.apply _).tupled).toSeq, cart.checkedOut) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /shopping-cart/src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %date{ISO8601} %-5level %logger - %msg%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /shopping-cart/src/test/scala/com/example/shoppingcart/impl/ShoppingCartEntitySpec.scala: -------------------------------------------------------------------------------- 1 | package com.example.shoppingcart.impl 2 | 3 | import akka.Done 4 | import akka.actor.ActorSystem 5 | import akka.testkit.TestKit 6 | import com.lightbend.lagom.scaladsl.testkit.PersistentEntityTestDriver 7 | import com.lightbend.lagom.scaladsl.playjson.JsonSerializerRegistry 8 | import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} 9 | 10 | class ShoppingCartEntitySpec extends WordSpec with Matchers with BeforeAndAfterAll { 11 | 12 | private val system = ActorSystem("ShoppingcartEntitySpec", 13 | JsonSerializerRegistry.actorSystemSetupFor(ShoppingCartSerializerRegistry)) 14 | 15 | override protected def afterAll(): Unit = { 16 | TestKit.shutdownActorSystem(system) 17 | } 18 | 19 | private def withTestDriver(block: PersistentEntityTestDriver[ShoppingCartCommand[_], ShoppingCartEvent, ShoppingCartState] => Unit): Unit = { 20 | val driver = new PersistentEntityTestDriver(system, new ShoppingCartEntity, "shoppingcart-1") 21 | block(driver) 22 | driver.getAllIssues should have size 0 23 | } 24 | 25 | "ShoppingCart entity" should { 26 | 27 | "add an item" in withTestDriver { driver => 28 | val outcome = driver.run(UpdateItem("123", 2)) 29 | outcome.replies should contain only Done 30 | outcome.events should contain only ItemUpdated("123", 2) 31 | outcome.state should === (ShoppingCartState(Map("123" -> 2), false)) 32 | } 33 | 34 | "remove an item" in withTestDriver { driver => 35 | driver.run(UpdateItem("123", 2)) 36 | val outcome = driver.run(UpdateItem("123", 0)) 37 | outcome.replies should contain only Done 38 | outcome.events should contain only ItemUpdated("123", 0) 39 | outcome.state should === (ShoppingCartState(Map.empty, false)) 40 | } 41 | 42 | "update multiple items" in withTestDriver { driver => 43 | driver.run(UpdateItem("123", 2)).state should === (ShoppingCartState( 44 | Map("123" -> 2), false)) 45 | driver.run(UpdateItem("456", 3)).state should === (ShoppingCartState( 46 | Map("123" -> 2, "456" -> 3), false)) 47 | driver.run(UpdateItem("123", 1)).state should === (ShoppingCartState( 48 | Map("123" -> 1, "456" -> 3), false)) 49 | driver.run(UpdateItem("456", 0)).state should === (ShoppingCartState( 50 | Map("123" -> 1), false)) 51 | } 52 | 53 | "allow checking out" in withTestDriver { driver => 54 | driver.run(UpdateItem("123", 2)) 55 | val outcome = driver.run(Checkout) 56 | outcome.replies should contain only Done 57 | outcome.events should contain only CheckedOut 58 | outcome.state should === (ShoppingCartState(Map("123" -> 2), true)) 59 | } 60 | 61 | "allow getting the state" in withTestDriver { driver => 62 | driver.run(UpdateItem("123", 2)) 63 | val outcome = driver.run(Get) 64 | outcome.replies should contain only ShoppingCartState(Map("123" -> 2), false) 65 | outcome.events should have size 0 66 | } 67 | 68 | "fail when removing an item that isn't added" in withTestDriver { driver => 69 | val outcome = driver.run(UpdateItem("123", 0)) 70 | outcome.replies should have size 1 71 | outcome.replies.head shouldBe a[ShoppingCartException] 72 | outcome.events should have size 0 73 | } 74 | 75 | "fail when adding a negative number of items" in withTestDriver { driver => 76 | val outcome = driver.run(UpdateItem("123", -1)) 77 | outcome.replies should have size 1 78 | outcome.replies.head shouldBe a[ShoppingCartException] 79 | outcome.events should have size 0 80 | } 81 | 82 | "fail when adding an item to a checked out cart" in withTestDriver { driver => 83 | driver.run(UpdateItem("123", 2), Checkout) 84 | val outcome = driver.run(UpdateItem("456", 3)) 85 | outcome.replies should have size 1 86 | outcome.replies.head shouldBe a[ShoppingCartException] 87 | outcome.events should have size 0 88 | } 89 | 90 | "fail when checking out twice" in withTestDriver { driver => 91 | driver.run(UpdateItem("123", 2), Checkout) 92 | val outcome = driver.run(Checkout) 93 | outcome.replies should have size 1 94 | outcome.replies.head shouldBe a[ShoppingCartException] 95 | outcome.events should have size 0 96 | } 97 | 98 | "fail when checking out an empty cart" in withTestDriver { driver => 99 | val outcome = driver.run(Checkout) 100 | outcome.replies should have size 1 101 | outcome.replies.head shouldBe a[ShoppingCartException] 102 | outcome.events should have size 0 103 | } 104 | 105 | } 106 | } 107 | --------------------------------------------------------------------------------