diff --git a/src/main/kotlin/eventDemo/app/AggregateId.kt b/src/main/kotlin/eventDemo/app/AggregateId.kt index db61976..43c19ce 100644 --- a/src/main/kotlin/eventDemo/app/AggregateId.kt +++ b/src/main/kotlin/eventDemo/app/AggregateId.kt @@ -1,5 +1,7 @@ package eventDemo.app +import eventDemo.plugins.GameIdSerializer +import kotlinx.serialization.Serializable import java.util.UUID sealed interface AggregateId { @@ -7,6 +9,7 @@ sealed interface AggregateId { } @JvmInline +@Serializable(with = GameIdSerializer::class) value class GameId(override val id: UUID = UUID.randomUUID()) : AggregateId { constructor(id: String) : this(UUID.fromString(id)) diff --git a/src/main/kotlin/eventDemo/app/Card.kt b/src/main/kotlin/eventDemo/app/Card.kt index a703cf8..4fa8f6d 100644 --- a/src/main/kotlin/eventDemo/app/Card.kt +++ b/src/main/kotlin/eventDemo/app/Card.kt @@ -3,6 +3,17 @@ package eventDemo.app import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +data class Game( + val id: GameId, +) { + companion object { + fun new(): Game { + return Game(GameId()) + } + } +} + @Serializable sealed interface Card { @Serializable diff --git a/src/main/kotlin/eventDemo/app/Command.kt b/src/main/kotlin/eventDemo/app/Command.kt new file mode 100644 index 0000000..2ba3849 --- /dev/null +++ b/src/main/kotlin/eventDemo/app/Command.kt @@ -0,0 +1,40 @@ +package eventDemo.app + +import eventDemo.plugins.CommandIdSerializer +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") +data class PlayCardCommand( + val payload: Payload, +) : Command { + constructor( + game: Game, + card: Card, + ) : this(Payload(game, card)) + + override val name: String = "PlayCard" + override val id: CommandId = CommandId() + + @Serializable + data class Payload( + val game: Game, + val card: Card, + ) +} diff --git a/src/main/kotlin/eventDemo/app/CommandStream.kt b/src/main/kotlin/eventDemo/app/CommandStream.kt new file mode 100644 index 0000000..012b26e --- /dev/null +++ b/src/main/kotlin/eventDemo/app/CommandStream.kt @@ -0,0 +1,28 @@ +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? { + return commandBus.firstOrNull() + } + + fun readNext(commandClass: Class): U? { + return commandBus.filterIsInstance(commandClass).firstOrNull() + } +} diff --git a/src/main/kotlin/eventDemo/app/actions/PutCard.kt b/src/main/kotlin/eventDemo/app/actions/Card.kt similarity index 96% rename from src/main/kotlin/eventDemo/app/actions/PutCard.kt rename to src/main/kotlin/eventDemo/app/actions/Card.kt index 8063b03..5b87364 100644 --- a/src/main/kotlin/eventDemo/app/actions/PutCard.kt +++ b/src/main/kotlin/eventDemo/app/actions/Card.kt @@ -41,7 +41,7 @@ fun Routing.card() { val eventStream by inject>() post { - val card = call.receive() + val card = call.receive() eventStream.publish(PlayCardEvent(it.card.game.id, card)) call.respondNullable(HttpStatusCode.OK, null) } diff --git a/src/main/kotlin/eventDemo/app/actions/Command.kt b/src/main/kotlin/eventDemo/app/actions/Command.kt new file mode 100644 index 0000000..ba1539b --- /dev/null +++ b/src/main/kotlin/eventDemo/app/actions/Command.kt @@ -0,0 +1,52 @@ +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/plugins/Koin.kt b/src/main/kotlin/eventDemo/plugins/Koin.kt index 2d946bc..a2bdb2a 100644 --- a/src/main/kotlin/eventDemo/plugins/Koin.kt +++ b/src/main/kotlin/eventDemo/plugins/Koin.kt @@ -1,5 +1,6 @@ package eventDemo.plugins +import eventDemo.app.CommandStream import eventDemo.app.EventStream import eventDemo.app.GameId import io.ktor.server.application.Application @@ -19,4 +20,5 @@ fun Application.configureKoin() { val appModule = module { singleOf>(::EventStream) + singleOf(::CommandStream) } diff --git a/src/main/kotlin/eventDemo/plugins/Routing.kt b/src/main/kotlin/eventDemo/plugins/Routing.kt index 2adb606..396c579 100644 --- a/src/main/kotlin/eventDemo/plugins/Routing.kt +++ b/src/main/kotlin/eventDemo/plugins/Routing.kt @@ -1,6 +1,7 @@ package eventDemo.plugins import eventDemo.app.actions.card +import eventDemo.app.actions.command import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.application.install @@ -9,7 +10,6 @@ import io.ktor.server.plugins.statuspages.StatusPages import io.ktor.server.resources.Resources import io.ktor.server.response.respondText import io.ktor.server.routing.routing -import io.ktor.util.converters.DataConversion.Configuration fun Application.configureRouting() { install(AutoHeadResponse) @@ -25,5 +25,6 @@ fun Application.configureRouting() { routing { card() + command() } } diff --git a/src/main/kotlin/eventDemo/plugins/Serialization.kt b/src/main/kotlin/eventDemo/plugins/Serialization.kt index d946bb4..1ab656f 100644 --- a/src/main/kotlin/eventDemo/plugins/Serialization.kt +++ b/src/main/kotlin/eventDemo/plugins/Serialization.kt @@ -1,5 +1,6 @@ package eventDemo.plugins +import eventDemo.app.CommandId import eventDemo.app.GameId import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application @@ -28,6 +29,19 @@ fun Application.configureSerialization() { } } +object CommandIdSerializer : KSerializer { + override fun deserialize(decoder: Decoder): CommandId = CommandId(decoder.decodeString()) + + override fun serialize( + encoder: Encoder, + value: CommandId, + ) { + encoder.encodeString(value.toString()) + } + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CommandId", PrimitiveKind.STRING) +} + object GameIdSerializer : KSerializer { override fun deserialize(decoder: Decoder): GameId = GameId(UUID.fromString(decoder.decodeString())) diff --git a/src/test/kotlin/eventDemo/app/actions/CardTest.kt b/src/test/kotlin/eventDemo/app/actions/CardTest.kt index 5cb3931..cd49d0f 100644 --- a/src/test/kotlin/eventDemo/app/actions/CardTest.kt +++ b/src/test/kotlin/eventDemo/app/actions/CardTest.kt @@ -4,6 +4,7 @@ import eventDemo.app.Card import eventDemo.app.EventStream import eventDemo.app.GameId import eventDemo.app.PlayCardEvent +import eventDemo.app.read import eventDemo.module import io.kotest.core.spec.style.FunSpec import io.ktor.client.call.body @@ -11,11 +12,13 @@ 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 @@ -27,13 +30,17 @@ class CardTest : FunSpec({ stopKoin() module() } - val id = GameId().toString() + val id = GameId() + val card: Card = Card.Simple(1, Card.Color.Blue) client.post("/game/$id/card") { contentType(Json) accept(Json) - setBody(Card.Simple(1, Card.Color.Blue)) + setBody(card) }.apply { - assertEquals(status, HttpStatusCode.OK) + assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) + + val eventStream = getKoin().get>() + assertEquals(PlayCardEvent(id, card), eventStream.read(id)) } } } @@ -42,7 +49,7 @@ class CardTest : FunSpec({ testApplication { val client = httpClient() val id = GameId() - val card = Card.Simple(1, Card.Color.Blue) + val card: Card = Card.Simple(1, Card.Color.Blue) application { stopKoin() module() @@ -54,7 +61,7 @@ class CardTest : FunSpec({ } client.get("/game/$id/card/last").apply { - assertEquals(HttpStatusCode.OK, status) + assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) assertEquals(card, this.call.body()) } } diff --git a/src/test/kotlin/eventDemo/app/actions/CommandTest.kt b/src/test/kotlin/eventDemo/app/actions/CommandTest.kt new file mode 100644 index 0000000..b285c04 --- /dev/null +++ b/src/test/kotlin/eventDemo/app/actions/CommandTest.kt @@ -0,0 +1,68 @@ +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()) + } + } + } +}) diff --git a/src/test/kotlin/eventDemo/app/actions/Client.kt b/src/test/kotlin/eventDemo/app/actions/TestHttpClient.kt similarity index 100% rename from src/test/kotlin/eventDemo/app/actions/Client.kt rename to src/test/kotlin/eventDemo/app/actions/TestHttpClient.kt