├── .env ├── .gitignore ├── LICENSE ├── README.md ├── bin ├── commandhandler.sh ├── config.sh ├── projector.sh ├── setup.sh └── web.sh ├── build.sbt ├── client ├── package.json ├── public │ └── index.html └── src │ ├── actions.js │ ├── api.js │ ├── components │ ├── App.js │ ├── InvoiceForm.js │ ├── InvoicesList.js │ ├── InvoicesTable.js │ ├── Message.css │ ├── Message.js │ └── NewInvoice.js │ ├── config.js │ ├── index.js │ ├── model.js │ ├── origin.js │ └── websocket.js ├── commandhandler ├── Dockerfile └── src │ └── main │ └── scala │ └── org │ └── amitayh │ └── invoices │ └── commandhandler │ ├── CommandHandler.scala │ └── CommandToResultTransformer.scala ├── common └── src │ └── main │ ├── resources │ └── logback.xml │ └── scala │ └── org │ └── amitayh │ └── invoices │ └── common │ ├── Config.scala │ ├── CreateTopics.scala │ ├── domain │ ├── Command.scala │ ├── CommandResult.scala │ ├── Event.scala │ ├── Invoice.scala │ ├── InvoiceError.scala │ ├── InvoiceSnapshot.scala │ └── Reducer.scala │ └── serde │ ├── AvroSerde.scala │ ├── CommandSerializer.scala │ ├── UuidConverters.scala │ └── UuidSerde.scala ├── config └── local.properties ├── docker-compose.yml ├── listdao └── src │ └── main │ ├── resources │ └── schema.sql │ └── scala │ └── org │ └── amitayh │ └── invoices │ └── dao │ ├── InvoiceList.scala │ ├── InvoiceRecord.scala │ └── MySqlInvoiceList.scala ├── listprojector ├── Dockerfile └── src │ └── main │ └── scala │ └── org │ └── amitayh │ └── invoices │ └── projector │ └── ListProjector.scala ├── project ├── build.properties └── plugins.sbt ├── streamprocessor └── src │ └── main │ └── scala │ └── org │ └── amitayh │ └── invoices │ └── streamprocessor │ └── StreamProcessorApp.scala └── web ├── Dockerfile └── src └── main ├── resources └── statics │ ├── asset-manifest.json │ ├── index.html │ └── static │ ├── css │ ├── main.98142e8e.css │ └── main.98142e8e.css.map │ └── js │ ├── main.66c286e3.js │ └── main.66c286e3.js.map └── scala └── org └── amitayh └── invoices └── web ├── CommandDto.scala ├── InvoicesApi.scala ├── InvoicesServer.scala ├── Kafka.scala ├── PushEvents.scala ├── Statics.scala └── UuidVar.scala /.env: -------------------------------------------------------------------------------- 1 | KAFKA_HOST_NAME= 2 | DB_DRIVER=com.mysql.cj.jdbc.Driver 3 | DB_URL=jdbc:mysql://mysql:3306/invoices?useSSL=false 4 | DB_USER=root 5 | DB_PASS=root 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### SBT template 3 | # Simple Build Tool 4 | # http://www.scala-sbt.org/release/docs/Getting-Started/Directories.html#configuring-version-control 5 | 6 | dist/* 7 | target/ 8 | lib_managed/ 9 | src_managed/ 10 | project/boot/ 11 | project/plugins/project/ 12 | .history 13 | .cache 14 | .lib/ 15 | 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Amitay Horwitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Event Sourcing with Kafka Streams 2 | 3 | This is a POC for using [Kafka Streams](https://kafka.apache.org/documentation/streams/) 4 | as a backbone for an event sourced system. 5 | 6 | ## Running the project locally 7 | 8 | You can either run the project with [Docker Compose](https://docs.docker.com/compose/), 9 | or run everything on your host. 10 | 11 | ### Prerequisites 12 | 13 | 1. Install sbt ([help](https://www.scala-sbt.org/)) 14 | 15 | 2. Build the project: 16 | 17 | ``` 18 | $ sbt assembly 19 | ``` 20 | 21 | ### Run with Docker Compose 22 | 23 | 1. Update your host IP address in [.env](.env) 24 | 25 | 2. Build images 26 | 27 | ``` 28 | $ docker-compose build 29 | ``` 30 | 31 | 3. Start the containers 32 | 33 | ``` 34 | $ docker-compose up 35 | ``` 36 | 37 | ### Run on host 38 | 39 | #### Setup 40 | 41 | 1. Run Zookeeper / Kafka ([help](https://kafka.apache.org/quickstart)) 42 | 43 | 2. Run MySQL ([help](https://dev.mysql.com/doc/mysql-getting-started/en/)) 44 | 45 | 3. Install the [schema](listdao/src/main/resources/schema.sql) 46 | 47 | 4. Create the topics (edit `config/local.properties` as needed): 48 | 49 | ``` 50 | $ bin/setup.sh config/local.properties 51 | ``` 52 | 53 | #### Running 54 | 55 | 1. Run the [command handler](commandhandler/src/main/scala/org/amitayh/invoices/commandhandler/CommandHandler.scala): 56 | 57 | ``` 58 | $ bin/commandhandler.sh config/local.properties 59 | ``` 60 | 61 | 2. Run the [invoices list projector](listprojector/src/main/scala/org/amitayh/invoices/projector/ListProjector.scala): 62 | 63 | ``` 64 | $ bin/projector.sh config/local.properties 65 | ``` 66 | 67 | 3. Run the [web server](web/src/main/scala/org/amitayh/invoices/web/InvoicesServer.scala): 68 | 69 | ``` 70 | $ bin/web.sh config/local.properties 71 | ``` 72 | 73 | If everything worked, you should be able to see the app running at `http://localhost:8080/index.html` 74 | 75 | ## License 76 | 77 | Copyright © 2018 Amitay Horwitz 78 | 79 | Distributed under the MIT License 80 | -------------------------------------------------------------------------------- /bin/commandhandler.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source "$(dirname "$0")/config.sh" 4 | 5 | BOOTSTRAP_SERVERS="$(prop 'bootstrap.servers')" \ 6 | java -jar commandhandler/target/scala-2.12/commandhandler.jar 7 | -------------------------------------------------------------------------------- /bin/config.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | config_file=${1} 4 | 5 | function prop { 6 | grep "${1}" ${config_file} | cut -d '=' -f2 7 | } 8 | -------------------------------------------------------------------------------- /bin/projector.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source "$(dirname "$0")/config.sh" 4 | 5 | BOOTSTRAP_SERVERS="$(prop 'bootstrap.servers')" \ 6 | DB_DRIVER="$(prop 'db.driver')" \ 7 | DB_URL="$(prop 'db.url')" \ 8 | DB_USER="$(prop 'db.user')" \ 9 | DB_PASS="$(prop 'db.pass')" \ 10 | java -jar listprojector/target/scala-2.12/listprojector.jar 11 | -------------------------------------------------------------------------------- /bin/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source "$(dirname "$0")/config.sh" 4 | 5 | BOOTSTRAP_SERVERS="$(prop 'bootstrap.servers')" \ 6 | java -jar common/target/scala-2.12/common.jar 7 | -------------------------------------------------------------------------------- /bin/web.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source "$(dirname "$0")/config.sh" 4 | 5 | BOOTSTRAP_SERVERS="$(prop 'bootstrap.servers')" \ 6 | DB_DRIVER="$(prop 'db.driver')" \ 7 | DB_URL="$(prop 'db.url')" \ 8 | DB_USER="$(prop 'db.user')" \ 9 | DB_PASS="$(prop 'db.pass')" \ 10 | java -jar web/target/scala-2.12/web.jar 11 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "invoices-kafka-streams" 2 | organization in ThisBuild := "org.amitayh" 3 | scalaVersion in ThisBuild := "2.12.3" 4 | 5 | // PROJECTS 6 | 7 | lazy val global = project 8 | .in(file(".")) 9 | .settings(commonSettings) 10 | .aggregate( 11 | common, 12 | streamprocessor, 13 | commandhandler, 14 | listprojector, 15 | web 16 | ) 17 | 18 | lazy val common = project 19 | .settings( 20 | name := "common", 21 | commonSettings, 22 | assemblySettings, 23 | libraryDependencies ++= commonDependencies ++ Seq( 24 | dependencies.log4s, 25 | dependencies.kafkaClients, 26 | dependencies.avro4s 27 | ) 28 | ) 29 | 30 | lazy val listdao = project 31 | .settings( 32 | name := "listdao", 33 | commonSettings, 34 | libraryDependencies ++= commonDependencies ++ Seq( 35 | dependencies.doobie, 36 | dependencies.doobieHikari 37 | ) 38 | ) 39 | .dependsOn(common, streamprocessor) 40 | 41 | lazy val streamprocessor = project 42 | .settings( 43 | name := "streamprocessor", 44 | commonSettings, 45 | libraryDependencies ++= commonDependencies ++ Seq( 46 | dependencies.kafkaStreams 47 | ) 48 | ) 49 | .dependsOn(common) 50 | 51 | lazy val commandhandler = project 52 | .settings( 53 | name := "commandhandler", 54 | commonSettings, 55 | assemblySettings, 56 | libraryDependencies ++= commonDependencies 57 | ) 58 | .dependsOn(common, streamprocessor) 59 | 60 | lazy val listprojector = project 61 | .settings( 62 | name := "listprojector", 63 | commonSettings, 64 | assemblySettings, 65 | libraryDependencies ++= commonDependencies ++ Seq( 66 | dependencies.mysqlConnector % Runtime 67 | ) 68 | ) 69 | .dependsOn(common, streamprocessor, listdao) 70 | 71 | lazy val web = project 72 | .settings( 73 | name := "web", 74 | commonSettings, 75 | assemblySettings, 76 | libraryDependencies ++= commonDependencies ++ Seq( 77 | dependencies.http4sBlaze, 78 | dependencies.http4sCirce, 79 | dependencies.http4sDsl, 80 | dependencies.circeCore, 81 | dependencies.circeGeneric, 82 | dependencies.circeParser, 83 | dependencies.kafkaClients, 84 | dependencies.catsRetry, 85 | dependencies.mysqlConnector % Runtime 86 | ) 87 | ) 88 | .dependsOn(common, listdao) 89 | 90 | // DEPENDENCIES 91 | 92 | lazy val dependencies = 93 | new { 94 | val logbackVersion = "1.2.3" 95 | val slf4jVersion = "1.7.25" 96 | val log4sVersion = "1.6.1" 97 | val http4sVersion = "0.20.0-M7" 98 | val circeVersion = "0.10.0" 99 | val doobieVersion = "0.6.0" 100 | val kafkaVersion = "2.0.0" 101 | val mysqlConnectorVersion = "8.0.12" 102 | val avro4sVersion = "1.9.0" 103 | val catsRetryVersion = "0.2.0" 104 | val origamiVersion = "5.0.1" 105 | val producerVersion = "5.0.0" 106 | 107 | val logback = "ch.qos.logback" % "logback-classic" % logbackVersion 108 | val slf4j = "org.slf4j" % "jcl-over-slf4j" % slf4jVersion 109 | val kafkaClients = "org.apache.kafka" % "kafka-clients" % kafkaVersion 110 | val kafkaStreams = "org.apache.kafka" % "kafka-streams" % kafkaVersion 111 | val mysqlConnector = "mysql" % "mysql-connector-java" % mysqlConnectorVersion 112 | val log4s = "org.log4s" %% "log4s" % log4sVersion 113 | val http4sBlaze = "org.http4s" %% "http4s-blaze-server" % http4sVersion 114 | val http4sCirce = "org.http4s" %% "http4s-circe" % http4sVersion 115 | val http4sDsl = "org.http4s" %% "http4s-dsl" % http4sVersion 116 | val circeCore = "io.circe" %% "circe-core" % circeVersion 117 | val circeGeneric = "io.circe" %% "circe-generic" % circeVersion 118 | val circeParser = "io.circe" %% "circe-parser" % circeVersion 119 | val doobie = "org.tpolecat" %% "doobie-core" % doobieVersion 120 | val doobieHikari = "org.tpolecat" %% "doobie-hikari" % doobieVersion 121 | val avro4s = "com.sksamuel.avro4s" %% "avro4s-core" % avro4sVersion 122 | val catsRetry = "com.github.cb372" %% "cats-retry-core" % catsRetryVersion 123 | } 124 | 125 | lazy val commonDependencies = Seq( 126 | dependencies.logback, 127 | dependencies.slf4j 128 | ) 129 | 130 | // SETTINGS 131 | 132 | lazy val compilerOptions = Seq( 133 | "-unchecked", 134 | "-feature", 135 | "-language:existentials", 136 | "-language:higherKinds", 137 | "-language:implicitConversions", 138 | "-language:postfixOps", 139 | "-Ypartial-unification", 140 | "-deprecation", 141 | "-encoding", 142 | "utf8" 143 | ) 144 | 145 | lazy val commonSettings = Seq( 146 | scalacOptions ++= compilerOptions, 147 | resolvers ++= Seq( 148 | "Local Maven Repository" at "file://" + Path.userHome.absolutePath + "/.m2/repository", 149 | Resolver.sonatypeRepo("releases"), 150 | Resolver.sonatypeRepo("snapshots") 151 | ) 152 | ) 153 | 154 | lazy val assemblySettings = Seq( 155 | assemblyJarName in assembly := name.value + ".jar", 156 | assemblyMergeStrategy in assembly := { 157 | case PathList("META-INF", xs @ _*) => MergeStrategy.discard 158 | case _ => MergeStrategy.first 159 | } 160 | ) 161 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "react-scripts": "1.1.1" 7 | }, 8 | "dependencies": { 9 | "chance": "^1.0.13", 10 | "debounce": "^1.1.0", 11 | "npm": "^6.4.1", 12 | "react": "^16.2.0", 13 | "react-dom": "^16.2.0", 14 | "uuid": "^3.2.1" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "postbuild": "cp -Rf build/* ../web/src/main/resources/statics", 20 | "test": "react-scripts test --env=jsdom", 21 | "eject": "react-scripts eject" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | Kafka Streams Demo 19 | 20 | 21 | 24 |
25 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /client/src/actions.js: -------------------------------------------------------------------------------- 1 | import debounce from 'debounce'; 2 | import uuidv4 from 'uuid/v4'; 3 | import * as api from './api'; 4 | import {emptyLineItem, pendingInvoice, randomDraft, tempId} from './model'; 5 | 6 | export const newInvoicePage = { 7 | type: 'NEW_INVOICE_PAGE', 8 | nextState: state => ({...state, page: 'new', draft: randomDraft()}) 9 | }; 10 | 11 | export const listPage = { 12 | type: 'LIST_PAGE', 13 | nextState: state => ({...state, page: 'list', draft: null}) 14 | }; 15 | 16 | export const clearMessage = { 17 | type: 'CLEAR_MESSAGE', 18 | nextState: state => ({...state, message: null}) 19 | }; 20 | 21 | const clearMessageDelayed = 22 | debounce(dispatch => dispatch(clearMessage), 3000); 23 | 24 | export const showMessage = message => ({ 25 | type: 'SHOW_MESSAGE', 26 | nextState: state => ({...state, message}), 27 | runEffect: dispatch => clearMessageDelayed(dispatch) 28 | }); 29 | 30 | export const fetchInvoicesSuccess = invoices => ({ 31 | type: 'FETCH_INVOICES_SUCCESS', 32 | nextState: state => ({...state, invoices}) 33 | }); 34 | 35 | export const fetchInvoices = { 36 | type: 'FETCH_INVOICES', 37 | nextState: state => ({...state, invoices: []}), 38 | runEffect: dispatch => { 39 | api.fetchInvoices() 40 | .then(invoices => dispatch(fetchInvoicesSuccess(invoices))) 41 | } 42 | }; 43 | 44 | const invoiceInList = (invoices, needle) => { 45 | const result = invoices.find(invoice => invoice.id === needle.id); 46 | return (result !== undefined); 47 | }; 48 | 49 | const updateList = (invoices, updated) => { 50 | return invoices.map(invoice => { 51 | return (invoice.id === updated.id) ? updated : invoice; 52 | }); 53 | }; 54 | 55 | const addToList = (invoices, invoice) => [invoice, ...invoices]; 56 | 57 | export const invoiceUpdated = invoice => ({ 58 | type: 'INVOICE_UPDATED', 59 | nextState: state => { 60 | const invoices = state.invoices; 61 | const updated = invoiceInList(invoices, invoice) ? 62 | updateList(invoices, invoice) : 63 | addToList(invoices, invoice); 64 | return {...state, invoices: updated}; 65 | } 66 | }); 67 | 68 | export const draftEdited = draft => ({ 69 | type: 'DRAFT_EDITED', 70 | nextState: state => ({...state, draft}) 71 | }); 72 | 73 | export const lineItemEdited = (index, lineItem) => ({ 74 | type: 'LINE_ITEM_EDITED', 75 | nextState: state => { 76 | const draft = state.draft; 77 | const lineItems = draft.lineItems.map((current, currentIndex) => { 78 | return (currentIndex === index) ? lineItem : current; 79 | }); 80 | return {...state, draft: {...draft, lineItems}}; 81 | } 82 | }); 83 | 84 | export const lineItemRemoved = index => ({ 85 | type: 'LINE_ITEM_REMOVED', 86 | nextState: state => { 87 | const draft = state.draft; 88 | const lineItems = draft.lineItems.filter((_, currentIndex) => { 89 | return (currentIndex !== index); 90 | }); 91 | return {...state, draft: {...draft, lineItems}}; 92 | } 93 | }); 94 | 95 | export const lineItemAdded = { 96 | type: 'LINE_ITEM_ADDED', 97 | nextState: state => { 98 | const draft = state.draft; 99 | const lineItems = [...draft.lineItems, emptyLineItem]; 100 | return {...state, draft: {...draft, lineItems}}; 101 | } 102 | }; 103 | 104 | export const emptyInvoiceAdded = invoiceId => ({ 105 | type: 'EMPTY_INVOICE_ADDED', 106 | nextState: state => { 107 | const invoices = state.invoices.map(invoice => { 108 | return (invoice.id === tempId) ? 109 | {...invoice, id: invoiceId} : 110 | invoice 111 | }); 112 | return {...state, invoices} 113 | } 114 | }); 115 | 116 | export const commandExecutionStarted = 117 | showMessage('Loading...'); 118 | 119 | export const commandExecutionSucceeded = commandId => 120 | showMessage(`Command ${commandId} succeeded!`); 121 | 122 | export const commandExecutionFailed = (commandId, cause) => 123 | showMessage(`Command ${commandId} failed!\n${cause}`); 124 | 125 | export const createInvoice = draft => ({ 126 | type: 'CREATE_INVOICE', 127 | nextState: state => { 128 | const invoices = [pendingInvoice, ...state.invoices]; 129 | return {...state, page: 'list', draft: null, invoices}; 130 | }, 131 | runEffect: dispatch => { 132 | const invoiceId = uuidv4(); 133 | dispatch(commandExecutionStarted); 134 | dispatch(emptyInvoiceAdded(invoiceId)); 135 | api.createInvoice(invoiceId, draft); 136 | } 137 | }); 138 | 139 | export const payInvoice = id => ({ 140 | type: 'PAY_INVOICE', 141 | nextState: state => state, 142 | runEffect: dispatch => { 143 | dispatch(commandExecutionStarted); 144 | api.payInvoice(id); 145 | } 146 | }); 147 | 148 | export const deleteInvoice = id => ({ 149 | type: 'DELETE_INVOICE', 150 | nextState: state => { 151 | const invoices = state.invoices.map(invoice => { 152 | return (invoice.id === id) ? 153 | {...invoice, deleting: true} : 154 | invoice 155 | }); 156 | return {...state, invoices} 157 | }, 158 | runEffect: dispatch => { 159 | dispatch(commandExecutionStarted); 160 | api.deleteInvoice(id); 161 | } 162 | }); 163 | 164 | export const removeLineItem = (invoiceId, index) => ({ 165 | type: 'REMOVE_LINE_ITEM', 166 | nextState: state => state, 167 | runEffect: dispatch => { 168 | dispatch(commandExecutionStarted); 169 | api.removeLineItem(invoiceId, index); 170 | } 171 | }); 172 | -------------------------------------------------------------------------------- /client/src/api.js: -------------------------------------------------------------------------------- 1 | import {originId} from './origin'; 2 | import {baseUrl} from './config'; 3 | import uuidv4 from 'uuid/v4'; 4 | 5 | const apiBaseUrl = `${baseUrl}/api`; 6 | const toJson = res => res.json(); 7 | 8 | const execute = (invoiceId, payload) => { 9 | const options = { 10 | method: 'POST', 11 | headers: {'content-type': 'application/json'}, 12 | body: JSON.stringify({ 13 | originId, 14 | commandId: uuidv4(), 15 | expectedVersion: null, 16 | payload 17 | }) 18 | }; 19 | return fetch(`${apiBaseUrl}/execute/${invoiceId}`, options).then(toJson); 20 | }; 21 | 22 | export const fetchInvoices = () => { 23 | return fetch(`${apiBaseUrl}/invoices`).then(toJson); 24 | }; 25 | 26 | export const createInvoice = (invoiceId, draft) => { 27 | return execute(invoiceId, { 28 | CreateInvoice: { 29 | customerName: draft.customer.name, 30 | customerEmail: draft.customer.email, 31 | issueDate: draft.issueDate, 32 | dueDate: draft.dueDate, 33 | lineItems: draft.lineItems 34 | } 35 | }); 36 | }; 37 | 38 | export const payInvoice = invoiceId => { 39 | return execute(invoiceId, {PayInvoice: {}}); 40 | }; 41 | 42 | export const deleteInvoice = invoiceId => { 43 | return execute(invoiceId, {DeleteInvoice: {}}); 44 | }; 45 | 46 | export const removeLineItem = (invoiceId, index) => { 47 | return execute(invoiceId, {RemoveLineItem: {index}}); 48 | }; 49 | -------------------------------------------------------------------------------- /client/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | import {initialState} from '../model'; 3 | import InvoicesList from './InvoicesList'; 4 | import NewInvoice from './NewInvoice'; 5 | import './Message.css'; 6 | 7 | class App extends PureComponent { 8 | render() { 9 | const state = this.props.state || initialState; 10 | const dispatch = this.props.dispatch; 11 | return ( 12 |
13 | 14 |

My Invoices

15 | {this.renderPage(state, dispatch)} 16 |
17 | ); 18 | } 19 | 20 | renderPage(state, dispatch) { 21 | switch (state.page) { 22 | case 'list': return ; 23 | case 'new': return ; 24 | default: return null; 25 | } 26 | } 27 | } 28 | 29 | class Message extends PureComponent { 30 | render() { 31 | const {message} = this.props; 32 | return (message !== null) ? 33 |
{message}
: 34 | null; 35 | } 36 | } 37 | 38 | export default App; 39 | -------------------------------------------------------------------------------- /client/src/components/InvoiceForm.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | 3 | export default class InvoiceForm extends PureComponent { 4 | render() { 5 | const {invoice, onChange, onAddLineItem, onChangeLineItem, onRemoveLineItem} = this.props; 6 | const {customer, lineItems} = invoice; 7 | return ( 8 |
9 |
10 |
11 | 12 | { 18 | const updatedCustomer = {name: e.target.value, email: customer.email}; 19 | onChange({...invoice, customer: updatedCustomer}) 20 | }} 21 | required 22 | /> 23 |
24 |
25 | 26 | { 32 | const updatedCustomer = {name: customer.name, email: e.target.value}; 33 | onChange({...invoice, customer: updatedCustomer}) 34 | }} 35 | required 36 | /> 37 |
38 |
39 | 40 | onChange({...invoice, issueDate: e.target.value})} 46 | required 47 | /> 48 |
49 |
50 | 51 | onChange({...invoice, dueDate: e.target.value})} 57 | required 58 | /> 59 |
60 |
61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | {lineItems.map((lineItem, index) => { 74 | return onChangeLineItem(index, updated)} 79 | onRemove={() => onRemoveLineItem(index)} 80 | /> 81 | })} 82 | 83 | 90 | 91 | 92 |
Line items
#DescriptionPriceQty.Actions
84 | 85 | 89 |
93 |
94 | ); 95 | } 96 | } 97 | 98 | class LineItem extends PureComponent { 99 | render() { 100 | const {number, lineItem, onChange, onRemove} = this.props; 101 | return ( 102 | 103 | {number} 104 | 105 | onChange({...lineItem, description: e.target.value})} 109 | /> 110 | 111 | 112 | onChange({...lineItem, price: Number(e.target.value)})} 117 | /> 118 | 119 | 120 | onChange({...lineItem, quantity: Number(e.target.value)})} 126 | /> 127 | 128 | 129 | 132 | 133 | 134 | ); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /client/src/components/InvoicesList.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | import InvoicesTable from './InvoicesTable'; 3 | import {newInvoicePage, payInvoice, deleteInvoice, removeLineItem} from '../actions'; 4 | 5 | const id1 = '4d6b05ad-98ec-46e4-b3c7-d12ac0dddc77'; 6 | 7 | class InvoicesList extends PureComponent { 8 | render() { 9 | const {invoices, dispatch} = this.props; 10 | return ( 11 |
12 |

List

13 | dispatch(payInvoice(id))} 16 | onDelete={id => dispatch(deleteInvoice(id))}/> 17 |

18 | 21 | {' '} 22 | 25 |

26 |
27 | ); 28 | } 29 | } 30 | 31 | export default InvoicesList; 32 | -------------------------------------------------------------------------------- /client/src/components/InvoicesTable.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | 3 | const formatter = new Intl.NumberFormat(['en-US'], {style: 'currency', currency: 'USD'}); 4 | 5 | const zeroPad = number => number.toString().padStart(6, '0'); 6 | 7 | export default class InvoicesTable extends PureComponent { 8 | render() { 9 | const {invoices, onPay, onDelete} = this.props; 10 | const total = invoices.length; 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {invoices.map((invoice, index) => { 25 | return onPay(invoice.id)} 30 | onDelete={() => onDelete(invoice.id)}/>; 31 | })} 32 | 33 |
#CustomerDue dateTotalStatusActions
34 | ); 35 | } 36 | } 37 | 38 | class InvoicesRow extends PureComponent { 39 | render() { 40 | const {invoice, number, onPay, onDelete} = this.props; 41 | if (invoice.status === 'Deleted') { 42 | return null; 43 | } else if (invoice.pending) { 44 | return this.renderPending(invoice, number); 45 | } else { 46 | return this.renderInvoice(invoice, number, onPay, onDelete); 47 | } 48 | } 49 | 50 | renderPending(invoice, number) { 51 | return ( 52 | 53 | {zeroPad(number)} 54 | 55 | 56 | 57 | 58 | 59 | 60 | {' '} 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | renderInvoice(invoice, number, onPay, onDelete) { 68 | const status = invoice.status; 69 | const isPaid = (status === 'Paid'); 70 | const isDeleting = !!invoice.deleting; 71 | const statusStyle = isPaid ? 'success' : ''; 72 | const rowStyle = isDeleting ? 'danger' : ''; 73 | return ( 74 | 75 | {zeroPad(number)} 76 | 77 | {invoice.customerName} 78 | {' '} 79 | <{invoice.customerEmail}> 80 | 81 | {invoice.dueDate} 82 | {formatter.format(invoice.total)} 83 | {status} 84 | 85 | 89 | {' '} 90 | 95 | 96 | 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /client/src/components/Message.css: -------------------------------------------------------------------------------- 1 | .message { 2 | position: absolute; 3 | white-space: pre; 4 | top: 20px; 5 | left: 0; 6 | right: 0; 7 | margin: auto; 8 | width: 520px; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/components/Message.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | 3 | export default class Message extends PureComponent { 4 | render() { 5 | const {message} = this.props; 6 | return (message !== null) ? 7 |
{message}
: 8 | null; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/src/components/NewInvoice.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | import InvoiceForm from './InvoiceForm'; 3 | import { 4 | createInvoice, 5 | draftEdited, 6 | lineItemAdded, 7 | lineItemEdited, 8 | lineItemRemoved, listPage 9 | } from '../actions'; 10 | 11 | class NewInvoices extends PureComponent { 12 | render() { 13 | const {draft, dispatch} = this.props; 14 | return ( 15 |
16 |

New

17 | dispatch(draftEdited(updated))} 20 | onChangeLineItem={(index, lineItem) => dispatch(lineItemEdited(index, lineItem))} 21 | onRemoveLineItem={index => dispatch(lineItemRemoved(index))} 22 | onAddLineItem={() => dispatch(lineItemAdded)} 23 | /> 24 |

25 | 29 | {' '} 30 | 34 |

35 |
36 | ); 37 | } 38 | } 39 | 40 | export default NewInvoices; 41 | -------------------------------------------------------------------------------- /client/src/config.js: -------------------------------------------------------------------------------- 1 | export const baseUrl = 'http://localhost:8080'; 2 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {initialState} from './model'; 4 | import {subscribe} from './websocket'; 5 | import {originId} from './origin'; 6 | import {fetchInvoices} from './actions'; 7 | import App from './components/App'; 8 | 9 | ReactDOM.render(, document.getElementById('root')); 10 | 11 | let state = initialState; 12 | 13 | // const debug = (action, state) => { 14 | // console.groupCollapsed(action.type); 15 | // console.info('Old state', state); 16 | // console.info('New state', action.nextState(state)); 17 | // console.groupEnd(); 18 | // }; 19 | 20 | const render = () => ReactDOM.render( 21 | , 22 | document.getElementById('root') 23 | ); 24 | 25 | function dispatch(action) { 26 | // debug(action, state); 27 | state = action.nextState(state); 28 | if (typeof action.runEffect === 'function') { 29 | action.runEffect(dispatch); 30 | } 31 | render(); 32 | } 33 | 34 | render(); // Initial render 35 | subscribe(dispatch, originId); 36 | dispatch(fetchInvoices); 37 | -------------------------------------------------------------------------------- /client/src/model.js: -------------------------------------------------------------------------------- 1 | import Chance from 'chance'; 2 | 3 | const chance = new Chance(); 4 | 5 | const randomLineItem = () => ({ 6 | description: chance.sentence({words: 4}), 7 | quantity: chance.integer({min: 1, max: 5}), 8 | price: chance.integer({min: 1, max: 100}) 9 | }); 10 | 11 | const plusMonths = (date, monthsToAdd) => new Date( 12 | date.getFullYear(), 13 | date.getMonth() + monthsToAdd, 14 | date.getDate() 15 | ); 16 | 17 | const formatDate = date => date.toISOString().substr(0, 10); 18 | 19 | export const randomDraft = () => ({ 20 | customer: { 21 | name: chance.name(), 22 | email: chance.email() 23 | }, 24 | issueDate: formatDate(new Date()), 25 | dueDate: formatDate(plusMonths(new Date(), 1)), 26 | lineItems: chance.n(randomLineItem, chance.integer({min: 2, max: 5})) 27 | }); 28 | 29 | export const emptyLineItem = { 30 | description: '', 31 | quantity: 0, 32 | price: 0 33 | }; 34 | 35 | export const emptyDraft = { 36 | customer: { 37 | name: '', 38 | email: '' 39 | }, 40 | issueDate: '2018-04-01', 41 | dueDate: '2018-05-01', 42 | lineItems: [] 43 | }; 44 | 45 | export const tempId = '__TEMP__'; 46 | 47 | export const pendingInvoice = {id: tempId, pending: true}; 48 | 49 | export const initialState = { 50 | page: 'list', 51 | invoices: [], 52 | draft: null, 53 | message: null, 54 | }; 55 | -------------------------------------------------------------------------------- /client/src/origin.js: -------------------------------------------------------------------------------- 1 | import uuidv4 from 'uuid/v4'; 2 | 3 | export const originId = uuidv4(); 4 | -------------------------------------------------------------------------------- /client/src/websocket.js: -------------------------------------------------------------------------------- 1 | import {commandExecutionFailed, commandExecutionSucceeded, invoiceUpdated} from './actions'; 2 | import {baseUrl} from './config'; 3 | 4 | const handlers = { 5 | CommandSucceeded: (dispatch, message) => { 6 | dispatch(commandExecutionSucceeded(message.commandId)); 7 | }, 8 | CommandFailed: (dispatch, message) => { 9 | dispatch(commandExecutionFailed(message.commandId, message.cause)); 10 | }, 11 | InvoiceUpdated: (dispatch, message) => { 12 | dispatch(invoiceUpdated(message.record)); 13 | } 14 | }; 15 | 16 | export const subscribe = (dispatch, originId) => { 17 | const events = new EventSource(`${baseUrl}/events/${originId}`); 18 | events.addEventListener('message', event => { 19 | const message = JSON.parse(event.data); 20 | Object.keys(message).forEach(key => { 21 | const handler = handlers[key]; 22 | if (handler) { 23 | handler(dispatch, message[key]); 24 | } 25 | }); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /commandhandler/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM java:8 2 | ADD target/scala-2.12/commandhandler.jar commandhandler.jar 3 | CMD java -jar commandhandler.jar 4 | -------------------------------------------------------------------------------- /commandhandler/src/main/scala/org/amitayh/invoices/commandhandler/CommandHandler.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.commandhandler 2 | 3 | import java.lang.{Iterable => JIterable} 4 | import java.util.Collections.{emptyList, singletonList} 5 | 6 | import org.amitayh.invoices.common.Config 7 | import org.amitayh.invoices.common.domain.{CommandResult, Event, InvoiceSnapshot} 8 | import org.amitayh.invoices.common.serde.AvroSerde.{CommandResultSerde, CommandSerde, EventSerde, SnapshotSerde} 9 | import org.amitayh.invoices.common.serde.UuidSerde 10 | import org.amitayh.invoices.streamprocessor.StreamProcessorApp 11 | import org.apache.kafka.streams.kstream.{Consumed, Produced, ValueMapper} 12 | import org.apache.kafka.streams.state.Stores 13 | import org.apache.kafka.streams.{StreamsBuilder, Topology} 14 | 15 | import scala.collection.JavaConverters._ 16 | 17 | object CommandHandler extends StreamProcessorApp { 18 | 19 | override def appId: String = "invoices.processor.command-handler" 20 | 21 | override def topology: Topology = { 22 | val builder = new StreamsBuilder 23 | 24 | builder.addStateStore( 25 | Stores.keyValueStoreBuilder( 26 | Stores.persistentKeyValueStore(Config.Stores.Snapshots), 27 | UuidSerde, 28 | SnapshotSerde)) 29 | 30 | val commands = builder.stream( 31 | Config.Topics.Commands.name, 32 | Consumed.`with`(UuidSerde, CommandSerde)) 33 | 34 | val results = commands.transform( 35 | CommandToResultTransformer.Supplier, 36 | Config.Stores.Snapshots) 37 | 38 | val successfulResults = results.flatMapValues[CommandResult.Success](ToSuccessful) 39 | val snapshots = successfulResults.mapValues[InvoiceSnapshot](ToSnapshots) 40 | val events = successfulResults.flatMapValues[Event](ToEvents) 41 | 42 | results.to( 43 | Config.Topics.CommandResults.name, 44 | Produced.`with`(UuidSerde, CommandResultSerde)) 45 | 46 | snapshots.to( 47 | Config.Topics.Snapshots.name, 48 | Produced.`with`(UuidSerde, SnapshotSerde)) 49 | 50 | events.to( 51 | Config.Topics.Events.name, 52 | Produced.`with`(UuidSerde, EventSerde)) 53 | 54 | builder.build() 55 | } 56 | 57 | } 58 | 59 | object ToSuccessful extends ValueMapper[CommandResult, JIterable[CommandResult.Success]] { 60 | override def apply(result: CommandResult): JIterable[CommandResult.Success] = result match { 61 | case CommandResult(_, _, success: CommandResult.Success) => singletonList(success) 62 | case _ => emptyList[CommandResult.Success] 63 | } 64 | } 65 | 66 | object ToSnapshots extends ValueMapper[CommandResult.Success, InvoiceSnapshot] { 67 | override def apply(result: CommandResult.Success): InvoiceSnapshot = result.newSnapshot 68 | } 69 | 70 | object ToEvents extends ValueMapper[CommandResult.Success, JIterable[Event]] { 71 | override def apply(result: CommandResult.Success): JIterable[Event] = result.events.asJava 72 | } 73 | -------------------------------------------------------------------------------- /commandhandler/src/main/scala/org/amitayh/invoices/commandhandler/CommandToResultTransformer.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.commandhandler 2 | 3 | import java.time.Instant 4 | import java.util.UUID 5 | 6 | import org.amitayh.invoices.common.Config 7 | import org.amitayh.invoices.common.domain.{Command, CommandResult, InvoiceSnapshot, SnapshotReducer} 8 | import org.apache.kafka.streams.KeyValue 9 | import org.apache.kafka.streams.kstream.{Transformer, TransformerSupplier} 10 | import org.apache.kafka.streams.processor.ProcessorContext 11 | import org.apache.kafka.streams.state.KeyValueStore 12 | 13 | class CommandToResultTransformer 14 | extends Transformer[UUID, Command, KeyValue[UUID, CommandResult]] { 15 | 16 | private var context: ProcessorContext = _ 17 | 18 | private var store: KeyValueStore[UUID, InvoiceSnapshot] = _ 19 | 20 | override def init(context: ProcessorContext): Unit = { 21 | this.context = context 22 | store = context 23 | .getStateStore(Config.Stores.Snapshots) 24 | .asInstanceOf[KeyValueStore[UUID, InvoiceSnapshot]] 25 | } 26 | 27 | /** 28 | * By using the invoice ID as the partition key for the commands topic, 29 | * we get serializability over command handling. This means that no 30 | * concurrent commands will run for the same invoice, so we can safely 31 | * handle commands as read-process-write without a race condition. We 32 | * are still able to scale out by adding more partitions. 33 | */ 34 | override def transform(id: UUID, command: Command): KeyValue[UUID, CommandResult] = { 35 | val snapshot = loadSnapshot(id) 36 | val result = command(timestamp(), snapshot) 37 | updateSnapshot(id, result.outcome) 38 | KeyValue.pair(id, result) 39 | } 40 | 41 | override def close(): Unit = () 42 | 43 | private def loadSnapshot(id: UUID): InvoiceSnapshot = 44 | Option(store.get(id)).getOrElse(SnapshotReducer.empty) 45 | 46 | private def timestamp(): Instant = 47 | Instant.ofEpochMilli(context.timestamp()) 48 | 49 | private def updateSnapshot(id: UUID, outcome: CommandResult.Outcome): Unit = outcome match { 50 | case CommandResult.Success(_, _, snapshot) => store.put(id, snapshot) 51 | case _ => () 52 | } 53 | 54 | } 55 | 56 | object CommandToResultTransformer { 57 | val Supplier: TransformerSupplier[UUID, Command, KeyValue[UUID, CommandResult]] = 58 | () => new CommandToResultTransformer 59 | } 60 | -------------------------------------------------------------------------------- /common/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | true 9 | 10 | [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/Config.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common 2 | 3 | import org.amitayh.invoices.common.serde.{AvroSerde, UuidSerde} 4 | import org.apache.kafka.clients.admin.NewTopic 5 | import org.apache.kafka.common.config.TopicConfig 6 | import org.apache.kafka.common.serialization.{Deserializer, Serde, Serializer} 7 | 8 | import scala.collection.JavaConverters._ 9 | import scala.concurrent.duration._ 10 | 11 | object Config { 12 | val BootstrapServers = sys.env("BOOTSTRAP_SERVERS") 13 | 14 | object Stores { 15 | val Snapshots = "invoices.store.snapshots" 16 | } 17 | 18 | object Topics { 19 | sealed trait CleanupPolicy 20 | object CleanupPolicy { 21 | case object Compact extends CleanupPolicy 22 | } 23 | 24 | case class Topic[K, V](name: String, 25 | keySerde: Serde[K], 26 | valueSerde: Serde[V], 27 | numPartitions: Int = 4, 28 | replicationFactor: Short = 1, 29 | retention: Option[Duration] = None, 30 | cleanupPolicy: Option[CleanupPolicy] = None) { 31 | 32 | val keySerializer: Serializer[K] = keySerde.serializer 33 | 34 | val keyDeserializer: Deserializer[K] = keySerde.deserializer 35 | 36 | val valueSerializer: Serializer[V] = valueSerde.serializer 37 | 38 | val valueDeserializer: Deserializer[V] = valueSerde.deserializer 39 | 40 | def toNewTopic: NewTopic = { 41 | val emptyConfigs = Map.empty[String, String] 42 | val withRetention = retentionConfig.foldLeft(emptyConfigs)(_ + _) 43 | val withCleanupPolicy = cleanupPolicyConfig.foldLeft(withRetention)(_ + _) 44 | new NewTopic(name, numPartitions, replicationFactor) 45 | .configs(withCleanupPolicy.asJava) 46 | } 47 | 48 | private def retentionConfig: Option[(String, String)] = retention.map { retention => 49 | val millis = if (retention.isFinite) retention.toMillis else -1 50 | TopicConfig.RETENTION_MS_CONFIG -> millis.toString 51 | } 52 | 53 | private def cleanupPolicyConfig: Option[(String, String)] = cleanupPolicy.map { 54 | case CleanupPolicy.Compact => 55 | TopicConfig.CLEANUP_POLICY_CONFIG -> 56 | TopicConfig.CLEANUP_POLICY_COMPACT 57 | } 58 | 59 | } 60 | 61 | val Events = Topic( 62 | "invoices.topic.events", 63 | UuidSerde, 64 | AvroSerde.EventSerde, 65 | retention = Some(Duration.Inf)) 66 | 67 | val Commands = Topic( 68 | "invoices.topic.commands", 69 | UuidSerde, 70 | AvroSerde.CommandSerde, 71 | retention = Some(5.minutes)) 72 | 73 | val CommandResults = Topic( 74 | "invoices.topic.command-results", 75 | UuidSerde, 76 | AvroSerde.CommandResultSerde, 77 | retention = Some(5.minutes)) 78 | 79 | val Snapshots = Topic( 80 | "invoices.topic.snapshots", 81 | UuidSerde, 82 | AvroSerde.SnapshotSerde, 83 | cleanupPolicy = Some(CleanupPolicy.Compact)) 84 | 85 | val All = Set(Events, Commands, CommandResults, Snapshots) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/CreateTopics.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common 2 | 3 | import java.util.Properties 4 | 5 | import org.amitayh.invoices.common.Config.Topics 6 | import org.apache.kafka.clients.admin.{AdminClient, AdminClientConfig} 7 | import org.log4s.getLogger 8 | 9 | import scala.collection.JavaConverters._ 10 | import scala.util.Try 11 | 12 | object CreateTopics extends App { 13 | 14 | private val logger = getLogger 15 | 16 | val admin: AdminClient = { 17 | val props = new Properties 18 | props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, Config.BootstrapServers) 19 | AdminClient.create(props) 20 | } 21 | 22 | logger.info(s"Deleting topics...") 23 | val topicNames = Topics.All.map(_.name).asJava 24 | val deleteResult = Try(admin.deleteTopics(topicNames).all().get()) 25 | logger.info(deleteResult.toString) 26 | 27 | Thread.sleep(1000) 28 | 29 | logger.info(s"Creating topics...") 30 | val createTopic = Topics.All.map(_.toNewTopic) 31 | val createResult = Try(admin.createTopics(createTopic.asJava).all().get()) 32 | logger.info(createResult.toString) 33 | 34 | admin.close() 35 | 36 | } 37 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/domain/Command.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.domain 2 | 3 | import java.time.{Instant, LocalDate} 4 | import java.util.UUID 5 | 6 | import scala.collection.immutable.Seq 7 | 8 | case class Command(originId: UUID, 9 | commandId: UUID, 10 | expectedVersion: Option[Int], 11 | payload: Command.Payload) { 12 | 13 | def apply(timestamp: Instant, snapshot: InvoiceSnapshot): CommandResult = { 14 | val outcome = snapshot 15 | .validateVersion(expectedVersion) 16 | .flatMap(payload(_)) 17 | .fold( 18 | CommandResult.Failure, 19 | success(timestamp, snapshot, _)) 20 | 21 | CommandResult(originId, commandId, outcome) 22 | } 23 | 24 | private def success(timestamp: Instant, 25 | snapshot: InvoiceSnapshot, 26 | payloads: Seq[Event.Payload]): CommandResult.Outcome = { 27 | payloads.foldLeft(CommandResult.Success(snapshot)) { (acc, payload) => 28 | acc.update(timestamp, commandId, payload) 29 | } 30 | } 31 | 32 | } 33 | 34 | object Command { 35 | type Result = Either[InvoiceError, Seq[Event.Payload]] 36 | 37 | sealed trait Payload { 38 | def apply(invoice: Invoice): Result 39 | } 40 | 41 | case class CreateInvoice(customerName: String, 42 | customerEmail: String, 43 | issueDate: LocalDate, 44 | dueDate: LocalDate, 45 | lineItems: List[LineItem]) extends Payload { 46 | 47 | override def apply(invoice: Invoice): Result = { 48 | val createdEvent = Event.InvoiceCreated(customerName, customerEmail, issueDate, dueDate) 49 | val lineItemEvents = lineItems.map(toLineItemEvent) 50 | success(createdEvent :: lineItemEvents) 51 | } 52 | 53 | private def toLineItemEvent(lineItem: LineItem): Event.Payload = 54 | Event.LineItemAdded( 55 | description = lineItem.description, 56 | quantity = lineItem.quantity, 57 | price = lineItem.price) 58 | } 59 | 60 | case class AddLineItem(description: String, 61 | quantity: Double, 62 | price: Double) extends Payload { 63 | override def apply(invoice: Invoice): Result = 64 | success(Event.LineItemAdded(description, quantity, price)) 65 | } 66 | 67 | case class RemoveLineItem(index: Int) extends Payload { 68 | override def apply(invoice: Invoice): Result = { 69 | if (invoice.hasLineItem(index)) success(Event.LineItemRemoved(index)) 70 | else failure(LineItemDoesNotExist(index)) 71 | } 72 | } 73 | 74 | case class PayInvoice() extends Payload { 75 | override def apply(invoice: Invoice): Result = 76 | success(Event.PaymentReceived(invoice.total)) 77 | } 78 | 79 | case class DeleteInvoice() extends Payload { 80 | override def apply(invoice: Invoice): Result = 81 | success(Event.InvoiceDeleted()) 82 | } 83 | 84 | private def success(events: Event.Payload*): Result = success(events.toList) 85 | 86 | private def success(events: List[Event.Payload]): Result = Right(events) 87 | 88 | private def failure(error: InvoiceError): Result = Left(error) 89 | } 90 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/domain/CommandResult.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.domain 2 | 3 | import java.time.Instant 4 | import java.util.UUID 5 | 6 | case class CommandResult(originId: UUID, 7 | commandId: UUID, 8 | outcome: CommandResult.Outcome) 9 | 10 | object CommandResult { 11 | sealed trait Outcome 12 | 13 | case class Success(events: Vector[Event], 14 | oldSnapshot: InvoiceSnapshot, 15 | newSnapshot: InvoiceSnapshot) extends Outcome { 16 | 17 | def update(timestamp: Instant, 18 | commandId: UUID, 19 | payload: Event.Payload): Success = { 20 | val event = Event(nextVersion, timestamp, commandId, payload) 21 | val snapshot = SnapshotReducer.handle(newSnapshot, event) 22 | copy(events = events :+ event, newSnapshot = snapshot) 23 | } 24 | 25 | private def nextVersion: Int = 26 | oldSnapshot.version + events.length + 1 27 | 28 | } 29 | 30 | object Success { 31 | def apply(snapshot: InvoiceSnapshot): Success = 32 | Success(Vector.empty, snapshot, snapshot) 33 | } 34 | 35 | case class Failure(cause: InvoiceError) extends Outcome 36 | } 37 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/domain/Event.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.domain 2 | 3 | import java.time.{Instant, LocalDate} 4 | import java.util.UUID 5 | 6 | case class Event(version: Int, 7 | timestamp: Instant, 8 | commandId: UUID, 9 | payload: Event.Payload) 10 | 11 | object Event { 12 | sealed trait Payload { 13 | def apply(invoice: Invoice): Invoice = invoice 14 | } 15 | 16 | case class InvoiceCreated(customerName: String, 17 | customerEmail: String, 18 | issueDate: LocalDate, 19 | dueDate: LocalDate) extends Payload { 20 | override def apply(invoice: Invoice): Invoice = 21 | invoice 22 | .setCustomer(customerName, customerEmail) 23 | .setDates(issueDate, dueDate) 24 | } 25 | 26 | case class LineItemAdded(description: String, 27 | quantity: BigDecimal, 28 | price: BigDecimal) extends Payload { 29 | override def apply(invoice: Invoice): Invoice = 30 | invoice.addLineItem(description, quantity, price) 31 | } 32 | 33 | case class LineItemRemoved(index: Int) extends Payload { 34 | override def apply(invoice: Invoice): Invoice = 35 | invoice.removeLineItem(index) 36 | } 37 | 38 | case class PaymentReceived(amount: BigDecimal) extends Payload { 39 | override def apply(invoice: Invoice): Invoice = invoice.pay(amount) 40 | } 41 | 42 | case class InvoiceDeleted() extends Payload { 43 | override def apply(invoice: Invoice): Invoice = invoice.delete 44 | } 45 | 46 | case class InvoiceSentToCustomer() extends Payload 47 | 48 | } 49 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/domain/Invoice.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.domain 2 | 3 | import java.time.LocalDate 4 | 5 | case class Invoice(customer: Customer, 6 | issueDate: LocalDate, 7 | dueDate: LocalDate, 8 | lineItems: Vector[LineItem], 9 | status: InvoiceStatus, 10 | paid: BigDecimal) { 11 | 12 | def setCustomer(name: String, email: String): Invoice = 13 | copy(customer = Customer(name, email)) 14 | 15 | def setDates(newIssueDate: LocalDate, newDueDate: LocalDate): Invoice = 16 | copy(issueDate = newIssueDate, dueDate = newDueDate) 17 | 18 | def addLineItem(description: String, 19 | quantity: BigDecimal, 20 | price: BigDecimal): Invoice = { 21 | val lineItem = LineItem(description, quantity, price) 22 | copy(lineItems = lineItems :+ lineItem) 23 | } 24 | 25 | def removeLineItem(index: Int): Invoice = { 26 | val before = lineItems.take(index) 27 | val after = lineItems.drop(index + 1) 28 | copy(lineItems = before ++ after) 29 | } 30 | 31 | def pay(amount: BigDecimal): Invoice = { 32 | val newStatus = if (amount == balance) InvoiceStatus.Paid else status 33 | copy(paid = paid + amount, status = newStatus) 34 | } 35 | 36 | def delete: Invoice = 37 | copy(status = InvoiceStatus.Deleted) 38 | 39 | def hasLineItem(index: Int): Boolean = 40 | lineItems.indices contains index 41 | 42 | def total: BigDecimal = 43 | lineItems.foldLeft[BigDecimal](0)(_ + _.total) 44 | 45 | def balance: BigDecimal = total - paid 46 | 47 | } 48 | 49 | object Invoice { 50 | val Draft = Invoice( 51 | customer = Customer.Empty, 52 | issueDate = LocalDate.MIN, 53 | dueDate = LocalDate.MAX, 54 | lineItems = Vector.empty, 55 | status = InvoiceStatus.New, 56 | paid = 0) 57 | } 58 | 59 | case class Customer(name: String, email: String) 60 | 61 | object Customer { 62 | val Empty = Customer("", "") 63 | } 64 | 65 | case class LineItem(description: String, 66 | quantity: BigDecimal, 67 | price: BigDecimal) { 68 | def total: BigDecimal = quantity * price 69 | } 70 | 71 | sealed trait InvoiceStatus 72 | object InvoiceStatus { 73 | case object New extends InvoiceStatus 74 | case object Paid extends InvoiceStatus 75 | case object Deleted extends InvoiceStatus 76 | } 77 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/domain/InvoiceError.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.domain 2 | 3 | sealed trait InvoiceError { 4 | def message: String 5 | } 6 | 7 | case class VersionMismatch(actual: Int, expected: Option[Int]) extends InvoiceError { 8 | override def message: String = s"Version mismatch - expected $expected, actually $actual" 9 | } 10 | 11 | case class LineItemDoesNotExist(index: Int) extends InvoiceError { 12 | override def message: String = s"Line item #$index does not exist" 13 | } 14 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/domain/InvoiceSnapshot.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.domain 2 | 3 | import java.time.Instant 4 | 5 | case class InvoiceSnapshot(invoice: Invoice, 6 | version: Int, 7 | timestamp: Instant) { 8 | 9 | def validateVersion(expectedVersion: Option[Int]): Either[InvoiceError, Invoice] = 10 | if (expectedVersion.forall(_ == version)) Right(invoice) 11 | else Left(VersionMismatch(version, expectedVersion)) 12 | 13 | } 14 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/domain/Reducer.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.domain 2 | 3 | import java.time.Instant 4 | 5 | trait Reducer[S, E] { 6 | def empty: S 7 | def handle(s: S, e: E): S 8 | } 9 | 10 | object InvoiceReducer extends Reducer[Invoice, Event.Payload] { 11 | override val empty: Invoice = Invoice.Draft 12 | 13 | override def handle(invoice: Invoice, event: Event.Payload): Invoice = event match { 14 | case Event.InvoiceCreated(customerName, customerEmail, issueDate, dueDate) => 15 | invoice 16 | .setCustomer(customerName, customerEmail) 17 | .setDates(issueDate, dueDate) 18 | 19 | case Event.LineItemAdded(description, quantity, price) => 20 | invoice.addLineItem(description, quantity, price) 21 | 22 | case Event.LineItemRemoved(index) => 23 | invoice.removeLineItem(index) 24 | 25 | case Event.PaymentReceived(amount) => 26 | invoice.pay(amount) 27 | 28 | case Event.InvoiceDeleted() => 29 | invoice.delete 30 | 31 | case _ => invoice 32 | } 33 | } 34 | 35 | object SnapshotReducer extends Reducer[InvoiceSnapshot, Event] { 36 | override val empty: InvoiceSnapshot = 37 | InvoiceSnapshot(InvoiceReducer.empty, 0, Instant.MIN) 38 | 39 | override def handle(snapshot: InvoiceSnapshot, event: Event): InvoiceSnapshot = { 40 | if (versionsMatch(snapshot, event)) updateSnapshot(snapshot, event) 41 | else throw new RuntimeException(s"Unexpected version $snapshot / $event") 42 | } 43 | 44 | private def versionsMatch(snapshot: InvoiceSnapshot, event: Event): Boolean = 45 | snapshot.version == (event.version - 1) 46 | 47 | private def updateSnapshot(snapshot: InvoiceSnapshot, event: Event): InvoiceSnapshot = { 48 | val invoice = InvoiceReducer.handle(snapshot.invoice, event.payload) 49 | InvoiceSnapshot(invoice, event.version, event.timestamp) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/serde/AvroSerde.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.serde 2 | 3 | import java.io.ByteArrayOutputStream 4 | import java.nio.ByteBuffer 5 | import java.time.Instant 6 | import java.util 7 | import java.util.UUID 8 | 9 | import com.sksamuel.avro4s._ 10 | import org.amitayh.invoices.common.domain._ 11 | import org.amitayh.invoices.common.serde.UuidConverters.{fromByteBuffer, toByteBuffer} 12 | import org.apache.avro.Schema 13 | import org.apache.avro.Schema.Field 14 | import org.apache.kafka.common.serialization.{Deserializer, Serde, Serializer} 15 | 16 | object AvroSerde { 17 | implicit val instantToSchema: ToSchema[Instant] = new ToSchema[Instant] { 18 | override val schema: Schema = Schema.create(Schema.Type.STRING) 19 | } 20 | 21 | implicit val instantToValue: ToValue[Instant] = new ToValue[Instant] { 22 | override def apply(value: Instant): String = value.toString 23 | } 24 | 25 | implicit val instantFromValue: FromValue[Instant] = new FromValue[Instant] { 26 | override def apply(value: Any, field: Field): Instant = 27 | Instant.parse(value.toString) 28 | } 29 | 30 | implicit val uuidToSchema: ToSchema[UUID] = new ToSchema[UUID] { 31 | override val schema: Schema = Schema.create(Schema.Type.BYTES) 32 | } 33 | 34 | implicit val uuidToValue: ToValue[UUID] = new ToValue[UUID] { 35 | override def apply(value: UUID): ByteBuffer = toByteBuffer(value) 36 | } 37 | 38 | implicit val uuidFromValue: FromValue[UUID] = new FromValue[UUID] { 39 | override def apply(value: Any, field: Field): UUID = 40 | fromByteBuffer(value.asInstanceOf[ByteBuffer]) 41 | } 42 | 43 | val CommandSerde: Serde[Command] = serdeFor[Command] 44 | 45 | val CommandResultSerde: Serde[CommandResult] = serdeFor[CommandResult] 46 | 47 | val SnapshotSerde: Serde[InvoiceSnapshot] = serdeFor[InvoiceSnapshot] 48 | 49 | val EventSerde: Serde[Event] = serdeFor[Event] 50 | 51 | def toBytes[T: SchemaFor: ToRecord](data: T): Array[Byte] = { 52 | val baos = new ByteArrayOutputStream 53 | val output = AvroOutputStream.binary[T](baos) 54 | output.write(data) 55 | output.close() 56 | baos.toByteArray 57 | } 58 | 59 | def fromBytes[T: SchemaFor: FromRecord](data: Array[Byte]): T = { 60 | val input = AvroInputStream.binary[T](data) 61 | input.iterator.next() 62 | } 63 | 64 | private def serdeFor[T: SchemaFor: ToRecord: FromRecord]: Serde[T] = new Serde[T] { 65 | override val serializer: Serializer[T] = new Serializer[T] { 66 | override def serialize(topic: String, data: T): Array[Byte] = toBytes(data) 67 | override def configure(configs: util.Map[String, _], isKey: Boolean): Unit = () 68 | override def close(): Unit = () 69 | } 70 | override val deserializer: Deserializer[T] = new Deserializer[T] { 71 | override def deserialize(topic: String, data: Array[Byte]): T = fromBytes(data) 72 | override def configure(configs: util.Map[String, _], isKey: Boolean): Unit = () 73 | override def close(): Unit = () 74 | } 75 | override def configure(configs: util.Map[String, _], isKey: Boolean): Unit = () 76 | override def close(): Unit = () 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/serde/CommandSerializer.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.serde 2 | 3 | import java.util 4 | 5 | import org.amitayh.invoices.common.domain.Command 6 | import org.amitayh.invoices.common.serde.AvroSerde._ 7 | import org.apache.kafka.common.serialization.Serializer 8 | 9 | object CommandSerializer extends Serializer[Command] { 10 | override def configure(configs: util.Map[String, _], isKey: Boolean): Unit = () 11 | override def serialize(topic: String, command: Command): Array[Byte] = toBytes(command) 12 | override def close(): Unit = () 13 | } 14 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/serde/UuidConverters.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.serde 2 | 3 | import java.nio.ByteBuffer 4 | import java.util.UUID 5 | 6 | object UuidConverters { 7 | def toBytes(uuid: UUID): Array[Byte] = 8 | toByteBuffer(uuid).array 9 | 10 | def toByteBuffer(uuid: UUID): ByteBuffer = { 11 | val buffer = ByteBuffer.allocate(16) 12 | buffer.putLong(0, uuid.getMostSignificantBits) 13 | buffer.putLong(8, uuid.getLeastSignificantBits) 14 | buffer 15 | } 16 | 17 | def fromBytes(data: Array[Byte]): UUID = 18 | fromByteBuffer(ByteBuffer.wrap(data)) 19 | 20 | def fromByteBuffer(buffer: ByteBuffer): UUID = { 21 | val mostSignificantBits = buffer.getLong(0) 22 | val leastSignificantBits = buffer.getLong(8) 23 | new UUID(mostSignificantBits, leastSignificantBits) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /common/src/main/scala/org/amitayh/invoices/common/serde/UuidSerde.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.common.serde 2 | 3 | import java.util 4 | import java.util.UUID 5 | 6 | import org.amitayh.invoices.common.serde.UuidConverters.{fromBytes, toBytes} 7 | import org.apache.kafka.common.serialization.{Deserializer, Serde, Serializer} 8 | 9 | object UuidSerializer extends Serializer[UUID] { 10 | override def configure(configs: util.Map[String, _], isKey: Boolean): Unit = () 11 | override def serialize(topic: String, uuid: UUID): Array[Byte] = toBytes(uuid) 12 | override def close(): Unit = () 13 | } 14 | 15 | object UuidDeserializer extends Deserializer[UUID] { 16 | override def configure(configs: util.Map[String, _], isKey: Boolean): Unit = () 17 | override def deserialize(topic: String, data: Array[Byte]): UUID = fromBytes(data) 18 | override def close(): Unit = () 19 | } 20 | 21 | object UuidSerde extends Serde[UUID] { 22 | override def configure(configs: util.Map[String, _], isKey: Boolean): Unit = () 23 | override val serializer: Serializer[UUID] = UuidSerializer 24 | override val deserializer: Deserializer[UUID] = UuidDeserializer 25 | override def close(): Unit = () 26 | } 27 | -------------------------------------------------------------------------------- /config/local.properties: -------------------------------------------------------------------------------- 1 | bootstrap.servers=localhost:9092 2 | db.driver=com.mysql.cj.jdbc.Driver 3 | db.url=jdbc:mysql://localhost/invoices 4 | db.user=root 5 | db.pass= 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | zookeeper: 5 | image: wurstmeister/zookeeper 6 | ports: 7 | - 2181:2181 8 | 9 | kafka: 10 | image: wurstmeister/kafka 11 | depends_on: 12 | - zookeeper 13 | ports: 14 | - 9092:9092 15 | environment: 16 | KAFKA_ADVERTISED_HOST_NAME: ${KAFKA_HOST_NAME} 17 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 18 | KAFKA_CREATE_TOPICS: "invoices.topic.events:1:1,invoices.topic.commands:1:1,invoices.topic.command-results:1:1,invoices.topic.snapshots:1:1:compact" 19 | volumes: 20 | - /var/run/docker.sock:/var/run/docker.sock 21 | 22 | mysql: 23 | image: mysql 24 | command: --default-authentication-plugin=mysql_native_password 25 | restart: always 26 | volumes: 27 | - ./listdao/src/main/resources/:/docker-entrypoint-initdb.d/ 28 | environment: 29 | MYSQL_DATABASE: invoices 30 | MYSQL_ROOT_PASSWORD: ${DB_PASS} 31 | 32 | commandhandler: 33 | build: ./commandhandler 34 | depends_on: 35 | - kafka 36 | environment: 37 | BOOTSTRAP_SERVERS: ${KAFKA_HOST_NAME}:9092 38 | 39 | listprojector: 40 | build: ./listprojector 41 | depends_on: 42 | - mysql 43 | - kafka 44 | environment: 45 | BOOTSTRAP_SERVERS: ${KAFKA_HOST_NAME}:9092 46 | DB_DRIVER: ${DB_DRIVER} 47 | DB_URL: ${DB_URL} 48 | DB_USER: ${DB_USER} 49 | DB_PASS: ${DB_PASS} 50 | 51 | web: 52 | build: ./web 53 | depends_on: 54 | - mysql 55 | - kafka 56 | environment: 57 | BOOTSTRAP_SERVERS: ${KAFKA_HOST_NAME}:9092 58 | DB_DRIVER: ${DB_DRIVER} 59 | DB_URL: ${DB_URL} 60 | DB_USER: ${DB_USER} 61 | DB_PASS: ${DB_PASS} 62 | ports: 63 | - 8080:8080 64 | -------------------------------------------------------------------------------- /listdao/src/main/resources/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `invoices` ( 2 | `id` VARCHAR(64) NOT NULL, 3 | `version` INT UNSIGNED DEFAULT 0, 4 | `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 5 | `updated_at` VARCHAR(64) DEFAULT NULL, 6 | `customer_name` VARCHAR(64) DEFAULT NULL, 7 | `customer_email` VARCHAR(64) DEFAULT NULL, 8 | `issue_date` VARCHAR(64) DEFAULT NULL, 9 | `due_date` VARCHAR(64) DEFAULT NULL, 10 | `total` double DEFAULT NULL, 11 | `status` VARCHAR(64) DEFAULT NULL, 12 | PRIMARY KEY (`id`) 13 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 14 | -------------------------------------------------------------------------------- /listdao/src/main/scala/org/amitayh/invoices/dao/InvoiceList.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.dao 2 | 3 | trait InvoiceList[F[_]] { 4 | def save(record: InvoiceRecord): F[Unit] 5 | def get: F[List[InvoiceRecord]] 6 | } 7 | -------------------------------------------------------------------------------- /listdao/src/main/scala/org/amitayh/invoices/dao/InvoiceRecord.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.dao 2 | 3 | import java.util.UUID 4 | 5 | import org.amitayh.invoices.common.domain.InvoiceSnapshot 6 | 7 | case class InvoiceRecord(id: String, 8 | version: Int, 9 | updatedAt: String, 10 | customerName: String, 11 | customerEmail: String, 12 | issueDate: String, 13 | dueDate: String, 14 | total: Double, 15 | status: String) 16 | 17 | object InvoiceRecord { 18 | def apply(id: UUID, snapshot: InvoiceSnapshot): InvoiceRecord = { 19 | val invoice = snapshot.invoice 20 | val customer = invoice.customer 21 | InvoiceRecord( 22 | id = id.toString, 23 | version = snapshot.version, 24 | updatedAt = snapshot.timestamp.toString, 25 | customerName = customer.name, 26 | customerEmail = customer.email, 27 | issueDate = invoice.issueDate.toString, 28 | dueDate = invoice.dueDate.toString, 29 | total = invoice.total.toDouble, 30 | status = invoice.status.toString) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /listdao/src/main/scala/org/amitayh/invoices/dao/MySqlInvoiceList.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.dao 2 | 3 | import cats.Monad 4 | import cats.effect.{Async, ContextShift, Resource} 5 | import cats.syntax.functor._ 6 | import doobie.free.connection.ConnectionIO 7 | import doobie.hikari.HikariTransactor 8 | import doobie.implicits._ 9 | import doobie.util.ExecutionContexts 10 | import doobie.util.transactor.Transactor 11 | 12 | class MySqlInvoiceList[F[_]: Monad](transactor: Transactor[F]) extends InvoiceList[F] { 13 | override def save(record: InvoiceRecord): F[Unit] = 14 | MySqlInvoiceList.save(record).transact(transactor) 15 | 16 | override def get: F[List[InvoiceRecord]] = 17 | MySqlInvoiceList.get.transact(transactor) 18 | } 19 | 20 | object MySqlInvoiceList { 21 | def save(record: InvoiceRecord): ConnectionIO[Unit] = { 22 | import record._ 23 | val sql = sql""" 24 | INSERT INTO invoices (id, version, updated_at, customer_name, customer_email, issue_date, due_date, total, status) 25 | VALUES ($id, $version, $updatedAt, $customerName, $customerEmail, $issueDate, $dueDate, $total, $status) 26 | ON DUPLICATE KEY UPDATE 27 | version = VALUES(version), 28 | updated_at = VALUES(updated_at), 29 | customer_name = VALUES(customer_name), 30 | customer_email = VALUES(customer_email), 31 | issue_date = VALUES(issue_date), 32 | due_date = VALUES(due_date), 33 | total = VALUES(total), 34 | status = VALUES(status) 35 | """ 36 | sql.update.run.void 37 | } 38 | 39 | def get: ConnectionIO[List[InvoiceRecord]] = { 40 | val sql = sql""" 41 | SELECT id, version, updated_at, customer_name, customer_email, issue_date, due_date, total, status 42 | FROM invoices 43 | WHERE status IN ('New', 'Paid') 44 | ORDER BY created_at DESC 45 | """ 46 | sql.query[InvoiceRecord].to[List] 47 | } 48 | 49 | def resource[F[_]: Async: ContextShift]: Resource[F, MySqlInvoiceList[F]] = for { 50 | connectEC <- ExecutionContexts.fixedThreadPool[F](32) 51 | transactEC <- ExecutionContexts.cachedThreadPool[F] 52 | transactor <- HikariTransactor.newHikariTransactor[F]( 53 | driverClassName = sys.env("DB_DRIVER"), 54 | url = sys.env("DB_URL"), 55 | user = sys.env("DB_USER"), 56 | pass = sys.env("DB_PASS"), 57 | connectEC = connectEC, 58 | transactEC = transactEC) 59 | } yield new MySqlInvoiceList[F](transactor) 60 | } 61 | -------------------------------------------------------------------------------- /listprojector/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM java:8 2 | ADD target/scala-2.12/listprojector.jar listprojector.jar 3 | CMD java -jar listprojector.jar 4 | -------------------------------------------------------------------------------- /listprojector/src/main/scala/org/amitayh/invoices/projector/ListProjector.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.projector 2 | 3 | import java.util.UUID 4 | 5 | import cats.effect.concurrent.Deferred 6 | import cats.effect.{ContextShift, IO} 7 | import cats.syntax.apply._ 8 | import org.amitayh.invoices.common.Config 9 | import org.amitayh.invoices.common.domain.InvoiceSnapshot 10 | import org.amitayh.invoices.common.serde.AvroSerde.SnapshotSerde 11 | import org.amitayh.invoices.common.serde.UuidSerde 12 | import org.amitayh.invoices.dao.{InvoiceList, InvoiceRecord, MySqlInvoiceList} 13 | import org.amitayh.invoices.streamprocessor.StreamProcessorApp 14 | import org.apache.kafka.streams.kstream.{Consumed, ForeachAction, KeyValueMapper} 15 | import org.apache.kafka.streams.{KeyValue, StreamsBuilder, Topology} 16 | 17 | import scala.concurrent.ExecutionContext.global 18 | 19 | object ListProjector extends StreamProcessorApp { 20 | 21 | override def appId: String = "invoices.processor.list-projector" 22 | 23 | override def topology: Topology = ListProjectorTopology.create.unsafeRunSync() 24 | 25 | } 26 | 27 | object ListProjectorTopology { 28 | implicit val contextShift: ContextShift[IO] = IO.contextShift(global) 29 | 30 | def create: IO[Topology] = for { 31 | deferred <- Deferred[IO, Topology] 32 | _ <- MySqlInvoiceList.resource[IO].use { invoiceList => 33 | buildTopology(invoiceList).flatMap(deferred.complete) *> IO.never 34 | }.start 35 | topology <- deferred.get 36 | } yield topology 37 | 38 | private def buildTopology(invoiceList: InvoiceList[IO]): IO[Topology] = IO { 39 | val builder = new StreamsBuilder 40 | 41 | val snapshots = builder.stream( 42 | Config.Topics.Snapshots.name, 43 | Consumed.`with`(UuidSerde, SnapshotSerde)) 44 | 45 | snapshots 46 | .map[UUID, InvoiceRecord](ToRecord) 47 | .foreach(new SaveInvoiceRecord(invoiceList)) 48 | 49 | builder.build() 50 | } 51 | } 52 | 53 | object ToRecord extends KeyValueMapper[UUID, InvoiceSnapshot, KeyValue[UUID, InvoiceRecord]] { 54 | override def apply(id: UUID, snapshot: InvoiceSnapshot): KeyValue[UUID, InvoiceRecord] = 55 | KeyValue.pair(id, InvoiceRecord(id, snapshot)) 56 | } 57 | 58 | class SaveInvoiceRecord(invoicesList: InvoiceList[IO]) 59 | extends ForeachAction[UUID, InvoiceRecord] { 60 | 61 | override def apply(id: UUID, value: InvoiceRecord): Unit = 62 | invoicesList.save(value).unsafeRunSync() 63 | 64 | } 65 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.0.2 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | logLevel := Level.Warn 2 | 3 | addSbtPlugin("io.get-coursier" % "sbt-coursier" % "1.0.2") 4 | 5 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.3.2") 6 | 7 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.5") 8 | -------------------------------------------------------------------------------- /streamprocessor/src/main/scala/org/amitayh/invoices/streamprocessor/StreamProcessorApp.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.streamprocessor 2 | 3 | import java.util.Properties 4 | import java.util.concurrent.CountDownLatch 5 | 6 | import org.amitayh.invoices.common.Config 7 | import org.apache.kafka.streams.KafkaStreams.State 8 | import org.apache.kafka.streams.{KafkaStreams, StreamsConfig, Topology} 9 | import org.log4s.getLogger 10 | 11 | trait StreamProcessorApp extends App { 12 | 13 | def appId: String 14 | 15 | def topology: Topology 16 | 17 | private val logger = getLogger 18 | 19 | private val latch = new CountDownLatch(1) 20 | 21 | private val streams: KafkaStreams = { 22 | val props = new Properties 23 | props.put(StreamsConfig.APPLICATION_ID_CONFIG, appId) 24 | props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, Config.BootstrapServers) 25 | props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, StreamsConfig.EXACTLY_ONCE) 26 | new KafkaStreams(topology, props) 27 | } 28 | 29 | streams.setStateListener((newState: State, oldState: State) => { 30 | logger.info(s"$oldState -> $newState") 31 | }) 32 | 33 | streams.setUncaughtExceptionHandler((_: Thread, e: Throwable) => { 34 | logger.error(e)(s"Exception was thrown in stream processor $appId") 35 | latch.countDown() 36 | }) 37 | 38 | def start(): Unit = { 39 | logger.info("Starting...") 40 | streams.start() 41 | sys.ShutdownHookThread(close()) 42 | latch.await() 43 | } 44 | 45 | def close(): Unit = { 46 | logger.info("Shutting down...") 47 | streams.close() 48 | } 49 | 50 | start() 51 | 52 | } 53 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM java:8 2 | ADD target/scala-2.12/web.jar web.jar 3 | EXPOSE 8080 4 | CMD java -jar web.jar 5 | -------------------------------------------------------------------------------- /web/src/main/resources/statics/asset-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "main.css": "static/css/main.98142e8e.css", 3 | "main.css.map": "static/css/main.98142e8e.css.map", 4 | "main.js": "static/js/main.66c286e3.js", 5 | "main.js.map": "static/js/main.66c286e3.js.map" 6 | } -------------------------------------------------------------------------------- /web/src/main/resources/statics/index.html: -------------------------------------------------------------------------------- 1 | Kafka Streams Demo
-------------------------------------------------------------------------------- /web/src/main/resources/statics/static/css/main.98142e8e.css: -------------------------------------------------------------------------------- 1 | .message{position:absolute;white-space:pre;top:20px;left:0;right:0;margin:auto;width:520px} 2 | /*# sourceMappingURL=main.98142e8e.css.map*/ -------------------------------------------------------------------------------- /web/src/main/resources/statics/static/css/main.98142e8e.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["components/Message.css"],"names":[],"mappings":"AAAA,SACI,kBACA,gBACA,SACA,OACA,QACA,YACA,WAAa","file":"static/css/main.98142e8e.css","sourcesContent":[".message {\n position: absolute;\n white-space: pre;\n top: 20px;\n left: 0;\n right: 0;\n margin: auto;\n width: 520px;\n}\n\n\n\n// WEBPACK FOOTER //\n// ./src/components/Message.css"],"sourceRoot":""} -------------------------------------------------------------------------------- /web/src/main/scala/org/amitayh/invoices/web/CommandDto.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.web 2 | 3 | import java.time.LocalDate 4 | 5 | import io.circe.Decoder 6 | import io.circe.generic.semiauto._ 7 | import org.amitayh.invoices.common.domain.Command.Payload 8 | import org.amitayh.invoices.common.domain.{Command, LineItem} 9 | 10 | import scala.util.Try 11 | 12 | object CommandDto { 13 | 14 | implicit val decodeLocalDate: Decoder[LocalDate] = 15 | Decoder.decodeString.emapTry(string => Try(LocalDate.parse(string))) 16 | 17 | implicit val decodeLineItem: Decoder[LineItem] = deriveDecoder 18 | 19 | implicit val decodePayload: Decoder[Payload] = deriveDecoder 20 | 21 | implicit val decodeCommand: Decoder[Command] = deriveDecoder 22 | 23 | } 24 | -------------------------------------------------------------------------------- /web/src/main/scala/org/amitayh/invoices/web/InvoicesApi.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.web 2 | 3 | import java.util.UUID 4 | 5 | import cats.effect.{Concurrent, Timer} 6 | import cats.implicits._ 7 | import fs2.Stream 8 | import fs2.concurrent.Topic 9 | import io.circe._ 10 | import io.circe.generic.auto._ 11 | import io.circe.syntax._ 12 | import org.amitayh.invoices.common.domain.CommandResult.{Failure, Success} 13 | import org.amitayh.invoices.common.domain.{Command, CommandResult} 14 | import org.amitayh.invoices.dao.InvoiceList 15 | import org.amitayh.invoices.web.CommandDto._ 16 | import org.amitayh.invoices.web.PushEvents.CommandResultRecord 17 | import org.http4s.circe._ 18 | import org.http4s.dsl.Http4sDsl 19 | import org.http4s.{EntityDecoder, HttpRoutes, Response} 20 | 21 | import scala.concurrent.duration._ 22 | 23 | class InvoicesApi[F[_]: Concurrent: Timer] extends Http4sDsl[F] { 24 | 25 | private val maxQueued = 16 26 | 27 | implicit val commandEntityDecoder: EntityDecoder[F, Command] = jsonOf[F, Command] 28 | 29 | def service(invoiceList: InvoiceList[F], 30 | producer: Kafka.Producer[F, UUID, Command], 31 | commandResultsTopic: Topic[F, CommandResultRecord]): HttpRoutes[F] = HttpRoutes.of[F] { 32 | case GET -> Root / "invoices" => 33 | invoiceList.get.flatMap(invoices => Ok(invoices.asJson)) 34 | 35 | case request @ POST -> Root / "execute" / "async" / UuidVar(invoiceId) => 36 | request 37 | .as[Command] 38 | .flatMap(producer.send(invoiceId, _)) 39 | .flatMap(metaData => Accepted(Json.fromLong(metaData.timestamp))) 40 | 41 | case request @ POST -> Root / "execute" / UuidVar(invoiceId) => 42 | request.as[Command].flatMap { command => 43 | val response = resultStream(commandResultsTopic, command.commandId) merge timeoutStream 44 | producer.send(invoiceId, command) *> response.head.compile.toList.map(_.head) 45 | } 46 | } 47 | 48 | private def resultStream(commandResultsTopic: Topic[F, CommandResultRecord], 49 | commandId: UUID): Stream[F, Response[F]] = 50 | commandResultsTopic.subscribe(maxQueued).collectFirst { 51 | case Some((_, CommandResult(_, `commandId`, outcome))) => outcome 52 | }.flatMap { 53 | case Success(_, _, snapshot) => Stream.eval(Ok(snapshot.asJson)) 54 | case Failure(cause) => Stream.eval(UnprocessableEntity(cause.message)) 55 | } 56 | 57 | private def timeoutStream: Stream[F, Response[F]] = 58 | Stream.eval(Timer[F].sleep(5.seconds) *> RequestTimeout("timeout")) 59 | 60 | } 61 | 62 | object InvoicesApi { 63 | def apply[F[_]: Concurrent: Timer]: InvoicesApi[F] = new InvoicesApi[F] 64 | } 65 | -------------------------------------------------------------------------------- /web/src/main/scala/org/amitayh/invoices/web/InvoicesServer.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.web 2 | 3 | import java.util.UUID 4 | 5 | import cats.effect.{ExitCode, IO, IOApp} 6 | import cats.syntax.functor._ 7 | import fs2.Stream 8 | import fs2.concurrent.Topic 9 | import org.amitayh.invoices.common.Config.Topics 10 | import org.amitayh.invoices.common.domain.{Command, CommandResult, InvoiceSnapshot} 11 | import org.amitayh.invoices.dao.{InvoiceList, MySqlInvoiceList} 12 | import org.amitayh.invoices.web.PushEvents._ 13 | import org.http4s.implicits._ 14 | import org.http4s.server.Router 15 | import org.http4s.server.blaze.BlazeServerBuilder 16 | 17 | object InvoicesServer extends IOApp { 18 | 19 | override def run(args: List[String]): IO[ExitCode] = 20 | stream.compile.drain.as(ExitCode.Success) 21 | 22 | private val stream: Stream[IO, ExitCode] = for { 23 | invoiceList <- Stream.resource(MySqlInvoiceList.resource[IO]) 24 | producer <- Stream.resource(Kafka.producer[IO, UUID, Command](Topics.Commands)) 25 | commandResultsTopic <- Stream.eval(Topic[IO, CommandResultRecord](None)) 26 | invoiceUpdatesTopic <- Stream.eval(Topic[IO, InvoiceSnapshotRecord](None)) 27 | server <- httpServer(invoiceList, producer, commandResultsTopic, invoiceUpdatesTopic) concurrently 28 | commandResults.through(commandResultsTopic.publish) concurrently 29 | invoiceUpdates.through(invoiceUpdatesTopic.publish) 30 | } yield server 31 | 32 | private def commandResults: Stream[IO, CommandResultRecord] = 33 | Kafka.subscribe[IO, UUID, CommandResult]( 34 | topic = Topics.CommandResults, 35 | groupId = "invoices.websocket.command-results").map(Some(_)) 36 | 37 | private def invoiceUpdates: Stream[IO, InvoiceSnapshotRecord] = 38 | Kafka.subscribe[IO, UUID, InvoiceSnapshot]( 39 | topic = Topics.Snapshots, 40 | groupId = "invoices.websocket.snapshots").map(Some(_)) 41 | 42 | private def httpServer(invoiceList: InvoiceList[IO], 43 | producer: Kafka.Producer[IO, UUID, Command], 44 | commandResultsTopic: Topic[IO, CommandResultRecord], 45 | invoiceUpdatesTopic: Topic[IO, InvoiceSnapshotRecord]): Stream[IO, ExitCode] = 46 | BlazeServerBuilder[IO] 47 | .bindHttp(8080, "0.0.0.0") 48 | .withHttpApp( 49 | Router( 50 | "/api" -> InvoicesApi[IO].service(invoiceList, producer, commandResultsTopic), 51 | "/events" -> PushEvents[IO].service(commandResultsTopic, invoiceUpdatesTopic), 52 | "/" -> Statics[IO].service).orNotFound) 53 | .serve 54 | 55 | } 56 | -------------------------------------------------------------------------------- /web/src/main/scala/org/amitayh/invoices/web/Kafka.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.web 2 | 3 | import java.time.Duration 4 | import java.util.Collections.singletonList 5 | import java.util.Properties 6 | 7 | import cats.effect._ 8 | import cats.syntax.apply._ 9 | import cats.syntax.functor._ 10 | import fs2._ 11 | import org.amitayh.invoices.common.Config 12 | import org.amitayh.invoices.common.Config.Topics.Topic 13 | import org.apache.kafka.clients.consumer._ 14 | import org.apache.kafka.clients.producer.{KafkaProducer, ProducerConfig, ProducerRecord, RecordMetadata} 15 | import org.log4s.{Logger, getLogger} 16 | 17 | import scala.collection.JavaConverters._ 18 | 19 | object Kafka { 20 | 21 | trait Producer[F[_], K, V] { 22 | def send(key: K, value: V): F[RecordMetadata] 23 | } 24 | 25 | object Producer { 26 | def apply[F[_]: Async, K, V](producer: KafkaProducer[K, V], topic: Topic[K, V]): Producer[F, K, V] = 27 | (key: K, value: V) => Async[F].async { cb => 28 | val record = new ProducerRecord(topic.name, key, value) 29 | producer.send(record, (metadata: RecordMetadata, exception: Exception) => { 30 | if (exception != null) cb(Left(exception)) 31 | else cb(Right(metadata)) 32 | }) 33 | } 34 | } 35 | 36 | def producer[F[_]: Async, K, V](topic: Topic[K, V]): Resource[F, Producer[F, K, V]] = Resource { 37 | val create = Sync[F].delay { 38 | val props = new Properties 39 | props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, Config.BootstrapServers) 40 | new KafkaProducer[K, V](props, topic.keySerializer, topic.valueSerializer) 41 | } 42 | create.map(producer => (Producer(producer, topic), close(producer))) 43 | } 44 | 45 | def subscribe[F[_]: Sync, K, V](topic: Topic[K, V], groupId: String): Stream[F, (K, V)] = { 46 | val create = Sync[F].delay { 47 | val props = new Properties 48 | props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, Config.BootstrapServers) 49 | props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId) 50 | val consumer = new KafkaConsumer(props, topic.keyDeserializer, topic.valueDeserializer) 51 | consumer.subscribe(singletonList(topic.name)) 52 | consumer 53 | } 54 | Stream.bracket(create)(close[F]).flatMap(consume[F, K, V]) 55 | } 56 | 57 | private val logger: Logger = getLogger 58 | 59 | def log[F[_]: Sync](msg: String): F[Unit] = Sync[F].delay(logger.info(msg)) 60 | 61 | private def consume[F[_]: Sync, K, V](consumer: KafkaConsumer[K, V]): Stream[F, (K, V)] = for { 62 | records <- Stream.repeatEval(Sync[F].delay(consumer.poll(Duration.ofSeconds(1)))) 63 | record <- Stream.emits(records.iterator.asScala.toSeq) 64 | } yield record.key -> record.value 65 | 66 | private def close[F[_]: Sync](producer: KafkaProducer[_, _]): F[Unit] = 67 | Sync[F].delay(producer.close()) *> log(s"Producer closed") 68 | 69 | private def close[F[_]: Sync](consumer: KafkaConsumer[_, _]): F[Unit] = 70 | Sync[F].delay(consumer.close()) *> log("Consumer closed") 71 | 72 | } 73 | -------------------------------------------------------------------------------- /web/src/main/scala/org/amitayh/invoices/web/PushEvents.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.web 2 | 3 | import java.util.UUID 4 | 5 | import cats.effect._ 6 | import fs2.concurrent.Topic 7 | import io.circe.generic.auto._ 8 | import io.circe.syntax._ 9 | import org.amitayh.invoices.common.domain.{CommandResult, InvoiceSnapshot} 10 | import org.amitayh.invoices.dao.InvoiceRecord 11 | import org.amitayh.invoices.web.PushEvents._ 12 | import org.http4s.dsl.Http4sDsl 13 | import org.http4s.{HttpRoutes, ServerSentEvent} 14 | 15 | class PushEvents[F[_]: Concurrent] extends Http4sDsl[F] { 16 | 17 | private val maxQueued = 16 18 | 19 | def service(commandResultsTopic: Topic[F, CommandResultRecord], 20 | invoiceUpdatesTopic: Topic[F, InvoiceSnapshotRecord]): HttpRoutes[F] = HttpRoutes.of[F] { 21 | case GET -> Root / UuidVar(originId) => 22 | val commandResults = commandResultsTopic.subscribe(maxQueued).collect { 23 | case Some((_, result)) if result.originId == originId => 24 | Event(result).asServerSentEvent 25 | } 26 | val invoiceUpdates = invoiceUpdatesTopic.subscribe(maxQueued).collect { 27 | case Some((id, snapshot)) => Event(id, snapshot).asServerSentEvent 28 | } 29 | Ok(commandResults merge invoiceUpdates) 30 | } 31 | 32 | } 33 | 34 | object PushEvents { 35 | type CommandResultRecord = Option[(UUID, CommandResult)] 36 | type InvoiceSnapshotRecord = Option[(UUID, InvoiceSnapshot)] 37 | 38 | def apply[F[_]: Concurrent]: PushEvents[F] = new PushEvents[F] 39 | } 40 | 41 | sealed trait Event { 42 | def asServerSentEvent: ServerSentEvent = 43 | ServerSentEvent(this.asJson.noSpaces) 44 | } 45 | 46 | case class CommandSucceeded(commandId: UUID) extends Event 47 | case class CommandFailed(commandId: UUID, cause: String) extends Event 48 | case class InvoiceUpdated(record: InvoiceRecord) extends Event 49 | 50 | object Event { 51 | def apply(result: CommandResult): Event = result match { 52 | case CommandResult(_, commandId, _: CommandResult.Success) => 53 | CommandSucceeded(commandId) 54 | 55 | case CommandResult(_, commandId, CommandResult.Failure(cause)) => 56 | CommandFailed(commandId, cause.message) 57 | } 58 | 59 | def apply(id: UUID, snapshot: InvoiceSnapshot): Event = 60 | InvoiceUpdated(InvoiceRecord(id, snapshot)) 61 | } 62 | -------------------------------------------------------------------------------- /web/src/main/scala/org/amitayh/invoices/web/Statics.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.web 2 | 3 | import cats.effect.{ContextShift, Sync} 4 | import org.http4s.dsl.Http4sDsl 5 | import org.http4s.{HttpRoutes, StaticFile} 6 | 7 | import scala.concurrent.ExecutionContext.global 8 | 9 | class Statics[F[_]: Sync: ContextShift] extends Http4sDsl[F] { 10 | 11 | val service: HttpRoutes[F] = HttpRoutes.of[F] { 12 | case request @ GET -> fileName => 13 | StaticFile 14 | .fromResource( 15 | name = s"/statics$fileName", 16 | blockingExecutionContext = global, 17 | req = Some(request), 18 | preferGzipped = true) 19 | .getOrElseF(NotFound()) 20 | } 21 | 22 | } 23 | 24 | object Statics { 25 | def apply[F[_]: Sync: ContextShift]: Statics[F] = new Statics[F] 26 | } 27 | -------------------------------------------------------------------------------- /web/src/main/scala/org/amitayh/invoices/web/UuidVar.scala: -------------------------------------------------------------------------------- 1 | package org.amitayh.invoices.web 2 | 3 | import java.util.UUID 4 | 5 | import scala.util.Try 6 | 7 | object UuidVar { 8 | def unapply(arg: String): Option[UUID] = 9 | Try(UUID.fromString(arg)).toOption 10 | } 11 | --------------------------------------------------------------------------------