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
@Serializable
@Resource("/game/{id}")
@Resource("/games/{id}")
class Game(
@Serializable(with = GameIdSerializer::class)
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.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
}

View File

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

View File

@@ -42,7 +42,7 @@ class ProjectionSnapshotRepositoryInMemory<E : Event<ID>, P : Projection<ID>, 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<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.
*

View File

@@ -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<Card> =
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
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

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