Refactor
This commit is contained in:
@@ -3,15 +3,16 @@
|
|||||||
@Suppress("ktlint:standard:property-naming")
|
@Suppress("ktlint:standard:property-naming")
|
||||||
val ktor_version: String by project
|
val ktor_version: String by project
|
||||||
val kotlin_version: String by project
|
val kotlin_version: String by project
|
||||||
|
val kotlin_serialization_version: String by project
|
||||||
val logback_version: String by project
|
val logback_version: String by project
|
||||||
val koin_version: String by project
|
val koin_version: String by project
|
||||||
val kotlin_logging_version: String by project
|
val kotlin_logging_version: String by project
|
||||||
val kotest_version: String by project
|
val kotest_version: String by project
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm") version "1.9.22"
|
kotlin("jvm") version "2.1.10"
|
||||||
id("io.ktor.plugin") version "2.3.8"
|
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"
|
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-ktor:$koin_version")
|
||||||
implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
|
implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
|
||||||
implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_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("io.ktor:ktor-server-tests-jvm")
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
|
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")
|
testImplementation("io.kotest:kotest-runner-junit5:$kotest_version")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
ktor_version=2.3.8
|
ktor_version=2.3.13
|
||||||
kotlin_version=1.9.22
|
#ktor_version=3.0.3
|
||||||
logback_version=1.4.14
|
kotlin_version=2.1.10
|
||||||
koin_version=3.5.3
|
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
|
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
|
kotlin.code.style=official
|
||||||
|
|||||||
@@ -5,4 +5,58 @@ info:
|
|||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
servers:
|
servers:
|
||||||
- url: "https://event_demo"
|
- url: "https://event_demo"
|
||||||
paths: {}
|
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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package eventDemo
|
package eventDemo
|
||||||
|
|
||||||
|
import eventDemo.plugins.configureCommandHandler
|
||||||
import eventDemo.plugins.configureHTTP
|
import eventDemo.plugins.configureHTTP
|
||||||
import eventDemo.plugins.configureKoin
|
import eventDemo.plugins.configureKoin
|
||||||
import eventDemo.plugins.configureRouting
|
import eventDemo.plugins.configureRouting
|
||||||
@@ -22,4 +23,5 @@ fun Application.module() {
|
|||||||
configureHTTP()
|
configureHTTP()
|
||||||
configureRouting()
|
configureRouting()
|
||||||
configureKoin()
|
configureKoin()
|
||||||
|
configureCommandHandler()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package eventDemo.app
|
|
||||||
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
|
|
||||||
class CommandStream {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
|
||||||
private val commandBus: MutableList<Command> = 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 <U : Command> readNext(commandClass: Class<U>): U? = commandBus.filterIsInstance(commandClass).firstOrNull()
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package eventDemo.app
|
|
||||||
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
|
|
||||||
class EventStream<ID : AggregateId> {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
|
||||||
private val eventBus: MutableMap<ID, MutableList<Event<ID>>> = mutableMapOf()
|
|
||||||
|
|
||||||
fun publish(event: Event<ID>) {
|
|
||||||
eventBus.getOrPut(event.id) { mutableListOf() }.add(event)
|
|
||||||
logger.atInfo {
|
|
||||||
message = "Event published: $event"
|
|
||||||
payload = mapOf("event" to event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun publish(vararg events: Event<ID>) {
|
|
||||||
events.forEach { publish(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <U : Event<ID>> read(
|
|
||||||
aggregateId: ID,
|
|
||||||
eventClass: Class<U>,
|
|
||||||
): U? = eventBus.get(aggregateId)?.filterIsInstance(eventClass)?.firstOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <reified U : Event<ID>, ID : AggregateId> EventStream<ID>.read(aggregateId: ID): U? = this.read(aggregateId, U::class.java)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package eventDemo.app
|
|
||||||
|
|
||||||
sealed interface Event<ID : AggregateId> {
|
|
||||||
val id: ID
|
|
||||||
}
|
|
||||||
|
|
||||||
data class PlayCardEvent(
|
|
||||||
override val id: GameId,
|
|
||||||
val card: Card,
|
|
||||||
) : Event<GameId>
|
|
||||||
@@ -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<EventStream<GameId>>()
|
|
||||||
|
|
||||||
post<Game.Card.PutCard> {
|
|
||||||
val card = call.receive<Card>()
|
|
||||||
eventStream.publish(PlayCardEvent(it.card.game.id, card))
|
|
||||||
call.respondNullable<Any?>(HttpStatusCode.OK, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
get<Game.Card.LastCard> {
|
|
||||||
eventStream
|
|
||||||
.read<PlayCardEvent, GameId>(it.card.game.id)
|
|
||||||
?.let { it1 -> call.respond<Card>(it1.card) }
|
|
||||||
?: call.response.status(HttpStatusCode.BadRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<CommandStream>()
|
|
||||||
|
|
||||||
post<CommandRoute.Send> {
|
|
||||||
val command = call.receive<PlayCardCommand>()
|
|
||||||
commandStream.sendRequest(command)
|
|
||||||
call.respond(HttpStatusCode.OK, CommandRoute.Send.Response(command))
|
|
||||||
}
|
|
||||||
|
|
||||||
get<CommandRoute.Next> {
|
|
||||||
val command = commandStream.readNext()
|
|
||||||
if (command == null) {
|
|
||||||
call.response.status(HttpStatusCode.NoContent)
|
|
||||||
} else {
|
|
||||||
call.respond(HttpStatusCode.OK, command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<GameCommandStream>()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A player request to play a new card.
|
||||||
|
*
|
||||||
|
* It always returns [HttpStatusCode.OK], but it is not mean that card is already played!
|
||||||
|
*/
|
||||||
|
post<GameRoute.Card> {
|
||||||
|
val card = call.receive<Card>()
|
||||||
|
launch(Dispatchers.Default) {
|
||||||
|
commandStream.send(PlayCardCommand(Game(it.game.id), card))
|
||||||
|
}
|
||||||
|
|
||||||
|
call.respondNullable<Any?>(HttpStatusCode.OK, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
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
|
@Serializable
|
||||||
@SerialName("PlayCard")
|
@SerialName("PlayCard")
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GameEventStream>()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Read the last played card on the game.
|
||||||
|
*/
|
||||||
|
get<Game.Card> { card ->
|
||||||
|
eventStream
|
||||||
|
.readLastOf<CardIsPlayedEvent, _, _>(card.game.id)
|
||||||
|
?.let { call.respond(it.card) }
|
||||||
|
?: call.response.status(HttpStatusCode.BadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/main/kotlin/eventDemo/libs/command/Command.kt
Normal file
20
src/main/kotlin/eventDemo/libs/command/Command.kt
Normal file
@@ -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
|
||||||
|
}
|
||||||
36
src/main/kotlin/eventDemo/libs/command/CommandStream.kt
Normal file
36
src/main/kotlin/eventDemo/libs/command/CommandStream.kt
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package eventDemo.libs.command
|
||||||
|
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
interface CommandStream<C : Command> {
|
||||||
|
/**
|
||||||
|
* Send a new [Command] to the queue.
|
||||||
|
*/
|
||||||
|
suspend fun send(
|
||||||
|
type: KClass<C>,
|
||||||
|
command: C,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send multiple [Command] to the queue.
|
||||||
|
*/
|
||||||
|
suspend fun send(
|
||||||
|
type: KClass<C>,
|
||||||
|
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<C>)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <reified C : Command> CommandStream<C>.send(vararg command: C) = send(C::class, *command)
|
||||||
@@ -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<C> = CommandStream.ComputeStatus.(C) -> Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage [Command]'s
|
||||||
|
*
|
||||||
|
* It stores the new [Command] in memory.
|
||||||
|
*/
|
||||||
|
abstract class CommandStreamInMemory<C : Command> : CommandStream<C> {
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
private val failedCommand = mutableListOf<Command>()
|
||||||
|
private val queue: Channel<C> = Channel(onUndeliveredElement = { logger.atWarn { "${it.name} elem not send" } })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a new [Command] to the queue.
|
||||||
|
*/
|
||||||
|
override suspend fun send(
|
||||||
|
type: KClass<C>,
|
||||||
|
command: C,
|
||||||
|
) {
|
||||||
|
logger.atInfo {
|
||||||
|
message = "Command published: $command"
|
||||||
|
payload = mapOf("command" to command)
|
||||||
|
}
|
||||||
|
queue.send(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun process(block: CommandBlock<C>) {
|
||||||
|
queue.consumeEach { command ->
|
||||||
|
compute(command, block)
|
||||||
|
}
|
||||||
|
for (command in queue) {
|
||||||
|
compute(command, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compute(
|
||||||
|
command: C,
|
||||||
|
block: CommandBlock<C>,
|
||||||
|
) {
|
||||||
|
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 <C : Command> markAsSuccess(command: C) {
|
||||||
|
logger.atInfo {
|
||||||
|
message = "Compute command SUCCESS and it removed of the stack : $command"
|
||||||
|
payload = mapOf("command" to command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <C : Command> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/main/kotlin/eventDemo/libs/event/Event.kt
Normal file
11
src/main/kotlin/eventDemo/libs/event/Event.kt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package eventDemo.libs.event
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface AggregateId {
|
||||||
|
val id: UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Event<ID : AggregateId> {
|
||||||
|
val id: ID
|
||||||
|
}
|
||||||
19
src/main/kotlin/eventDemo/libs/event/EventStream.kt
Normal file
19
src/main/kotlin/eventDemo/libs/event/EventStream.kt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package eventDemo.libs.event
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
interface EventStream<E : Event<ID>, ID : AggregateId> {
|
||||||
|
fun publish(event: E)
|
||||||
|
|
||||||
|
fun publish(vararg events: E)
|
||||||
|
|
||||||
|
fun readLast(aggregateId: ID): E?
|
||||||
|
|
||||||
|
fun <R : E> readLastOf(
|
||||||
|
aggregateId: ID,
|
||||||
|
eventType: KClass<out R>,
|
||||||
|
): E?
|
||||||
|
|
||||||
|
fun readAll(aggregateId: ID): Flow<E>
|
||||||
|
}
|
||||||
43
src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt
Normal file
43
src/main/kotlin/eventDemo/libs/event/EventStreamInMemory.kt
Normal file
@@ -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<E : Event<ID>, ID : AggregateId>(
|
||||||
|
private val eventType: Class<E>,
|
||||||
|
) : EventStream<E, ID> {
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
private val eventBus: MutableList<E> = 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 <R : E> readLastOf(
|
||||||
|
aggregateId: ID,
|
||||||
|
eventType: KClass<out R>,
|
||||||
|
): R? =
|
||||||
|
eventBus
|
||||||
|
.filterIsInstance(eventType.java)
|
||||||
|
.lastOrNull { it.id == aggregateId }
|
||||||
|
|
||||||
|
override fun readAll(aggregateId: ID): Flow<E> =
|
||||||
|
flow {
|
||||||
|
eventBus.forEach { emit(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified R : E, E : Event<ID>, ID : AggregateId> EventStreamInMemory<E, ID>.readLastOf(aggregateId: ID): R? =
|
||||||
|
readLastOf(aggregateId, R::class)
|
||||||
9
src/main/kotlin/eventDemo/plugins/CommandHandler.kt
Normal file
9
src/main/kotlin/eventDemo/plugins/CommandHandler.kt
Normal file
@@ -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<PlayCardCommandHandler>()()
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package eventDemo.plugins
|
package eventDemo.plugins
|
||||||
|
|
||||||
import eventDemo.app.CommandStream
|
import eventDemo.app.actions.playNewCard.PlayCardCommandHandler
|
||||||
import eventDemo.app.EventStream
|
import eventDemo.shared.command.GameCommandStream
|
||||||
import eventDemo.app.GameId
|
import eventDemo.shared.event.GameEventStream
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.install
|
import io.ktor.server.application.install
|
||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
@@ -19,6 +19,7 @@ fun Application.configureKoin() {
|
|||||||
|
|
||||||
val appModule =
|
val appModule =
|
||||||
module {
|
module {
|
||||||
singleOf<EventStream<GameId>>(::EventStream)
|
singleOf<GameEventStream>(::GameEventStream)
|
||||||
singleOf<CommandStream>(::CommandStream)
|
singleOf<GameCommandStream>(::GameCommandStream)
|
||||||
|
singleOf(::PlayCardCommandHandler)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package eventDemo.plugins
|
package eventDemo.plugins
|
||||||
|
|
||||||
import eventDemo.app.actions.card
|
import eventDemo.app.actions.playNewCard.playNewCard
|
||||||
import eventDemo.app.actions.command
|
import eventDemo.app.actions.readLastPlayedCard.readLastPlayedCard
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.install
|
import io.ktor.server.application.install
|
||||||
@@ -24,7 +24,7 @@ fun Application.configureRouting() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
routing {
|
routing {
|
||||||
card()
|
playNewCard()
|
||||||
command()
|
readLastPlayedCard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package eventDemo.plugins
|
package eventDemo.plugins
|
||||||
|
|
||||||
import eventDemo.app.CommandId
|
import eventDemo.libs.command.CommandId
|
||||||
import eventDemo.app.GameId
|
import eventDemo.shared.GameId
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.install
|
import io.ktor.server.application.install
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package eventDemo.app
|
package eventDemo.shared
|
||||||
|
|
||||||
|
import eventDemo.libs.event.AggregateId
|
||||||
import eventDemo.plugins.GameIdSerializer
|
import eventDemo.plugins.GameIdSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
sealed interface AggregateId {
|
|
||||||
val id: UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmInline
|
@JvmInline
|
||||||
@Serializable(with = GameIdSerializer::class)
|
@Serializable(with = GameIdSerializer::class)
|
||||||
value class GameId(
|
value class GameId(
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package eventDemo.shared.command
|
||||||
|
|
||||||
|
import eventDemo.app.actions.playNewCard.PlayCardCommand
|
||||||
|
import eventDemo.libs.command.CommandStreamInMemory
|
||||||
|
|
||||||
|
class GameCommandStream : CommandStreamInMemory<PlayCardCommand>()
|
||||||
54
src/main/kotlin/eventDemo/shared/entity/Card.kt
Normal file
54
src/main/kotlin/eventDemo/shared/entity/Card.kt
Normal file
@@ -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
|
||||||
|
}
|
||||||
13
src/main/kotlin/eventDemo/shared/entity/Game.kt
Normal file
13
src/main/kotlin/eventDemo/shared/entity/Game.kt
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/main/kotlin/eventDemo/shared/event/CardIsPlayedEvent.kt
Normal file
14
src/main/kotlin/eventDemo/shared/event/CardIsPlayedEvent.kt
Normal file
@@ -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<GameId> {
|
||||||
|
override val id: GameId
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CardIsPlayedEvent(
|
||||||
|
override val id: GameId,
|
||||||
|
val card: Card,
|
||||||
|
) : GameEvent
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package eventDemo.shared.event
|
||||||
|
|
||||||
|
import eventDemo.libs.event.EventStreamInMemory
|
||||||
|
import eventDemo.shared.GameId
|
||||||
|
|
||||||
|
class GameEventStream : EventStreamInMemory<GameEvent, GameId>(GameEvent::class.java)
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
package eventDemo.app.actions
|
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.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.kotest.core.spec.style.FunSpec
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.accept
|
import io.ktor.client.request.accept
|
||||||
@@ -30,8 +29,9 @@ class CardTest :
|
|||||||
stopKoin()
|
stopKoin()
|
||||||
module()
|
module()
|
||||||
}
|
}
|
||||||
|
|
||||||
val id = GameId()
|
val id = GameId()
|
||||||
val card: Card = Card.Simple(1, Card.Color.Blue)
|
val card: Card = Card.NumericCard(1, Card.Color.Blue)
|
||||||
httpClient()
|
httpClient()
|
||||||
.post("/game/$id/card") {
|
.post("/game/$id/card") {
|
||||||
contentType(Json)
|
contentType(Json)
|
||||||
@@ -40,8 +40,8 @@ class CardTest :
|
|||||||
}.apply {
|
}.apply {
|
||||||
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
|
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
|
||||||
|
|
||||||
val eventStream = getKoin().get<EventStream<GameId>>()
|
val eventStream = getKoin().get<GameEventStream>()
|
||||||
assertEquals(PlayCardEvent(id, card), eventStream.read<PlayCardEvent, GameId>(id))
|
assertEquals(CardIsPlayedEvent(id, card), eventStream.readLast(id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,20 +49,22 @@ class CardTest :
|
|||||||
test("/game/{id}/card/last") {
|
test("/game/{id}/card/last") {
|
||||||
testApplication {
|
testApplication {
|
||||||
val id = GameId()
|
val id = GameId()
|
||||||
val card: Card = Card.Simple(1, Card.Color.Blue)
|
val card: Card = Card.NumericCard(1, Card.Color.Blue)
|
||||||
application {
|
application {
|
||||||
stopKoin()
|
stopKoin()
|
||||||
module()
|
module()
|
||||||
val eventStream by inject<EventStream<GameId>>()
|
val eventStream by inject<GameEventStream>()
|
||||||
eventStream.publish(
|
eventStream.publish(
|
||||||
PlayCardEvent(GameId(), Card.Simple(2, Card.Color.Yellow)),
|
CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow)),
|
||||||
PlayCardEvent(id, card),
|
CardIsPlayedEvent(id, card),
|
||||||
|
// Other game
|
||||||
|
CardIsPlayedEvent(GameId(), Card.NumericCard(2, Card.Color.Yellow)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient().get("/game/$id/card/last").apply {
|
httpClient().get("/game/$id/card/last").apply {
|
||||||
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
|
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
|
||||||
assertEquals(card, this.call.body<Card>())
|
assertEquals(card, call.body<Card>())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<CommandStream>()
|
|
||||||
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>()
|
|
||||||
commandStream.sendRequest(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
httpClient().get("/command/next").apply {
|
|
||||||
assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
|
|
||||||
assertEquals(command, this.call.body<Command>())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user