From ae5c229e4b3a4c5499c35a05db1a0b07d3646f35 Mon Sep 17 00:00:00 2001 From: Fabrice Lecomte Date: Thu, 30 May 2024 21:41:02 +0200 Subject: [PATCH] Refactor --- build.gradle.kts | 8 +- gradle.properties | 15 ++-- resources/openapi/documentation.yaml | 56 ++++++++++++- src/main/kotlin/eventDemo/Application.kt | 2 + src/main/kotlin/eventDemo/app/Card.kt | 39 --------- .../kotlin/eventDemo/app/CommandStream.kt | 24 ------ src/main/kotlin/eventDemo/app/EventStream.kt | 27 ------ src/main/kotlin/eventDemo/app/Events.kt | 10 --- src/main/kotlin/eventDemo/app/actions/Card.kt | 61 -------------- .../kotlin/eventDemo/app/actions/Command.kt | 56 ------------- .../app/actions/playNewCard/HttpInput.kt | 50 +++++++++++ .../playNewCard/PlayCardCommand.kt} | 24 ++---- .../playNewCard/PlayCardCommandHandler.kt | 26 ++++++ .../actions/readLastPlayedCard/HttpInput.kt | 42 ++++++++++ .../kotlin/eventDemo/libs/command/Command.kt | 20 +++++ .../eventDemo/libs/command/CommandStream.kt | 36 ++++++++ .../libs/command/CommandStreamInMemory.kt | 82 +++++++++++++++++++ src/main/kotlin/eventDemo/libs/event/Event.kt | 11 +++ .../eventDemo/libs/event/EventStream.kt | 19 +++++ .../libs/event/EventStreamInMemory.kt | 43 ++++++++++ .../eventDemo/plugins/CommandHandler.kt | 9 ++ src/main/kotlin/eventDemo/plugins/Koin.kt | 11 +-- src/main/kotlin/eventDemo/plugins/Routing.kt | 8 +- .../kotlin/eventDemo/plugins/Serialization.kt | 4 +- .../{app/AggregateId.kt => shared/GameId.kt} | 7 +- .../shared/command/GameCommandStream.kt | 6 ++ .../kotlin/eventDemo/shared/entity/Card.kt | 54 ++++++++++++ .../kotlin/eventDemo/shared/entity/Game.kt | 13 +++ .../shared/event/CardIsPlayedEvent.kt | 14 ++++ .../eventDemo/shared/event/GameEventStream.kt | 6 ++ .../kotlin/eventDemo/app/actions/CardTest.kt | 28 ++++--- .../eventDemo/app/actions/CommandTest.kt | 70 ---------------- 32 files changed, 537 insertions(+), 344 deletions(-) delete mode 100644 src/main/kotlin/eventDemo/app/Card.kt delete mode 100644 src/main/kotlin/eventDemo/app/CommandStream.kt delete mode 100644 src/main/kotlin/eventDemo/app/EventStream.kt delete mode 100644 src/main/kotlin/eventDemo/app/Events.kt delete mode 100644 src/main/kotlin/eventDemo/app/actions/Card.kt delete mode 100644 src/main/kotlin/eventDemo/app/actions/Command.kt create mode 100644 src/main/kotlin/eventDemo/app/actions/playNewCard/HttpInput.kt rename src/main/kotlin/eventDemo/app/{Command.kt => actions/playNewCard/PlayCardCommand.kt} (52%) create mode 100644 src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommandHandler.kt create mode 100644 src/main/kotlin/eventDemo/app/actions/readLastPlayedCard/HttpInput.kt create mode 100644 src/main/kotlin/eventDemo/libs/command/Command.kt create mode 100644 src/main/kotlin/eventDemo/libs/command/CommandStream.kt create mode 100644 src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt create mode 100644 src/main/kotlin/eventDemo/libs/event/Event.kt create mode 100644 src/main/kotlin/eventDemo/libs/event/EventStream.kt create mode 100644 src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt create mode 100644 src/main/kotlin/eventDemo/plugins/CommandHandler.kt rename src/main/kotlin/eventDemo/{app/AggregateId.kt => shared/GameId.kt} (83%) create mode 100644 src/main/kotlin/eventDemo/shared/command/GameCommandStream.kt create mode 100644 src/main/kotlin/eventDemo/shared/entity/Card.kt create mode 100644 src/main/kotlin/eventDemo/shared/entity/Game.kt create mode 100644 src/main/kotlin/eventDemo/shared/event/CardIsPlayedEvent.kt create mode 100644 src/main/kotlin/eventDemo/shared/event/GameEventStream.kt delete mode 100644 src/test/kotlin/eventDemo/app/actions/CommandTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 77559a2..4cbb236 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,15 +3,16 @@ @Suppress("ktlint:standard:property-naming") val ktor_version: String by project val kotlin_version: String by project +val kotlin_serialization_version: String by project val logback_version: String by project val koin_version: String by project val kotlin_logging_version: String by project val kotest_version: String by project plugins { - kotlin("jvm") version "1.9.22" + kotlin("jvm") version "2.1.10" id("io.ktor.plugin") version "2.3.8" - id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22" + id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10" id("org.jlleitschuh.gradle.ktlint") version "12.1.0" } @@ -52,8 +53,9 @@ dependencies { implementation("io.insert-koin:koin-ktor:$koin_version") implementation("io.insert-koin:koin-logger-slf4j:$koin_version") implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version") testImplementation("io.ktor:ktor-server-tests-jvm") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") - testImplementation("io.ktor:ktor-server-test-host-jvm:2.3.8") + testImplementation("io.ktor:ktor-server-test-host-jvm:2.3.11") testImplementation("io.kotest:kotest-runner-junit5:$kotest_version") } diff --git a/gradle.properties b/gradle.properties index e722697..aff205b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,13 @@ -ktor_version=2.3.8 -kotlin_version=1.9.22 -logback_version=1.4.14 -koin_version=3.5.3 +ktor_version=2.3.13 +#ktor_version=3.0.3 +kotlin_version=2.1.10 +kotlin_serialization_version=1.8.0 +logback_version=1.5.16 +koin_version=3.5.6 +# koin_version=4.0.2 kotlin_logging_version=5.1.0 -kotest_version=5.8.0 +#kotlin_logging_version=7.0.4 +kotest_version=5.9.1 +#kotest_version=6.0.0 kotlin.code.style=official diff --git a/resources/openapi/documentation.yaml b/resources/openapi/documentation.yaml index 57933a5..c5fcd6c 100644 --- a/resources/openapi/documentation.yaml +++ b/resources/openapi/documentation.yaml @@ -5,4 +5,58 @@ info: version: "1.0.0" servers: - url: "https://event_demo" -paths: {} \ No newline at end of file +paths: + "/game/{id}/card/last": + get: + description: get the last card played + responses: + 200: + description: The last card + content: + application/json: + schema: + $ref: "#/components/schemas/Card" + +components: + schemas: + Card: + oneOf: + - $ref: "#/components/schemas/SimpleCard" + - $ref: "#/components/schemas/ReverseCard" + - $ref: "#/components/schemas/PassCard" + - $ref: "#/components/schemas/Plus2Card" + - $ref: "#/components/schemas/Plus4Card" + - $ref: "#/components/schemas/ChangeColorCard" + SimpleCard: + properties: + number: + type: integer + color: + $ref: "#/components/schemas/CardColor" + ReverseCard: + properties: + color: + $ref: "#/components/schemas/CardColor" + PassCard: + properties: + color: + $ref: "#/components/schemas/CardColor" + Plus2Card: + properties: + color: + $ref: "#/components/schemas/CardColor" + Plus4Card: + properties: + nextColor: + $ref: "#/components/schemas/CardColor" + ChangeColorCard: + properties: + nextColor: + $ref: "#/components/schemas/CardColor" + CardColor: + type: string + enum: + - Blue + - Red + - Yellow + - Green \ No newline at end of file diff --git a/src/main/kotlin/eventDemo/Application.kt b/src/main/kotlin/eventDemo/Application.kt index 1529de2..2ee122f 100644 --- a/src/main/kotlin/eventDemo/Application.kt +++ b/src/main/kotlin/eventDemo/Application.kt @@ -1,5 +1,6 @@ package eventDemo +import eventDemo.plugins.configureCommandHandler import eventDemo.plugins.configureHTTP import eventDemo.plugins.configureKoin import eventDemo.plugins.configureRouting @@ -22,4 +23,5 @@ fun Application.module() { configureHTTP() configureRouting() configureKoin() + configureCommandHandler() } diff --git a/src/main/kotlin/eventDemo/app/Card.kt b/src/main/kotlin/eventDemo/app/Card.kt deleted file mode 100644 index 8a4d554..0000000 --- a/src/main/kotlin/eventDemo/app/Card.kt +++ /dev/null @@ -1,39 +0,0 @@ -package eventDemo.app - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class Game( - val id: GameId, -) { - companion object { - fun new(): Game = Game(GameId()) - } -} - -@Serializable -sealed interface Card { - @Serializable - enum class Color { - Blue, - Red, - Yellow, - Green, - } - - @Serializable - @SerialName("Simple") - data class Simple( - val number: Int, - val color: Color, - ) : Card - - sealed interface Special : Card - - @Serializable - @SerialName("Reverse") - data class ReverseCard( - val color: Color, - ) : Special -} diff --git a/src/main/kotlin/eventDemo/app/CommandStream.kt b/src/main/kotlin/eventDemo/app/CommandStream.kt deleted file mode 100644 index ad25bdd..0000000 --- a/src/main/kotlin/eventDemo/app/CommandStream.kt +++ /dev/null @@ -1,24 +0,0 @@ -package eventDemo.app - -import io.github.oshai.kotlinlogging.KotlinLogging - -class CommandStream { - private val logger = KotlinLogging.logger {} - private val commandBus: MutableList = mutableListOf() - - fun sendRequest(command: Command) { - commandBus.add(command) - logger.atInfo { - message = "Command published: $command" - payload = mapOf("command" to command) - } - } - - fun sendRequest(vararg commands: Command) { - commands.forEach { sendRequest(it) } - } - - fun readNext(): Command? = commandBus.firstOrNull() - - fun readNext(commandClass: Class): U? = commandBus.filterIsInstance(commandClass).firstOrNull() -} diff --git a/src/main/kotlin/eventDemo/app/EventStream.kt b/src/main/kotlin/eventDemo/app/EventStream.kt deleted file mode 100644 index 762922a..0000000 --- a/src/main/kotlin/eventDemo/app/EventStream.kt +++ /dev/null @@ -1,27 +0,0 @@ -package eventDemo.app - -import io.github.oshai.kotlinlogging.KotlinLogging - -class EventStream { - private val logger = KotlinLogging.logger {} - private val eventBus: MutableMap>> = mutableMapOf() - - fun publish(event: Event) { - eventBus.getOrPut(event.id) { mutableListOf() }.add(event) - logger.atInfo { - message = "Event published: $event" - payload = mapOf("event" to event) - } - } - - fun publish(vararg events: Event) { - events.forEach { publish(it) } - } - - fun > read( - aggregateId: ID, - eventClass: Class, - ): U? = eventBus.get(aggregateId)?.filterIsInstance(eventClass)?.firstOrNull() -} - -inline fun , ID : AggregateId> EventStream.read(aggregateId: ID): U? = this.read(aggregateId, U::class.java) diff --git a/src/main/kotlin/eventDemo/app/Events.kt b/src/main/kotlin/eventDemo/app/Events.kt deleted file mode 100644 index 0b30b69..0000000 --- a/src/main/kotlin/eventDemo/app/Events.kt +++ /dev/null @@ -1,10 +0,0 @@ -package eventDemo.app - -sealed interface Event { - val id: ID -} - -data class PlayCardEvent( - override val id: GameId, - val card: Card, -) : Event diff --git a/src/main/kotlin/eventDemo/app/actions/Card.kt b/src/main/kotlin/eventDemo/app/actions/Card.kt deleted file mode 100644 index d3a115e..0000000 --- a/src/main/kotlin/eventDemo/app/actions/Card.kt +++ /dev/null @@ -1,61 +0,0 @@ -package eventDemo.app.actions - -import eventDemo.app.Card -import eventDemo.app.EventStream -import eventDemo.app.GameId -import eventDemo.app.PlayCardEvent -import eventDemo.app.read -import eventDemo.plugins.GameIdSerializer -import io.ktor.http.HttpStatusCode -import io.ktor.resources.Resource -import io.ktor.server.application.call -import io.ktor.server.request.receive -import io.ktor.server.resources.get -import io.ktor.server.resources.post -import io.ktor.server.response.respond -import io.ktor.server.response.respondNullable -import io.ktor.server.routing.Routing -import kotlinx.serialization.Serializable -import org.koin.ktor.ext.inject - -@Serializable -@Resource("/game/{id}") -class Game( - @Serializable(with = GameIdSerializer::class) - val id: GameId, -) { - @Serializable - @Resource("card") - class Card( - val game: Game, - ) { - @Serializable - @Resource("") - class PutCard( - val card: Card, - ) - - @Serializable - @Resource("last") - class LastCard( - val card: Card, - ) - } -} - -fun Routing.card() { - val eventStream by inject>() - - post { - val card = call.receive() - eventStream.publish(PlayCardEvent(it.card.game.id, card)) - call.respondNullable(HttpStatusCode.OK, null) - } - - get { - eventStream - .read(it.card.game.id) - ?.let { it1 -> call.respond(it1.card) } - ?: call.response.status(HttpStatusCode.BadRequest) - } -} diff --git a/src/main/kotlin/eventDemo/app/actions/Command.kt b/src/main/kotlin/eventDemo/app/actions/Command.kt deleted file mode 100644 index 1af6cd5..0000000 --- a/src/main/kotlin/eventDemo/app/actions/Command.kt +++ /dev/null @@ -1,56 +0,0 @@ -package eventDemo.app.actions - -import eventDemo.app.Command -import eventDemo.app.CommandId -import eventDemo.app.CommandStream -import eventDemo.app.PlayCardCommand -import io.ktor.http.HttpStatusCode -import io.ktor.resources.Resource -import io.ktor.server.application.call -import io.ktor.server.request.receive -import io.ktor.server.resources.get -import io.ktor.server.resources.post -import io.ktor.server.response.respond -import io.ktor.server.routing.Routing -import kotlinx.serialization.Serializable -import org.koin.ktor.ext.inject - -@Serializable -@Resource("/command") -class CommandRoute { - @Resource("send") - class Send( - val commandRoute: CommandRoute, - ) { - @Serializable - data class Response( - val id: CommandId, - ) { - constructor(command: Command) : this(command.id) - } - } - - @Resource("next") - class Next( - val commandRoute: CommandRoute, - ) -} - -fun Routing.command() { - val commandStream by inject() - - post { - val command = call.receive() - commandStream.sendRequest(command) - call.respond(HttpStatusCode.OK, CommandRoute.Send.Response(command)) - } - - get { - val command = commandStream.readNext() - if (command == null) { - call.response.status(HttpStatusCode.NoContent) - } else { - call.respond(HttpStatusCode.OK, command) - } - } -} diff --git a/src/main/kotlin/eventDemo/app/actions/playNewCard/HttpInput.kt b/src/main/kotlin/eventDemo/app/actions/playNewCard/HttpInput.kt new file mode 100644 index 0000000..54cf683 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/actions/playNewCard/HttpInput.kt @@ -0,0 +1,50 @@ +package eventDemo.app.actions.playNewCard + +import eventDemo.libs.command.send +import eventDemo.plugins.GameIdSerializer +import eventDemo.shared.GameId +import eventDemo.shared.command.GameCommandStream +import eventDemo.shared.entity.Card +import eventDemo.shared.entity.Game +import io.ktor.http.HttpStatusCode +import io.ktor.resources.Resource +import io.ktor.server.application.call +import io.ktor.server.request.receive +import io.ktor.server.resources.post +import io.ktor.server.response.respondNullable +import io.ktor.server.routing.Routing +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.koin.ktor.ext.inject + +@Serializable +@Resource("/game/{id}") +class GameRoute( + @Serializable(with = GameIdSerializer::class) + val id: GameId, +) { + @Serializable + @Resource("card") + class Card( + val game: GameRoute, + ) +} + +fun Routing.playNewCard() { + val commandStream by inject() + + /* + * A player request to play a new card. + * + * It always returns [HttpStatusCode.OK], but it is not mean that card is already played! + */ + post { + val card = call.receive() + launch(Dispatchers.Default) { + commandStream.send(PlayCardCommand(Game(it.game.id), card)) + } + + call.respondNullable(HttpStatusCode.OK, null) + } +} diff --git a/src/main/kotlin/eventDemo/app/Command.kt b/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommand.kt similarity index 52% rename from src/main/kotlin/eventDemo/app/Command.kt rename to src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommand.kt index e47b487..888be79 100644 --- a/src/main/kotlin/eventDemo/app/Command.kt +++ b/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommand.kt @@ -1,25 +1,11 @@ -package eventDemo.app +package eventDemo.app.actions.playNewCard -import eventDemo.plugins.CommandIdSerializer +import eventDemo.libs.command.Command +import eventDemo.libs.command.CommandId +import eventDemo.shared.entity.Card +import eventDemo.shared.entity.Game import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.util.UUID - -@JvmInline -@Serializable(with = CommandIdSerializer::class) -value class CommandId( - private val id: UUID = UUID.randomUUID(), -) { - constructor(id: String) : this(UUID.fromString(id)) - - override fun toString(): String = id.toString() -} - -@Serializable -sealed interface Command { - val id: CommandId - val name: String -} @Serializable @SerialName("PlayCard") diff --git a/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommandHandler.kt b/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommandHandler.kt new file mode 100644 index 0000000..973d97c --- /dev/null +++ b/src/main/kotlin/eventDemo/app/actions/playNewCard/PlayCardCommandHandler.kt @@ -0,0 +1,26 @@ +package eventDemo.app.actions.playNewCard + +import eventDemo.shared.command.GameCommandStream +import eventDemo.shared.event.CardIsPlayedEvent +import eventDemo.shared.event.GameEventStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Listen [PlayCardCommand] on [GameCommandStream], check the validity and execute the action. + * This action produces a new [CardIsPlayedEvent] + */ +class PlayCardCommandHandler( + private val commandStream: GameCommandStream, + private val eventStream: GameEventStream, +) { + operator fun invoke() { + CoroutineScope(Dispatchers.IO).launch { + commandStream.process { + // TODO check the command can be executed + eventStream.publish(CardIsPlayedEvent(it.payload.game.id, it.payload.card)) + } + } + } +} diff --git a/src/main/kotlin/eventDemo/app/actions/readLastPlayedCard/HttpInput.kt b/src/main/kotlin/eventDemo/app/actions/readLastPlayedCard/HttpInput.kt new file mode 100644 index 0000000..7fa1297 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/actions/readLastPlayedCard/HttpInput.kt @@ -0,0 +1,42 @@ +package eventDemo.app.actions.readLastPlayedCard + +import eventDemo.libs.event.readLastOf +import eventDemo.plugins.GameIdSerializer +import eventDemo.shared.GameId +import eventDemo.shared.event.CardIsPlayedEvent +import eventDemo.shared.event.GameEventStream +import io.ktor.http.HttpStatusCode +import io.ktor.resources.Resource +import io.ktor.server.application.call +import io.ktor.server.resources.get +import io.ktor.server.response.respond +import io.ktor.server.routing.Routing +import kotlinx.serialization.Serializable +import org.koin.ktor.ext.inject + +@Serializable +@Resource("/game/{id}") +class Game( + @Serializable(with = GameIdSerializer::class) + val id: GameId, +) { + @Serializable + @Resource("card/last") + class Card( + val game: Game, + ) +} + +fun Routing.readLastPlayedCard() { + val eventStream by inject() + + /* + * Read the last played card on the game. + */ + get { card -> + eventStream + .readLastOf(card.game.id) + ?.let { call.respond(it.card) } + ?: call.response.status(HttpStatusCode.BadRequest) + } +} diff --git a/src/main/kotlin/eventDemo/libs/command/Command.kt b/src/main/kotlin/eventDemo/libs/command/Command.kt new file mode 100644 index 0000000..600255c --- /dev/null +++ b/src/main/kotlin/eventDemo/libs/command/Command.kt @@ -0,0 +1,20 @@ +package eventDemo.libs.command + +import eventDemo.plugins.CommandIdSerializer +import kotlinx.serialization.Serializable +import java.util.UUID + +@JvmInline +@Serializable(with = CommandIdSerializer::class) +value class CommandId( + private val id: UUID = UUID.randomUUID(), +) { + constructor(id: String) : this(UUID.fromString(id)) + + override fun toString(): String = id.toString() +} + +interface Command { + val id: CommandId + val name: String +} diff --git a/src/main/kotlin/eventDemo/libs/command/CommandStream.kt b/src/main/kotlin/eventDemo/libs/command/CommandStream.kt new file mode 100644 index 0000000..13f6137 --- /dev/null +++ b/src/main/kotlin/eventDemo/libs/command/CommandStream.kt @@ -0,0 +1,36 @@ +package eventDemo.libs.command + +import kotlin.reflect.KClass + +interface CommandStream { + /** + * Send a new [Command] to the queue. + */ + suspend fun send( + type: KClass, + command: C, + ) + + /** + * Send multiple [Command] to the queue. + */ + suspend fun send( + type: KClass, + vararg commands: C, + ) { + commands.forEach { send(type, it) } + } + + /** + * A class to implement succes/faild action. + */ + interface ComputeStatus { + fun ack() + + fun nack() + } + + suspend fun process(block: CommandBlock) +} + +suspend inline fun CommandStream.send(vararg command: C) = send(C::class, *command) diff --git a/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt b/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt new file mode 100644 index 0000000..42e0216 --- /dev/null +++ b/src/main/kotlin/eventDemo/libs/command/CommandStreamInMemory.kt @@ -0,0 +1,82 @@ +package eventDemo.libs.command + +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlin.reflect.KClass + +typealias CommandBlock = CommandStream.ComputeStatus.(C) -> Unit + +/** + * Manage [Command]'s + * + * It stores the new [Command] in memory. + */ +abstract class CommandStreamInMemory : CommandStream { + private val logger = KotlinLogging.logger {} + private val failedCommand = mutableListOf() + private val queue: Channel = Channel(onUndeliveredElement = { logger.atWarn { "${it.name} elem not send" } }) + + /** + * Send a new [Command] to the queue. + */ + override suspend fun send( + type: KClass, + command: C, + ) { + logger.atInfo { + message = "Command published: $command" + payload = mapOf("command" to command) + } + queue.send(command) + } + + override suspend fun process(block: CommandBlock) { + queue.consumeEach { command -> + compute(command, block) + } + for (command in queue) { + compute(command, block) + } + } + + private fun compute( + command: C, + block: CommandBlock, + ) { + val status = object : CommandStream.ComputeStatus { + var isSet: Boolean = false + + override fun ack() { + if (!isSet) markAsSuccess(command) else error("Already NACK") + isSet = true + } + + override fun nack() { + if (!isSet) markAsFailed(command) else error("Already ACK") + isSet = true + } + } + + if (runCatching { status.block(command) }.isFailure) { + markAsFailed(command) + } else if (!status.isSet) { + status.ack() + } + } + + private fun markAsSuccess(command: C) { + logger.atInfo { + message = "Compute command SUCCESS and it removed of the stack : $command" + payload = mapOf("command" to command) + } + } + + private fun markAsFailed(command: C) { + failedCommand.add(command) + logger.atWarn { + message = "Compute command FAILD and it put on the top of the stack : $command" + payload = mapOf("command" to command) + } + } +} diff --git a/src/main/kotlin/eventDemo/libs/event/Event.kt b/src/main/kotlin/eventDemo/libs/event/Event.kt new file mode 100644 index 0000000..ee5915a --- /dev/null +++ b/src/main/kotlin/eventDemo/libs/event/Event.kt @@ -0,0 +1,11 @@ +package eventDemo.libs.event + +import java.util.UUID + +interface AggregateId { + val id: UUID +} + +interface Event { + val id: ID +} diff --git a/src/main/kotlin/eventDemo/libs/event/EventStream.kt b/src/main/kotlin/eventDemo/libs/event/EventStream.kt new file mode 100644 index 0000000..3c8f3ac --- /dev/null +++ b/src/main/kotlin/eventDemo/libs/event/EventStream.kt @@ -0,0 +1,19 @@ +package eventDemo.libs.event + +import kotlinx.coroutines.flow.Flow +import kotlin.reflect.KClass + +interface EventStream, ID : AggregateId> { + fun publish(event: E) + + fun publish(vararg events: E) + + fun readLast(aggregateId: ID): E? + + fun readLastOf( + aggregateId: ID, + eventType: KClass, + ): E? + + fun readAll(aggregateId: ID): Flow +} diff --git a/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt b/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt new file mode 100644 index 0000000..dcca5f7 --- /dev/null +++ b/src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt @@ -0,0 +1,43 @@ +package eventDemo.libs.event + +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.reflect.KClass + +abstract class EventStreamInMemory, ID : AggregateId>( + private val eventType: Class, +) : EventStream { + private val logger = KotlinLogging.logger {} + private val eventBus: MutableList = mutableListOf() + + override fun publish(event: E) { + eventBus.add(event) + logger.atInfo { + message = "Event published: $event" + payload = mapOf("event" to event) + } + } + + override fun publish(vararg events: E) { + events.forEach { publish(it) } + } + + override fun readLast(aggregateId: ID): E? = eventBus.lastOrNull() + + override fun readLastOf( + aggregateId: ID, + eventType: KClass, + ): R? = + eventBus + .filterIsInstance(eventType.java) + .lastOrNull { it.id == aggregateId } + + override fun readAll(aggregateId: ID): Flow = + flow { + eventBus.forEach { emit(it) } + } +} + +inline fun , ID : AggregateId> EventStreamInMemory.readLastOf(aggregateId: ID): R? = + readLastOf(aggregateId, R::class) diff --git a/src/main/kotlin/eventDemo/plugins/CommandHandler.kt b/src/main/kotlin/eventDemo/plugins/CommandHandler.kt new file mode 100644 index 0000000..c17311e --- /dev/null +++ b/src/main/kotlin/eventDemo/plugins/CommandHandler.kt @@ -0,0 +1,9 @@ +package eventDemo.plugins + +import eventDemo.app.actions.playNewCard.PlayCardCommandHandler +import io.ktor.server.application.Application +import org.koin.java.KoinJavaComponent.getKoin + +fun Application.configureCommandHandler() { + getKoin().get()() +} diff --git a/src/main/kotlin/eventDemo/plugins/Koin.kt b/src/main/kotlin/eventDemo/plugins/Koin.kt index a2bdb2a..7a370c1 100644 --- a/src/main/kotlin/eventDemo/plugins/Koin.kt +++ b/src/main/kotlin/eventDemo/plugins/Koin.kt @@ -1,8 +1,8 @@ package eventDemo.plugins -import eventDemo.app.CommandStream -import eventDemo.app.EventStream -import eventDemo.app.GameId +import eventDemo.app.actions.playNewCard.PlayCardCommandHandler +import eventDemo.shared.command.GameCommandStream +import eventDemo.shared.event.GameEventStream import io.ktor.server.application.Application import io.ktor.server.application.install import org.koin.core.module.dsl.singleOf @@ -19,6 +19,7 @@ fun Application.configureKoin() { val appModule = module { - singleOf>(::EventStream) - singleOf(::CommandStream) + singleOf(::GameEventStream) + singleOf(::GameCommandStream) + singleOf(::PlayCardCommandHandler) } diff --git a/src/main/kotlin/eventDemo/plugins/Routing.kt b/src/main/kotlin/eventDemo/plugins/Routing.kt index 396c579..f650435 100644 --- a/src/main/kotlin/eventDemo/plugins/Routing.kt +++ b/src/main/kotlin/eventDemo/plugins/Routing.kt @@ -1,7 +1,7 @@ package eventDemo.plugins -import eventDemo.app.actions.card -import eventDemo.app.actions.command +import eventDemo.app.actions.playNewCard.playNewCard +import eventDemo.app.actions.readLastPlayedCard.readLastPlayedCard import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.application.install @@ -24,7 +24,7 @@ fun Application.configureRouting() { } routing { - card() - command() + playNewCard() + readLastPlayedCard() } } diff --git a/src/main/kotlin/eventDemo/plugins/Serialization.kt b/src/main/kotlin/eventDemo/plugins/Serialization.kt index 1ab656f..e4c4fe3 100644 --- a/src/main/kotlin/eventDemo/plugins/Serialization.kt +++ b/src/main/kotlin/eventDemo/plugins/Serialization.kt @@ -1,7 +1,7 @@ package eventDemo.plugins -import eventDemo.app.CommandId -import eventDemo.app.GameId +import eventDemo.libs.command.CommandId +import eventDemo.shared.GameId import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application import io.ktor.server.application.install diff --git a/src/main/kotlin/eventDemo/app/AggregateId.kt b/src/main/kotlin/eventDemo/shared/GameId.kt similarity index 83% rename from src/main/kotlin/eventDemo/app/AggregateId.kt rename to src/main/kotlin/eventDemo/shared/GameId.kt index f23dae1..488cf8c 100644 --- a/src/main/kotlin/eventDemo/app/AggregateId.kt +++ b/src/main/kotlin/eventDemo/shared/GameId.kt @@ -1,13 +1,10 @@ -package eventDemo.app +package eventDemo.shared +import eventDemo.libs.event.AggregateId import eventDemo.plugins.GameIdSerializer import kotlinx.serialization.Serializable import java.util.UUID -sealed interface AggregateId { - val id: UUID -} - @JvmInline @Serializable(with = GameIdSerializer::class) value class GameId( diff --git a/src/main/kotlin/eventDemo/shared/command/GameCommandStream.kt b/src/main/kotlin/eventDemo/shared/command/GameCommandStream.kt new file mode 100644 index 0000000..7919f83 --- /dev/null +++ b/src/main/kotlin/eventDemo/shared/command/GameCommandStream.kt @@ -0,0 +1,6 @@ +package eventDemo.shared.command + +import eventDemo.app.actions.playNewCard.PlayCardCommand +import eventDemo.libs.command.CommandStreamInMemory + +class GameCommandStream : CommandStreamInMemory() diff --git a/src/main/kotlin/eventDemo/shared/entity/Card.kt b/src/main/kotlin/eventDemo/shared/entity/Card.kt new file mode 100644 index 0000000..30669eb --- /dev/null +++ b/src/main/kotlin/eventDemo/shared/entity/Card.kt @@ -0,0 +1,54 @@ +package eventDemo.shared.entity + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface Card { + @Serializable + enum class Color { + Blue, + Red, + Yellow, + Green, + } + + @Serializable + @SerialName("Simple") + data class NumericCard( + val number: Int, + val color: Color, + ) : Card + + sealed interface Special : Card + + @Serializable + @SerialName("Reverse") + data class ReverseCard( + val color: Color, + ) : Special + + @Serializable + @SerialName("Pass") + data class PassCard( + val color: Color, + ) : Special + + @Serializable + @SerialName("Plus2") + data class Plus2Card( + val color: Color, + ) : Special + + @Serializable + @SerialName("Plus4") + data class Plus4Card( + val nextColor: Color, + ) : Special + + @Serializable + @SerialName("ChangeColor") + data class ChangeColorCard( + val nextColor: Color, + ) : Special +} diff --git a/src/main/kotlin/eventDemo/shared/entity/Game.kt b/src/main/kotlin/eventDemo/shared/entity/Game.kt new file mode 100644 index 0000000..be90f2f --- /dev/null +++ b/src/main/kotlin/eventDemo/shared/entity/Game.kt @@ -0,0 +1,13 @@ +package eventDemo.shared.entity + +import eventDemo.shared.GameId +import kotlinx.serialization.Serializable + +@Serializable +data class Game( + val id: GameId, +) { + companion object { + fun new(): Game = Game(GameId()) + } +} diff --git a/src/main/kotlin/eventDemo/shared/event/CardIsPlayedEvent.kt b/src/main/kotlin/eventDemo/shared/event/CardIsPlayedEvent.kt new file mode 100644 index 0000000..3583431 --- /dev/null +++ b/src/main/kotlin/eventDemo/shared/event/CardIsPlayedEvent.kt @@ -0,0 +1,14 @@ +package eventDemo.shared.event + +import eventDemo.libs.event.Event +import eventDemo.shared.GameId +import eventDemo.shared.entity.Card + +sealed interface GameEvent : Event { + override val id: GameId +} + +data class CardIsPlayedEvent( + override val id: GameId, + val card: Card, +) : GameEvent diff --git a/src/main/kotlin/eventDemo/shared/event/GameEventStream.kt b/src/main/kotlin/eventDemo/shared/event/GameEventStream.kt new file mode 100644 index 0000000..faa4f82 --- /dev/null +++ b/src/main/kotlin/eventDemo/shared/event/GameEventStream.kt @@ -0,0 +1,6 @@ +package eventDemo.shared.event + +import eventDemo.libs.event.EventStreamInMemory +import eventDemo.shared.GameId + +class GameEventStream : EventStreamInMemory(GameEvent::class.java) diff --git a/src/test/kotlin/eventDemo/app/actions/CardTest.kt b/src/test/kotlin/eventDemo/app/actions/CardTest.kt index aa83ac7..415a405 100644 --- a/src/test/kotlin/eventDemo/app/actions/CardTest.kt +++ b/src/test/kotlin/eventDemo/app/actions/CardTest.kt @@ -1,11 +1,10 @@ package eventDemo.app.actions -import eventDemo.app.Card -import eventDemo.app.EventStream -import eventDemo.app.GameId -import eventDemo.app.PlayCardEvent -import eventDemo.app.read import eventDemo.module +import eventDemo.shared.GameId +import eventDemo.shared.entity.Card +import eventDemo.shared.event.CardIsPlayedEvent +import eventDemo.shared.event.GameEventStream import io.kotest.core.spec.style.FunSpec import io.ktor.client.call.body import io.ktor.client.request.accept @@ -30,8 +29,9 @@ class CardTest : stopKoin() module() } + val id = GameId() - val card: Card = Card.Simple(1, Card.Color.Blue) + val card: Card = Card.NumericCard(1, Card.Color.Blue) httpClient() .post("/game/$id/card") { contentType(Json) @@ -40,8 +40,8 @@ class CardTest : }.apply { assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - val eventStream = getKoin().get>() - assertEquals(PlayCardEvent(id, card), eventStream.read(id)) + val eventStream = getKoin().get() + assertEquals(CardIsPlayedEvent(id, card), eventStream.readLast(id)) } } } @@ -49,20 +49,22 @@ class CardTest : test("/game/{id}/card/last") { testApplication { val id = GameId() - val card: Card = Card.Simple(1, Card.Color.Blue) + val card: Card = Card.NumericCard(1, Card.Color.Blue) application { stopKoin() module() - val eventStream by inject>() + val eventStream by inject() eventStream.publish( - PlayCardEvent(GameId(), Card.Simple(2, Card.Color.Yellow)), - PlayCardEvent(id, card), + CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow)), + CardIsPlayedEvent(id, card), + // Other game + CardIsPlayedEvent(GameId(), Card.NumericCard(2, Card.Color.Yellow)), ) } httpClient().get("/game/$id/card/last").apply { assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - assertEquals(card, this.call.body()) + assertEquals(card, call.body()) } } } diff --git a/src/test/kotlin/eventDemo/app/actions/CommandTest.kt b/src/test/kotlin/eventDemo/app/actions/CommandTest.kt deleted file mode 100644 index 4d8cbd3..0000000 --- a/src/test/kotlin/eventDemo/app/actions/CommandTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package eventDemo.app.actions - -import eventDemo.app.Card -import eventDemo.app.Command -import eventDemo.app.CommandStream -import eventDemo.app.Game -import eventDemo.app.PlayCardCommand -import eventDemo.module -import io.kotest.core.spec.style.FunSpec -import io.ktor.client.call.body -import io.ktor.client.request.accept -import io.ktor.client.request.get -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType.Application.Json -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.server.testing.testApplication -import org.koin.core.context.stopKoin -import org.koin.java.KoinJavaComponent.getKoin -import org.koin.ktor.ext.inject -import kotlin.test.assertEquals - -class CommandTest : - FunSpec({ - test("/command/send") { - testApplication { - val client = httpClient() - application { - stopKoin() - module() - } - val command = PlayCardCommand(Game.new(), Card.Simple(1, Card.Color.Blue)) - client - .post("/command/send") { - contentType(Json) - accept(Json) - setBody(command) - }.apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - - val commandStream = getKoin().get() - assertEquals(command, commandStream.readNext()) - } - } - } - - test("/command/next") { - testApplication { - val command = - PlayCardCommand( - Game.new(), - Card.Simple(1, Card.Color.Blue), - ) - application { - stopKoin() - module() - - val commandStream by inject() - commandStream.sendRequest(command) - } - - httpClient().get("/command/next").apply { - assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) - assertEquals(command, this.call.body()) - } - } - } - })