Create route to list all the games

This commit is contained in:
2025-03-17 22:36:08 +01:00
parent 8074881d57
commit c3155da23c
14 changed files with 304 additions and 18 deletions

View File

@@ -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<GameList> =
projectionsSnapshot.getList()
}

View File

@@ -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<Games> {
val gameList = gameListRepository.getList()
call.respond(gameList)
}
}
}

View File

@@ -13,7 +13,7 @@ import io.ktor.server.routing.Route
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@Resource("/game/{id}") @Resource("/games/{id}")
class Game( class Game(
@Serializable(with = GameIdSerializer::class) @Serializable(with = GameIdSerializer::class)
val id: GameId, val id: GameId,

View File

@@ -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<Player> = emptySet(),
val winners: Set<Player> = emptySet(),
) : Projection<GameId> {
enum class Status {
OPENING,
IS_STARTED,
FINISH,
CANCELED,
}
}

View File

@@ -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,
)

View File

@@ -0,0 +1,5 @@
package eventDemo.business.event.projection.gameList
interface GameListRepository {
fun getList(): List<GameList>
}

View File

@@ -2,9 +2,11 @@ package eventDemo.configuration.injection
import eventDemo.adapter.infrastructureLayer.event.GameEventBusInMemory import eventDemo.adapter.infrastructureLayer.event.GameEventBusInMemory
import eventDemo.adapter.infrastructureLayer.event.GameEventStoreInMemory import eventDemo.adapter.infrastructureLayer.event.GameEventStoreInMemory
import eventDemo.adapter.infrastructureLayer.event.projection.GameListRepositoryInMemory
import eventDemo.adapter.infrastructureLayer.event.projection.GameStateRepositoryInMemory import eventDemo.adapter.infrastructureLayer.event.projection.GameStateRepositoryInMemory
import eventDemo.business.event.GameEventBus import eventDemo.business.event.GameEventBus
import eventDemo.business.event.GameEventStore import eventDemo.business.event.GameEventStore
import eventDemo.business.event.projection.gameList.GameListRepository
import eventDemo.business.event.projection.gameState.GameStateRepository import eventDemo.business.event.projection.gameState.GameStateRepository
import eventDemo.libs.event.projection.SnapshotConfig import eventDemo.libs.event.projection.SnapshotConfig
import org.koin.core.module.Module import org.koin.core.module.Module
@@ -22,4 +24,8 @@ fun Module.configureDIInfrastructure() {
single { single {
GameStateRepositoryInMemory(get(), get(), snapshotConfig = SnapshotConfig()) GameStateRepositoryInMemory(get(), get(), snapshotConfig = SnapshotConfig())
} bind GameStateRepository::class } bind GameStateRepository::class
single {
GameListRepositoryInMemory(get(), get(), snapshotConfig = SnapshotConfig())
} bind GameListRepository::class
} }

View File

@@ -1,5 +1,6 @@
package eventDemo.configuration.route package eventDemo.configuration.route
import eventDemo.adapter.interfaceLayer.readGamesList
import eventDemo.adapter.interfaceLayer.readTheGameState import eventDemo.adapter.interfaceLayer.readTheGameState
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
@@ -8,5 +9,6 @@ import org.koin.ktor.ext.get
fun Application.declareHttpGameRoute() { fun Application.declareHttpGameRoute() {
routing { routing {
readTheGameState(get()) readTheGameState(get())
readGamesList(get())
} }
} }

View File

@@ -42,7 +42,7 @@ class ProjectionSnapshotRepositoryInMemory<E : Event<ID>, P : Projection<ID>, ID
* 6. remove old one * 6. remove old one
*/ */
fun applyAndPutToCache(event: E) { fun applyAndPutToCache(event: E) {
if ((event.version % snapshotCacheConfig.modulo) == 0) { if ((event.version % snapshotCacheConfig.modulo) == 1) {
getUntil(event) getUntil(event)
.also { .also {
save(it) save(it)
@@ -51,6 +51,14 @@ class ProjectionSnapshotRepositoryInMemory<E : Event<ID>, P : Projection<ID>, ID
} }
} }
/**
* Build the list of all [Projections][Projection]
*/
fun getList(): List<P> =
projectionsSnapshot.map { (id, b) ->
getLast(id)
}
/** /**
* Build the last version of the [Projection] from the cache. * Build the last version of the [Projection] from the cache.
* *

View File

@@ -1,4 +1,4 @@
package eventDemo package eventDemo.app
import eventDemo.business.entity.Card import eventDemo.business.entity.Card
import eventDemo.business.entity.Deck import eventDemo.business.entity.Deck
@@ -8,5 +8,3 @@ fun Deck.allCardCount(): Int =
fun Deck.allCards(): Set<Card> = fun Deck.allCards(): Set<Card> =
stack + discard + playersHands.values.flatten() stack + discard + playersHands.values.flatten()
// suspend fun SendChannel<Frame>.send(command: GameCommand) = send(Frame.Text(Json.encodeToString(command)))

View File

@@ -1,7 +1,7 @@
package eventDemo.app.entity package eventDemo.app.entity
import eventDemo.allCardCount import eventDemo.app.allCardCount
import eventDemo.allCards import eventDemo.app.allCards
import eventDemo.business.entity.Deck import eventDemo.business.entity.Deck
import eventDemo.business.entity.Player import eventDemo.business.entity.Player
import eventDemo.business.entity.initHands import eventDemo.business.entity.initHands

View File

@@ -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")}")
}

View File

@@ -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<List<GameList>>()
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<GameEventHandler>()
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<List<GameList>>().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<GameEventHandler>()
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<List<GameList>>().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
}
}
}
}
})

View File

@@ -11,15 +11,12 @@ import eventDemo.business.event.event.PlayerReadyEvent
import eventDemo.business.event.projection.gameState.GameState import eventDemo.business.event.projection.gameState.GameState
import eventDemo.business.event.projection.gameState.GameStateRepository import eventDemo.business.event.projection.gameState.GameStateRepository
import eventDemo.configuration.configure import eventDemo.configuration.configure
import eventDemo.configuration.ktor.makeJwt
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.equals.shouldBeEqual
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.accept import io.ktor.client.request.accept
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -34,7 +31,7 @@ import kotlin.test.assertNotNull
class GameStateRouteTest : class GameStateRouteTest :
FunSpec({ FunSpec({
test("/game/{id}/state on empty game") { test("/games/{id}/state on empty game") {
testApplication { testApplication {
val id = GameId() val id = GameId()
val player1 = Player(name = "Nikola") val player1 = Player(name = "Nikola")
@@ -44,7 +41,7 @@ class GameStateRouteTest :
} }
httpClient() httpClient()
.get("/game/$id/state") { .get("/games/$id/state") {
withAuth(player1) withAuth(player1)
accept(ContentType.Application.Json) accept(ContentType.Application.Json)
}.apply { }.apply {
@@ -57,7 +54,7 @@ class GameStateRouteTest :
} }
} }
test("/game/{id}/card/last") { test("/games/{id}/card/last") {
testApplication { testApplication {
val gameId = GameId() val gameId = GameId()
val player1 = Player(name = "Nikola") val player1 = Player(name = "Nikola")
@@ -105,7 +102,7 @@ class GameStateRouteTest :
} }
httpClient() httpClient()
.get("/game/$gameId/card/last") { .get("/games/$gameId/card/last") {
withAuth(player1) withAuth(player1)
accept(ContentType.Application.Json) accept(ContentType.Application.Json)
}.apply { }.apply {
@@ -115,7 +112,3 @@ class GameStateRouteTest :
} }
} }
}) })
private fun HttpRequestBuilder.withAuth(player: Player) {
header("Authorization", "Bearer ${player.makeJwt("secret")}")
}