From c3155da23c76398e37c8b550e75e9e15e4f40209 Mon Sep 17 00:00:00 2001 From: Fabrice Lecomte Date: Mon, 17 Mar 2025 22:36:08 +0100 Subject: [PATCH] Create route to list all the games --- .../projection/GameListRepositoryInMemory.kt | 39 ++++++ .../adapter/interfaceLayer/GameList.kt | 28 ++++ .../interfaceLayer/ReadTheGameState.kt | 2 +- .../event/projection/gameList/GameList.kt | 22 ++++ .../projection/gameList/GameListBuilder.kt | 51 +++++++ .../projection/gameList/GameListRepository.kt | 5 + .../injection/ConfigureDIInfrastructure.kt | 6 + .../configuration/route/DeclareHttpRoutes.kt | 2 + .../ProjectionSnapshotRepositoryInMemory.kt | 10 +- .../kotlin/eventDemo/{ => app}/Helpers.kt | 4 +- .../kotlin/eventDemo/app/entity/DeckTest.kt | 4 +- .../kotlin/eventDemo/app/query/AuthHelper.kt | 10 ++ .../eventDemo/app/query/GameListRouteTest.kt | 124 ++++++++++++++++++ .../eventDemo/app/query/GameStateRouteTest.kt | 15 +-- 14 files changed, 304 insertions(+), 18 deletions(-) create mode 100644 src/main/kotlin/eventDemo/adapter/infrastructureLayer/event/projection/GameListRepositoryInMemory.kt create mode 100644 src/main/kotlin/eventDemo/adapter/interfaceLayer/GameList.kt create mode 100644 src/main/kotlin/eventDemo/business/event/projection/gameList/GameList.kt create mode 100644 src/main/kotlin/eventDemo/business/event/projection/gameList/GameListBuilder.kt create mode 100644 src/main/kotlin/eventDemo/business/event/projection/gameList/GameListRepository.kt rename src/test/kotlin/eventDemo/{ => app}/Helpers.kt (66%) create mode 100644 src/test/kotlin/eventDemo/app/query/AuthHelper.kt create mode 100644 src/test/kotlin/eventDemo/app/query/GameListRouteTest.kt diff --git a/src/main/kotlin/eventDemo/adapter/infrastructureLayer/event/projection/GameListRepositoryInMemory.kt b/src/main/kotlin/eventDemo/adapter/infrastructureLayer/event/projection/GameListRepositoryInMemory.kt new file mode 100644 index 0000000..a68ec36 --- /dev/null +++ b/src/main/kotlin/eventDemo/adapter/infrastructureLayer/event/projection/GameListRepositoryInMemory.kt @@ -0,0 +1,39 @@ +package eventDemo.adapter.infrastructureLayer.event.projection + +import eventDemo.business.entity.GameId +import eventDemo.business.event.GameEventHandler +import eventDemo.business.event.GameEventStore +import eventDemo.business.event.projection.gameList.GameList +import eventDemo.business.event.projection.gameList.GameListRepository +import eventDemo.business.event.projection.gameList.apply +import eventDemo.business.event.projection.gameState.GameState +import eventDemo.libs.event.projection.ProjectionSnapshotRepositoryInMemory +import eventDemo.libs.event.projection.SnapshotConfig + +class GameListRepositoryInMemory( + eventStore: GameEventStore, + eventHandler: GameEventHandler, + snapshotConfig: SnapshotConfig = SnapshotConfig(), +) : GameListRepository { + private val projectionsSnapshot = + ProjectionSnapshotRepositoryInMemory( + eventStore = eventStore, + snapshotCacheConfig = snapshotConfig, + applyToProjection = GameList::apply, + initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) }, + ) + + init { + eventHandler.registerProjectionBuilder { event -> + projectionsSnapshot.applyAndPutToCache(event) + } + } + + /** + * Get the last version of the [GameState] from the all eventStream. + * + * It fetches it from the local cache if possible, otherwise it builds it. + */ + override fun getList(): List = + projectionsSnapshot.getList() +} diff --git a/src/main/kotlin/eventDemo/adapter/interfaceLayer/GameList.kt b/src/main/kotlin/eventDemo/adapter/interfaceLayer/GameList.kt new file mode 100644 index 0000000..81365ee --- /dev/null +++ b/src/main/kotlin/eventDemo/adapter/interfaceLayer/GameList.kt @@ -0,0 +1,28 @@ +package eventDemo.adapter.interfaceLayer + +import eventDemo.business.event.projection.gameList.GameListRepository +import io.ktor.resources.Resource +import io.ktor.server.application.call +import io.ktor.server.auth.authenticate +import io.ktor.server.resources.get +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import kotlinx.serialization.Serializable + +@Serializable +@Resource("/games") +class Games + +/** + * API routes to show all games. + */ +fun Route.readGamesList(gameListRepository: GameListRepository) { + authenticate { + // Read the last played card on the game. + get { + val gameList = gameListRepository.getList() + call.respond(gameList) + } + } +} diff --git a/src/main/kotlin/eventDemo/adapter/interfaceLayer/ReadTheGameState.kt b/src/main/kotlin/eventDemo/adapter/interfaceLayer/ReadTheGameState.kt index d901c4e..f1edcf4 100644 --- a/src/main/kotlin/eventDemo/adapter/interfaceLayer/ReadTheGameState.kt +++ b/src/main/kotlin/eventDemo/adapter/interfaceLayer/ReadTheGameState.kt @@ -13,7 +13,7 @@ import io.ktor.server.routing.Route import kotlinx.serialization.Serializable @Serializable -@Resource("/game/{id}") +@Resource("/games/{id}") class Game( @Serializable(with = GameIdSerializer::class) val id: GameId, diff --git a/src/main/kotlin/eventDemo/business/event/projection/gameList/GameList.kt b/src/main/kotlin/eventDemo/business/event/projection/gameList/GameList.kt new file mode 100644 index 0000000..913c5e6 --- /dev/null +++ b/src/main/kotlin/eventDemo/business/event/projection/gameList/GameList.kt @@ -0,0 +1,22 @@ +package eventDemo.business.event.projection.gameList + +import eventDemo.business.entity.GameId +import eventDemo.business.entity.Player +import eventDemo.business.event.projection.Projection +import kotlinx.serialization.Serializable + +@Serializable +data class GameList( + override val aggregateId: GameId, + override val lastEventVersion: Int = 0, + val status: Status = Status.OPENING, + val players: Set = emptySet(), + val winners: Set = emptySet(), +) : Projection { + enum class Status { + OPENING, + IS_STARTED, + FINISH, + CANCELED, + } +} diff --git a/src/main/kotlin/eventDemo/business/event/projection/gameList/GameListBuilder.kt b/src/main/kotlin/eventDemo/business/event/projection/gameList/GameListBuilder.kt new file mode 100644 index 0000000..4fa18a7 --- /dev/null +++ b/src/main/kotlin/eventDemo/business/event/projection/gameList/GameListBuilder.kt @@ -0,0 +1,51 @@ +package eventDemo.business.event.projection.gameList + +import eventDemo.business.event.event.CardIsPlayedEvent +import eventDemo.business.event.event.GameEvent +import eventDemo.business.event.event.GameStartedEvent +import eventDemo.business.event.event.NewPlayerEvent +import eventDemo.business.event.event.PlayerChoseColorEvent +import eventDemo.business.event.event.PlayerHavePassEvent +import eventDemo.business.event.event.PlayerReadyEvent +import eventDemo.business.event.event.PlayerWinEvent + +fun GameList.apply(event: GameEvent): GameList = + when (event) { + is NewPlayerEvent -> { + copy( + players = players + event.player, + status = GameList.Status.OPENING, + ) + } + + is GameStartedEvent -> { + copy( + status = GameList.Status.IS_STARTED, + ) + } + + is PlayerWinEvent -> { + copy( + winners = winners + event.player, + status = GameList.Status.FINISH, + ) + } + + is CardIsPlayedEvent -> { + this + } + + is PlayerChoseColorEvent -> { + this + } + + is PlayerHavePassEvent -> { + this + } + + is PlayerReadyEvent -> { + this + } + }.copy( + lastEventVersion = event.version, + ) diff --git a/src/main/kotlin/eventDemo/business/event/projection/gameList/GameListRepository.kt b/src/main/kotlin/eventDemo/business/event/projection/gameList/GameListRepository.kt new file mode 100644 index 0000000..f2440e3 --- /dev/null +++ b/src/main/kotlin/eventDemo/business/event/projection/gameList/GameListRepository.kt @@ -0,0 +1,5 @@ +package eventDemo.business.event.projection.gameList + +interface GameListRepository { + fun getList(): List +} diff --git a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt index f7e615f..80b4c2b 100644 --- a/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt +++ b/src/main/kotlin/eventDemo/configuration/injection/ConfigureDIInfrastructure.kt @@ -2,9 +2,11 @@ package eventDemo.configuration.injection import eventDemo.adapter.infrastructureLayer.event.GameEventBusInMemory import eventDemo.adapter.infrastructureLayer.event.GameEventStoreInMemory +import eventDemo.adapter.infrastructureLayer.event.projection.GameListRepositoryInMemory import eventDemo.adapter.infrastructureLayer.event.projection.GameStateRepositoryInMemory import eventDemo.business.event.GameEventBus import eventDemo.business.event.GameEventStore +import eventDemo.business.event.projection.gameList.GameListRepository import eventDemo.business.event.projection.gameState.GameStateRepository import eventDemo.libs.event.projection.SnapshotConfig import org.koin.core.module.Module @@ -22,4 +24,8 @@ fun Module.configureDIInfrastructure() { single { GameStateRepositoryInMemory(get(), get(), snapshotConfig = SnapshotConfig()) } bind GameStateRepository::class + + single { + GameListRepositoryInMemory(get(), get(), snapshotConfig = SnapshotConfig()) + } bind GameListRepository::class } diff --git a/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt b/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt index 94450a9..a8685eb 100644 --- a/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt +++ b/src/main/kotlin/eventDemo/configuration/route/DeclareHttpRoutes.kt @@ -1,5 +1,6 @@ package eventDemo.configuration.route +import eventDemo.adapter.interfaceLayer.readGamesList import eventDemo.adapter.interfaceLayer.readTheGameState import io.ktor.server.application.Application import io.ktor.server.routing.routing @@ -8,5 +9,6 @@ import org.koin.ktor.ext.get fun Application.declareHttpGameRoute() { routing { readTheGameState(get()) + readGamesList(get()) } } diff --git a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionSnapshotRepositoryInMemory.kt b/src/main/kotlin/eventDemo/libs/event/projection/ProjectionSnapshotRepositoryInMemory.kt index fd7d2d8..d8f3eb0 100644 --- a/src/main/kotlin/eventDemo/libs/event/projection/ProjectionSnapshotRepositoryInMemory.kt +++ b/src/main/kotlin/eventDemo/libs/event/projection/ProjectionSnapshotRepositoryInMemory.kt @@ -42,7 +42,7 @@ class ProjectionSnapshotRepositoryInMemory, P : Projection, ID * 6. remove old one */ fun applyAndPutToCache(event: E) { - if ((event.version % snapshotCacheConfig.modulo) == 0) { + if ((event.version % snapshotCacheConfig.modulo) == 1) { getUntil(event) .also { save(it) @@ -51,6 +51,14 @@ class ProjectionSnapshotRepositoryInMemory, P : Projection, ID } } + /** + * Build the list of all [Projections][Projection] + */ + fun getList(): List

= + projectionsSnapshot.map { (id, b) -> + getLast(id) + } + /** * Build the last version of the [Projection] from the cache. * diff --git a/src/test/kotlin/eventDemo/Helpers.kt b/src/test/kotlin/eventDemo/app/Helpers.kt similarity index 66% rename from src/test/kotlin/eventDemo/Helpers.kt rename to src/test/kotlin/eventDemo/app/Helpers.kt index efd577d..2571474 100644 --- a/src/test/kotlin/eventDemo/Helpers.kt +++ b/src/test/kotlin/eventDemo/app/Helpers.kt @@ -1,4 +1,4 @@ -package eventDemo +package eventDemo.app import eventDemo.business.entity.Card import eventDemo.business.entity.Deck @@ -8,5 +8,3 @@ fun Deck.allCardCount(): Int = fun Deck.allCards(): Set = stack + discard + playersHands.values.flatten() - -// suspend fun SendChannel.send(command: GameCommand) = send(Frame.Text(Json.encodeToString(command))) diff --git a/src/test/kotlin/eventDemo/app/entity/DeckTest.kt b/src/test/kotlin/eventDemo/app/entity/DeckTest.kt index 45ba901..1d3ba15 100644 --- a/src/test/kotlin/eventDemo/app/entity/DeckTest.kt +++ b/src/test/kotlin/eventDemo/app/entity/DeckTest.kt @@ -1,7 +1,7 @@ package eventDemo.app.entity -import eventDemo.allCardCount -import eventDemo.allCards +import eventDemo.app.allCardCount +import eventDemo.app.allCards import eventDemo.business.entity.Deck import eventDemo.business.entity.Player import eventDemo.business.entity.initHands diff --git a/src/test/kotlin/eventDemo/app/query/AuthHelper.kt b/src/test/kotlin/eventDemo/app/query/AuthHelper.kt new file mode 100644 index 0000000..d41f641 --- /dev/null +++ b/src/test/kotlin/eventDemo/app/query/AuthHelper.kt @@ -0,0 +1,10 @@ +package eventDemo.app.query + +import eventDemo.business.entity.Player +import eventDemo.configuration.ktor.makeJwt +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header + +internal fun HttpRequestBuilder.withAuth(player: Player) { + header("Authorization", "Bearer ${player.makeJwt("secret")}") +} diff --git a/src/test/kotlin/eventDemo/app/query/GameListRouteTest.kt b/src/test/kotlin/eventDemo/app/query/GameListRouteTest.kt new file mode 100644 index 0000000..c2848da --- /dev/null +++ b/src/test/kotlin/eventDemo/app/query/GameListRouteTest.kt @@ -0,0 +1,124 @@ +package eventDemo.app.query + +import eventDemo.business.entity.GameId +import eventDemo.business.entity.Player +import eventDemo.business.event.GameEventHandler +import eventDemo.business.event.event.GameStartedEvent +import eventDemo.business.event.event.NewPlayerEvent +import eventDemo.business.event.event.PlayerReadyEvent +import eventDemo.business.event.projection.gameList.GameList +import eventDemo.configuration.configure +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.equals.shouldBeEqual +import io.ktor.client.call.body +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.testApplication +import kotlinx.coroutines.runBlocking +import org.koin.core.context.stopKoin +import org.koin.ktor.ext.inject +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GameListRouteTest : + FunSpec({ + test("/games with no game started") { + testApplication { + val player1 = Player(name = "Nikola") + application { + stopKoin() + configure() + } + + httpClient() + .get("/games") { + withAuth(player1) + accept(ContentType.Application.Json) + }.apply { + assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) + val list = call.body>() + assertTrue(list.isEmpty()) + } + } + } + + test("/games return a game with status OPENING") { + testApplication { + val gameId = GameId() + val player1 = Player(name = "Nikola") + + application { + stopKoin() + configure() + + val eventHandler by inject() + runBlocking { + eventHandler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } + } + } + + httpClient() + .get("/games") { + withAuth(player1) + accept(ContentType.Application.Json) + }.apply { + assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) + call.body>().first().let { + it.status shouldBeEqual GameList.Status.OPENING + it.players shouldHaveSize 1 + it.players shouldContain player1 + it.winners shouldHaveSize 0 + } + } + } + } + + test("/games return a game with status IS_STARTED") { + testApplication { + val gameId = GameId() + val player1 = Player(name = "Nikola") + val player2 = Player(name = "Einstein") + + application { + stopKoin() + configure() + + val eventHandler by inject() + runBlocking { + eventHandler.handle(gameId) { NewPlayerEvent(gameId, player1, it) } + eventHandler.handle(gameId) { NewPlayerEvent(gameId, player2, it) } + eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player1, it) } + eventHandler.handle(gameId) { PlayerReadyEvent(gameId, player2, it) } + eventHandler.handle(gameId) { + GameStartedEvent.new( + gameId, + setOf(player1, player2), + it, + shuffleIsDisabled = true, + ) + } + } + } + + httpClient() + .get("/games") { + withAuth(player1) + accept(ContentType.Application.Json) + }.apply { + assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) + call.body>().first().let { + it.status shouldBeEqual GameList.Status.IS_STARTED + it.players shouldHaveSize 2 + it.players shouldContain player1 + it.players shouldContain player2 + it.winners shouldHaveSize 0 + } + } + } + } + }) diff --git a/src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt b/src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt index 40ae241..850434a 100644 --- a/src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt +++ b/src/test/kotlin/eventDemo/app/query/GameStateRouteTest.kt @@ -11,15 +11,12 @@ import eventDemo.business.event.event.PlayerReadyEvent import eventDemo.business.event.projection.gameState.GameState import eventDemo.business.event.projection.gameState.GameStateRepository import eventDemo.configuration.configure -import eventDemo.configuration.ktor.makeJwt import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.equals.shouldBeEqual import io.ktor.client.call.body -import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.accept import io.ktor.client.request.get -import io.ktor.client.request.header import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode @@ -34,7 +31,7 @@ import kotlin.test.assertNotNull class GameStateRouteTest : FunSpec({ - test("/game/{id}/state on empty game") { + test("/games/{id}/state on empty game") { testApplication { val id = GameId() val player1 = Player(name = "Nikola") @@ -44,7 +41,7 @@ class GameStateRouteTest : } httpClient() - .get("/game/$id/state") { + .get("/games/$id/state") { withAuth(player1) accept(ContentType.Application.Json) }.apply { @@ -57,7 +54,7 @@ class GameStateRouteTest : } } - test("/game/{id}/card/last") { + test("/games/{id}/card/last") { testApplication { val gameId = GameId() val player1 = Player(name = "Nikola") @@ -105,7 +102,7 @@ class GameStateRouteTest : } httpClient() - .get("/game/$gameId/card/last") { + .get("/games/$gameId/card/last") { withAuth(player1) accept(ContentType.Application.Json) }.apply { @@ -115,7 +112,3 @@ class GameStateRouteTest : } } }) - -private fun HttpRequestBuilder.withAuth(player: Player) { - header("Authorization", "Bearer ${player.makeJwt("secret")}") -}