create bus and subscriber

This commit is contained in:
2025-03-04 23:02:07 +01:00
parent a6847353b2
commit f3ca94c97e
36 changed files with 885 additions and 234 deletions

View File

@@ -2,5 +2,5 @@
ktlint_code_style = ktlint_official ktlint_code_style = ktlint_official
ktlint_standard = enabled ktlint_standard = enabled
ktlint_experimental = enabled ktlint_experimental = enabled
ktlint_standard_string-template-indent = disabled ktlint_standard_string-template-indent = enabled
ktlint_standard_multiline-expression-wrapping = disabled ktlint_standard_multiline-expression-wrapping = enabled

View File

@@ -1,17 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="ApplicationKt" type="KtorApplicationConfigurationType" factoryName="Ktor" nameIsGenerated="true">
<module name="event-demo.main" />
<option name="alternativeJrePath" />
<option name="alternativeJrePathEnabled" value="false" />
<option name="includeProvidedScope" value="true" />
<option name="mainClass" value="eventDemo.ApplicationKt" />
<option name="passParentEnvs" value="true" />
<option name="programParameters" value="" />
<option name="shortenCommandLine" value="NONE" />
<option name="vmParameters" value="-Dio.ktor.development=true" />
<option name="workingDirectory" value="$PROJECT_DIR$" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@@ -13,7 +13,7 @@ plugins {
kotlin("jvm") version "2.1.10" 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 "2.1.10" 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.2.0"
} }
group = "io.github.flecomte" group = "io.github.flecomte"
@@ -26,6 +26,11 @@ application {
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
} }
configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {
version.set("1.5.0")
enableExperimentalRules.set(true)
}
repositories { repositories {
mavenCentral() mavenCentral()
} }

View File

@@ -1,19 +1,29 @@
package eventDemo package eventDemo
import eventDemo.plugins.configureHTTP import eventDemo.app.actions.GameEventReactionSubscriber
import eventDemo.plugins.configureHttp
import eventDemo.plugins.configureHttpRouting
import eventDemo.plugins.configureKoin import eventDemo.plugins.configureKoin
import eventDemo.plugins.configureRouting
import eventDemo.plugins.configureSecurity import eventDemo.plugins.configureSecurity
import eventDemo.plugins.configureSerialization import eventDemo.plugins.configureSerialization
import eventDemo.plugins.configureSockets import eventDemo.plugins.configureSockets
import eventDemo.plugins.configureWebSocketsGameRoute
import io.ktor.server.application.Application import io.ktor.server.application.Application
import org.koin.ktor.ext.get
fun Application.configure() { fun Application.configure() {
configureSecurity()
configureSerialization()
configureSockets()
configureHTTP()
configureRouting()
configureKoin() configureKoin()
configureCommandHandler()
configureSecurity()
configureSerialization()
configureSockets()
configureWebSocketsGameRoute(get(), get())
configureHttp()
configureHttpRouting()
GameEventReactionSubscriber(get(), get())
.init()
} }

View File

@@ -1,14 +0,0 @@
package eventDemo
import eventDemo.app.actions.playNewCard.PlayCardCommandHandler
import io.ktor.server.application.Application
import org.koin.java.KoinJavaComponent.getKoin
/**
* Configure the command handler for the PlayCard.
*/
fun Application.configureCommandHandler() {
getKoin()
.get<PlayCardCommandHandler>()
.init()
}

View File

@@ -0,0 +1,73 @@
package eventDemo.app.actions
import eventDemo.app.actions.playNewCard.PlayCardCommand
import eventDemo.shared.command.GameCommandStream
import eventDemo.shared.entity.Player
import eventDemo.shared.event.CardIsPlayedEvent
import eventDemo.shared.event.GameEvent
import eventDemo.shared.event.GameEventStream
import eventDemo.shared.event.GameState
import eventDemo.shared.event.buildStateFromEventStream
import io.ktor.websocket.Frame
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/**
* Listen [PlayCardCommand] on [GameCommandStream], check the validity and execute the action.
*
* This action can be executing an action and produce a new [GameEvent] after verification.
*/
class GameCommandHandler(
private val eventStream: GameEventStream,
incoming: ReceiveChannel<Frame>,
outgoing: SendChannel<Frame>,
) {
private val commandStream = GameCommandStream(incoming, outgoing)
private val playerNotifier = outgoing
/**
* Init the handler
*/
fun init(player: Player) {
CoroutineScope(Dispatchers.IO).launch {
commandStream.process {
if (it.payload.player.id != player.id) {
nack()
}
when (it) {
is PlayCardCommand -> {
// Check the command can be executed
val canBeExecuted =
it.payload.gameId
.buildStateFromEventStream(eventStream)
.commandCardCanBeExecuted(it)
if (canBeExecuted) {
eventStream.publish(
CardIsPlayedEvent(
it.payload.gameId,
it.payload.card,
it.payload.player,
),
)
} else {
runBlocking {
playerNotifier.send(Frame.Text("Command cannot be executed"))
}
}
}
}
}
}
}
}
private fun GameState.commandCardCanBeExecuted(command: PlayCardCommand): Boolean =
canBePlayThisCard(
command.payload.player,
command.payload.card,
)

View File

@@ -0,0 +1,22 @@
package eventDemo.app.actions
import eventDemo.libs.event.EventBus
import eventDemo.shared.GameId
import eventDemo.shared.event.GameEvent
import eventDemo.shared.toFrame
import io.ktor.websocket.Frame
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.runBlocking
class GameEventPlayerNotificationSubscriber(
private val eventBus: EventBus<GameEvent, GameId>,
private val outgoing: SendChannel<Frame>,
) {
fun init() {
eventBus.subscribe { event: GameEvent ->
runBlocking {
outgoing.send(event.toFrame())
}
}
}
}

View File

@@ -0,0 +1,27 @@
package eventDemo.app.actions
import eventDemo.libs.event.EventBus
import eventDemo.libs.event.EventStream
import eventDemo.shared.GameId
import eventDemo.shared.event.GameEvent
import eventDemo.shared.event.GameStartedEvent
import eventDemo.shared.event.buildStateFromEventStream
class GameEventReactionSubscriber(
private val eventBus: EventBus<GameEvent, GameId>,
private val eventStream: EventStream<GameEvent, GameId>,
) {
fun init() {
eventBus.subscribe { event: GameEvent ->
val state = event.id.buildStateFromEventStream(eventStream)
if (state.isReady) {
eventStream.publish(
GameStartedEvent.new(
state.gameId,
state.players,
),
)
}
}
}
}

View File

@@ -1,14 +1,15 @@
package eventDemo.app.actions.playNewCard package eventDemo.app.actions.playNewCard
import eventDemo.libs.command.send import eventDemo.libs.command.send
import eventDemo.plugins.GameIdSerializer
import eventDemo.shared.GameId import eventDemo.shared.GameId
import eventDemo.shared.command.GameCommandStream import eventDemo.shared.command.GameCommandStreamInMemory
import eventDemo.shared.entity.Card import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Game import eventDemo.shared.entity.Player
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource import io.ktor.resources.Resource
import io.ktor.server.application.call import io.ktor.server.application.call
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.request.receive import io.ktor.server.request.receive
import io.ktor.server.resources.post import io.ktor.server.resources.post
import io.ktor.server.response.respondNullable import io.ktor.server.response.respondNullable
@@ -16,12 +17,11 @@ import io.ktor.server.routing.Routing
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.ktor.ext.inject
@Serializable @Serializable
@Resource("/game/{id}") @Resource("/game/{id}")
class GameRoute( class GameRoute(
@Serializable(with = GameIdSerializer::class) // @Serializable(with = GameIdSerializer::class)
val id: GameId, val id: GameId,
) { ) {
@Serializable @Serializable
@@ -35,8 +35,8 @@ class GameRoute(
* API route to send a request to play card. * API route to send a request to play card.
*/ */
fun Routing.playNewCard() { fun Routing.playNewCard() {
val commandStream by inject<GameCommandStream>() val commandStream = GameCommandStreamInMemory()
authenticate {
/* /*
* A player request to play a new card. * A player request to play a new card.
* *
@@ -44,10 +44,18 @@ fun Routing.playNewCard() {
*/ */
post<GameRoute.Card> { post<GameRoute.Card> {
val card = call.receive<Card>() val card = call.receive<Card>()
val name = call.principal<Player>()!!
launch(Dispatchers.Default) { launch(Dispatchers.Default) {
commandStream.send(PlayCardCommand(Game(it.game.id), card)) commandStream.send(
PlayCardCommand(
it.game.id,
name,
card,
),
)
} }
call.respondNullable<Any?>(HttpStatusCode.OK, null) call.respondNullable<Any?>(HttpStatusCode.OK, null)
} }
}
} }

View File

@@ -2,8 +2,9 @@ package eventDemo.app.actions.playNewCard
import eventDemo.libs.command.Command import eventDemo.libs.command.Command
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Game import eventDemo.shared.entity.Player
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -13,19 +14,32 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
@SerialName("PlayCard") @SerialName("PlayCard")
data class PlayCardCommand( data class PlayCardCommand(
val payload: Payload, override val payload: Payload,
) : Command { ) : GameCommand {
constructor( constructor(
game: Game, gameId: GameId,
player: Player,
card: Card, card: Card,
) : this(Payload(game, card)) ) : this(Payload(gameId, player, card))
override val name: String = "PlayCard" override val name: String = "PlayCard"
override val id: CommandId = CommandId() override val id: CommandId = CommandId()
@Serializable @Serializable
data class Payload( data class Payload(
val game: Game, override val gameId: GameId,
override val player: Player,
val card: Card, val card: Card,
) ) : GameCommand.Payload
}
@Serializable
sealed interface GameCommand : Command {
val payload: Payload
@Serializable
sealed interface Payload {
val gameId: GameId
val player: Player
}
} }

View File

@@ -1,30 +0,0 @@
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,
) {
/**
* Init the handler
*/
fun init() {
CoroutineScope(Dispatchers.IO).launch {
commandStream.process {
// TODO check the command can be executed
eventStream.publish(CardIsPlayedEvent(it.payload.game.id, it.payload.card))
}
}
}
}

View File

@@ -0,0 +1,97 @@
package eventDemo.libs.command
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.runBlocking
import kotlin.reflect.KClass
/**
* Manage [Command]'s with kotlin Channel
*/
class CommandStreamChannel<C : Command>(
private val incoming: ReceiveChannel<Frame>,
private val outgoing: SendChannel<Frame>,
private val serializer: (C) -> String,
private val deserializer: (String) -> C,
) : CommandStream<C> {
private val logger = KotlinLogging.logger {}
private val failedCommand = mutableListOf<C>()
/**
* 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)
}
outgoing.send(Frame.Text(serializer(command)))
}
override suspend fun process(action: CommandStream.ComputeStatus.(C) -> Unit) {
// incoming.consumeEach { commandAsFrame ->
// if (commandAsFrame is Frame.Text) {
// compute(deserializer(commandAsFrame.readText()), action)
// }
// }
for (command in incoming) {
if (command is Frame.Text) {
compute(deserializer(command.readText()), action)
}
}
}
private fun compute(
command: C,
action: CommandStream.ComputeStatus.(C) -> Unit,
) {
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.action(command) }.isFailure) {
markAsFailed(command)
} else if (!status.isSet) {
status.ack()
}
}
private fun markAsSuccess(command: C) {
logger.atInfo {
message = "Compute command SUCCESS and it removed of the stack : $command"
payload = mapOf("command" to command)
}
runBlocking {
outgoing.send(Frame.Text("Command executed successfully"))
}
}
private fun markAsFailed(command: C) {
failedCommand.add(command)
logger.atWarn {
message = "Compute command FAILED and it put it ot the top of the stack : $command"
payload = mapOf("command" to command)
}
runBlocking {
outgoing.send(Frame.Text("Command execution failed"))
}
}
}

View File

@@ -44,7 +44,8 @@ abstract class CommandStreamInMemory<C : Command> : CommandStream<C> {
command: C, command: C,
action: CommandBlock<C>, action: CommandBlock<C>,
) { ) {
val status = object : CommandStream.ComputeStatus { val status =
object : CommandStream.ComputeStatus {
var isSet: Boolean = false var isSet: Boolean = false
override fun ack() { override fun ack() {

View File

@@ -0,0 +1,7 @@
package eventDemo.libs.event
interface EventBus<E : Event<ID>, ID : AggregateId> {
fun publish(event: E)
fun subscribe(block: (E) -> Unit)
}

View File

@@ -0,0 +1,15 @@
package eventDemo.libs.event
class EventBusInMemory<E : Event<ID>, ID : AggregateId> : EventBus<E, ID> {
private val subscribers: MutableList<(E) -> Unit> = mutableListOf()
override fun publish(event: E) {
subscribers.forEach {
it(event)
}
}
override fun subscribe(block: (E) -> Unit) {
subscribers.add(block)
}
}

View File

@@ -1,6 +1,5 @@
package eventDemo.libs.event package eventDemo.libs.event
import kotlinx.coroutines.flow.Flow
import kotlin.reflect.KClass import kotlin.reflect.KClass
/** /**
@@ -22,6 +21,6 @@ interface EventStream<E : Event<ID>, ID : AggregateId> {
eventType: KClass<out R>, eventType: KClass<out R>,
): E? ): E?
/** Reads all events associated with a given aggregate ID as a Flow (asynchronous stream) */ /** Reads all events associated with a given aggregate ID */
fun readAll(aggregateId: ID): Flow<E> fun readAll(aggregateId: ID): List<E>
} }

View File

@@ -1,8 +1,6 @@
package eventDemo.libs.event package eventDemo.libs.event
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.reflect.KClass import kotlin.reflect.KClass
/** /**
@@ -10,14 +8,12 @@ import kotlin.reflect.KClass
* *
* All methods are implemented. * All methods are implemented.
*/ */
abstract class EventStreamInMemory<E : Event<ID>, ID : AggregateId>( class EventStreamInMemory<E : Event<ID>, ID : AggregateId> : EventStream<E, ID> {
private val eventType: Class<E>,
) : EventStream<E, ID> {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val eventBus: MutableList<E> = mutableListOf() private val events: MutableList<E> = mutableListOf()
override fun publish(event: E) { override fun publish(event: E) {
eventBus.add(event) events.add(event)
logger.atInfo { logger.atInfo {
message = "Event published: $event" message = "Event published: $event"
payload = mapOf("event" to event) payload = mapOf("event" to event)
@@ -28,20 +24,17 @@ abstract class EventStreamInMemory<E : Event<ID>, ID : AggregateId>(
events.forEach { publish(it) } events.forEach { publish(it) }
} }
override fun readLast(aggregateId: ID): E? = eventBus.lastOrNull() override fun readLast(aggregateId: ID): E? = events.lastOrNull()
override fun <R : E> readLastOf( override fun <R : E> readLastOf(
aggregateId: ID, aggregateId: ID,
eventType: KClass<out R>, eventType: KClass<out R>,
): R? = ): R? =
eventBus events
.filterIsInstance(eventType.java) .filterIsInstance(eventType.java)
.lastOrNull { it.id == aggregateId } .lastOrNull { it.id == aggregateId }
override fun readAll(aggregateId: ID): Flow<E> = override fun readAll(aggregateId: ID): List<E> = events
flow {
eventBus.forEach { emit(it) }
}
} }
inline fun <reified R : E, E : Event<ID>, ID : AggregateId> EventStreamInMemory<E, ID>.readLastOf(aggregateId: ID): R? = inline fun <reified R : E, E : Event<ID>, ID : AggregateId> EventStreamInMemory<E, ID>.readLastOf(aggregateId: ID): R? =

View File

@@ -7,7 +7,7 @@ import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.plugins.cors.routing.CORS import io.ktor.server.plugins.cors.routing.CORS
fun Application.configureHTTP() { fun Application.configureHttp() {
install(CORS) { install(CORS) {
allowMethod(HttpMethod.Options) allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put) allowMethod(HttpMethod.Put)

View File

@@ -1,11 +1,11 @@
package eventDemo.plugins package eventDemo.plugins
import eventDemo.app.actions.playNewCard.PlayCardCommandHandler import eventDemo.libs.event.EventBusInMemory
import eventDemo.shared.command.GameCommandStream import eventDemo.libs.event.EventStreamInMemory
import eventDemo.shared.event.GameEventBus
import eventDemo.shared.event.GameEventStream 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.dsl.module import org.koin.dsl.module
import org.koin.ktor.plugin.Koin import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger import org.koin.logger.slf4jLogger
@@ -13,13 +13,16 @@ import org.koin.logger.slf4jLogger
fun Application.configureKoin() { fun Application.configureKoin() {
install(Koin) { install(Koin) {
slf4jLogger() slf4jLogger()
modules(appModule) modules(appKoinModule)
} }
} }
val appModule = val appKoinModule =
module { module {
singleOf(::GameEventStream) single {
singleOf(::GameCommandStream) GameEventStream(get(), EventStreamInMemory())
singleOf(::PlayCardCommandHandler) }
single {
GameEventBus(EventBusInMemory())
}
} }

View File

@@ -11,7 +11,7 @@ import io.ktor.server.resources.Resources
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
fun Application.configureRouting() { fun Application.configureHttpRouting() {
install(AutoHeadResponse) install(AutoHeadResponse)
install(Resources) install(Resources)
install(StatusPages) { install(StatusPages) {

View File

@@ -2,25 +2,22 @@ package eventDemo.plugins
import com.auth0.jwt.JWT import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.call import io.ktor.server.application.call
import io.ktor.server.auth.UserIdPrincipal
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.authentication import io.ktor.server.auth.authentication
import io.ktor.server.auth.basic
import io.ktor.server.auth.form
import io.ktor.server.auth.jwt.JWTPrincipal import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt import io.ktor.server.auth.jwt.jwt
import io.ktor.server.auth.principal import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import java.util.Date
fun Application.configureSecurity() { fun Application.configureSecurity() {
// Please read the jwt property from the config file if you are using EngineMain // TODO: read the jwt property from the config file
val jwtAudience = "jwt-audience" val jwtRealm = "Play card game"
val jwtDomain = "https://jwt-provider-domain/" val jwtIssuer = "PlayCardGame"
val jwtRealm = "ktor sample app"
val jwtSecret = "secret" val jwtSecret = "secret"
authentication { authentication {
jwt { jwt {
@@ -28,47 +25,35 @@ fun Application.configureSecurity() {
verifier( verifier(
JWT JWT
.require(Algorithm.HMAC256(jwtSecret)) .require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience) .withIssuer(jwtIssuer)
.withIssuer(jwtDomain)
.build(), .build(),
) )
validate { credential -> validate { credential ->
if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null if (credential.payload.getClaim("username").asString() != "") {
} JWTPrincipal(credential.payload)
}
}
authentication {
basic(name = "myauth1") {
realm = "Ktor Server"
validate { credentials ->
if (credentials.name == credentials.password) {
UserIdPrincipal(credentials.name)
} else { } else {
null null
} }
} }
challenge { defaultScheme, realm ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
}
}
} }
form(name = "myauth2") {
userParamName = "user"
passwordParamName = "password"
challenge {
//
}
}
}
routing { routing {
authenticate("myauth1") { post("login/{username}") {
get("/protected/route/basic") { val username = call.parameters["username"]
val principal = call.principal<UserIdPrincipal>()!!
call.respondText("Hello ${principal.name}") val token =
} JWT
} .create()
authenticate("myauth2") { .withIssuer(jwtIssuer)
get("/protected/route/form") { .withClaim("username", username)
val principal = call.principal<UserIdPrincipal>()!! .withExpiresAt(Date(System.currentTimeMillis() + 60000))
call.respondText("Hello ${principal.name}") .sign(Algorithm.HMAC256(jwtSecret))
}
call.respond(hashMapOf("token" to token))
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package eventDemo.plugins
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import eventDemo.shared.GameId import eventDemo.shared.GameId
import eventDemo.shared.entity.Player.PlayerId
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
@@ -42,6 +43,19 @@ object CommandIdSerializer : KSerializer<CommandId> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CommandId", PrimitiveKind.STRING) override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CommandId", PrimitiveKind.STRING)
} }
object PlayerIdSerializer : KSerializer<PlayerId> {
override fun deserialize(decoder: Decoder): PlayerId = PlayerId(UUID.fromString(decoder.decodeString()))
override fun serialize(
encoder: Encoder,
value: PlayerId,
) {
encoder.encodeString(value.id.toString())
}
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("GameId", PrimitiveKind.STRING)
}
object GameIdSerializer : KSerializer<GameId> { object GameIdSerializer : KSerializer<GameId> {
override fun deserialize(decoder: Decoder): GameId = GameId(UUID.fromString(decoder.decodeString())) override fun deserialize(decoder: Decoder): GameId = GameId(UUID.fromString(decoder.decodeString()))

View File

@@ -1,16 +1,21 @@
package eventDemo.plugins package eventDemo.plugins
import eventDemo.app.actions.GameCommandHandler
import eventDemo.app.actions.GameEventPlayerNotificationSubscriber
import eventDemo.shared.entity.Player
import eventDemo.shared.event.GameEventBus
import eventDemo.shared.event.GameEventStream
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.pingPeriod import io.ktor.server.websocket.pingPeriod
import io.ktor.server.websocket.timeout import io.ktor.server.websocket.timeout
import io.ktor.server.websocket.webSocket import io.ktor.server.websocket.webSocket
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import java.time.Duration import java.time.Duration
fun Application.configureSockets() { fun Application.configureSockets() {
@@ -20,18 +25,26 @@ fun Application.configureSockets() {
maxFrameSize = Long.MAX_VALUE maxFrameSize = Long.MAX_VALUE
masking = false masking = false
} }
}
fun Application.configureWebSocketsGameRoute(
eventStream: GameEventStream,
eventBus: GameEventBus,
) {
routing { routing {
webSocket("/ws") { authenticate {
// websocketSession webSocket("/game") {
for (frame in incoming) { GameCommandHandler(eventStream, incoming, outgoing).init(call.getPlayer())
if (frame is Frame.Text) { GameEventPlayerNotificationSubscriber(eventBus, outgoing).init()
val text = frame.readText()
outgoing.send(Frame.Text("YOU SAID: $text"))
if (text.equals("bye", ignoreCase = true)) {
close(CloseReason(CloseReason.Codes.NORMAL, "Client said BYE"))
}
}
} }
} }
} }
} }
fun ApplicationCall.getPlayer() =
principal<JWTPrincipal>()!!.run {
Player(
id = payload.getClaim("playerid").asString(),
name = payload.getClaim("username").asString(),
)
}

View File

@@ -0,0 +1,15 @@
package eventDemo.shared
import eventDemo.app.actions.playNewCard.GameCommand
import eventDemo.shared.event.GameEvent
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.serialization.json.Json
fun Frame.Text.toEvent(): GameEvent = Json.decodeFromString(GameEvent.serializer(), readText())
fun GameEvent.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameEvent.serializer(), this))
fun Frame.Text.toCommand(): GameCommand = Json.decodeFromString(GameCommand.serializer(), readText())
fun GameCommand.toFrame(): Frame.Text = Frame.Text(Json.encodeToString(GameCommand.serializer(), this))

View File

@@ -2,19 +2,14 @@ package eventDemo.shared
import eventDemo.libs.event.AggregateId import eventDemo.libs.event.AggregateId
import eventDemo.plugins.GameIdSerializer import eventDemo.plugins.GameIdSerializer
import eventDemo.shared.entity.Game
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
/** /**
* An [AggregateId] for the [Game]. * An [AggregateId] for a game.
*/ */
@JvmInline @JvmInline
@Serializable(with = GameIdSerializer::class) @Serializable(with = GameIdSerializer::class)
value class GameId( value class GameId(
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : AggregateId { ) : AggregateId
constructor(id: String) : this(UUID.fromString(id))
override fun toString(): String = id.toString()
}

View File

@@ -0,0 +1,143 @@
package eventDemo.shared.event
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Deck
import eventDemo.shared.entity.Player
data class GameState(
val gameId: GameId,
val players: Set<Player> = emptySet(),
val lastPlayer: Player? = null,
val lastCard: LastCard? = null,
val lastColor: Card.Color? = null,
val direction: Direction = Direction.CLOCKWISE,
val readyPlayers: List<Player> = emptyList(),
val deck: Deck = Deck(players.toList()),
val isStarted: Boolean = false,
) {
data class LastCard(
val card: Card,
val player: Player,
)
enum class Direction {
CLOCKWISE,
COUNTER_CLOCKWISE,
;
fun revert(): Direction =
if (this === CLOCKWISE) {
COUNTER_CLOCKWISE
} else {
CLOCKWISE
}
}
val isReady: Boolean get() {
return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) }
}
fun canBePlayThisCard(
player: Player,
card: Card,
): Boolean {
if (!isReady) return false
val cardOnGame = lastCard?.card ?: return false
return when (cardOnGame) {
is Card.NumericCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.NumericCard -> card.number == cardOnGame.number || card.color == cardOnGame.color
is Card.ColorCard -> card.color == cardOnGame.color
}
}
is Card.ReverseCard -> {
when (card) {
is Card.ReverseCard -> true
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnGame.color
}
}
is Card.PassCard -> {
if (player.cardOnBoardIsForYou) {
false
} else {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnGame.color
}
}
}
is Card.ChangeColorCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == lastColor
}
}
is Card.Plus2Card -> {
if (player.cardOnBoardIsForYou && card is Card.Plus2Card) {
true
} else {
when (card) {
is Card.AllColorCard -> true
is Card.Plus2Card -> true
is Card.ColorCard -> card.color == cardOnGame.color
}
}
}
is Card.Plus4Card -> {
if (player.cardOnBoardIsForYou && card is Card.Plus4Card) {
true
} else {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == lastColor
}
}
}
}
}
private val lastPlayerIndex: Int? get() {
val i = players.indexOf(lastPlayer)
return if (i == -1) {
null
} else {
i
}
}
private val nextPlayerIndex: Int get() {
val y =
if (direction == Direction.CLOCKWISE) {
+1
} else {
-1
}
return ((lastPlayerIndex ?: 0) + y) % players.size
}
val nextPlayer: Player = players.elementAt(nextPlayerIndex)
private val Player.currentIndex: Int get() = players.indexOf(this)
private fun Player.playerDiffIndex(nextPlayer: Player): Int =
if (direction == Direction.CLOCKWISE) {
nextPlayer.currentIndex + this.currentIndex
} else {
nextPlayer.currentIndex - this.currentIndex
}.let { it % players.size }
val Player.cardOnBoardIsForYou: Boolean get() {
if (lastCard == null) error("No card")
return this.playerDiffIndex(lastCard.player) == 1
}
}

View File

@@ -1,9 +1,28 @@
package eventDemo.shared.command package eventDemo.shared.command
import eventDemo.app.actions.playNewCard.PlayCardCommand import eventDemo.app.actions.playNewCard.GameCommand
import eventDemo.libs.command.CommandStream
import eventDemo.libs.command.CommandStreamChannel
import eventDemo.libs.command.CommandStreamInMemory import eventDemo.libs.command.CommandStreamInMemory
import io.ktor.websocket.Frame
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.serialization.json.Json
/** /**
* A stream to publish and read the played card command. * A stream to publish and read the game command.
*/ */
class GameCommandStream : CommandStreamInMemory<PlayCardCommand>() class GameCommandStreamInMemory : CommandStreamInMemory<GameCommand>()
/**
* A stream to publish and read the game command.
*/
class GameCommandStream(
incoming: ReceiveChannel<Frame>,
outgoing: SendChannel<Frame>,
) : CommandStream<GameCommand> by CommandStreamChannel(
incoming,
outgoing,
{ Json.encodeToString(GameCommand.serializer(), it) },
{ Json.decodeFromString(GameCommand.serializer(), it) },
)

View File

@@ -1,13 +1,17 @@
package eventDemo.shared.entity package eventDemo.shared.entity
import eventDemo.plugins.UUIDSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID
/** /**
* A Play card * A Play card
*/ */
@Serializable @Serializable
sealed interface Card { sealed interface Card {
val id: UUID
/** /**
* The color of a card * The color of a card
*/ */
@@ -19,6 +23,10 @@ sealed interface Card {
Green, Green,
} }
sealed interface ColorCard : Card {
val color: Color
}
/** /**
* A play card with color and number * A play card with color and number
*/ */
@@ -26,8 +34,11 @@ sealed interface Card {
@SerialName("Simple") @SerialName("Simple")
data class NumericCard( data class NumericCard(
val number: Int, val number: Int,
val color: Color, override val color: Color,
) : Card @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Card,
ColorCard
sealed interface Special : Card sealed interface Special : Card
@@ -37,8 +48,13 @@ sealed interface Card {
@Serializable @Serializable
@SerialName("Reverse") @SerialName("Reverse")
data class ReverseCard( data class ReverseCard(
val color: Color, override val color: Color,
) : Special @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
ColorCard
sealed interface PassTurnCard : Card
/** /**
* A pass card to pass the turn of the next player. * A pass card to pass the turn of the next player.
@@ -46,8 +62,12 @@ sealed interface Card {
@Serializable @Serializable
@SerialName("Pass") @SerialName("Pass")
data class PassCard( data class PassCard(
val color: Color, override val color: Color,
) : Special @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
ColorCard,
PassTurnCard
/** /**
* A play card to force the next player to take 2 card and pass the turn. * A play card to force the next player to take 2 card and pass the turn.
@@ -55,24 +75,35 @@ sealed interface Card {
@Serializable @Serializable
@SerialName("Plus2") @SerialName("Plus2")
data class Plus2Card( data class Plus2Card(
val color: Color, override val color: Color,
) : Special @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : Special,
ColorCard,
PassTurnCard
sealed interface AllColorCard : Card
/** /**
* A play card to force the next player to take 4 card and pass the turn. * A play card to force the next player to take 4 card and pass the turn.
*/ */
@Serializable @Serializable
@SerialName("Plus4") @SerialName("Plus4")
data class Plus4Card( class Plus4Card(
val nextColor: Color, @Serializable(with = UUIDSerializer::class)
) : Special override val id: UUID = UUID.randomUUID(),
) : Special,
AllColorCard,
PassTurnCard
/** /**
* A play card to change the color. * A play card to change the color.
*/ */
@Serializable @Serializable
@SerialName("ChangeColor") @SerialName("ChangeColor")
data class ChangeColorCard( class ChangeColorCard(
val nextColor: Color, @Serializable(with = UUIDSerializer::class)
) : Special override val id: UUID = UUID.randomUUID(),
) : Special,
AllColorCard
} }

View File

@@ -0,0 +1,53 @@
package eventDemo.shared.entity
import kotlinx.serialization.Serializable
@Serializable
data class Deck(
val stack: Set<Card> = emptySet(),
val discard: Set<Card> = emptySet(),
val playersHands: List<PlayerHand> = emptyList(),
) {
constructor(players: List<Player>) : this(playersHands = players.map { PlayerHand(it) })
fun putOneCardOnDiscard(): Deck {
val takenCard = stack.first()
val newStack = stack.filterNot { it != takenCard }.toSet()
return copy(stack = newStack)
}
fun take(n: Int): Pair<Deck, List<Card>> {
val takenCards = stack.take(n)
val newStack = stack.filterNot { takenCards.contains(it) }.toSet()
return Pair(copy(stack = newStack), takenCards)
}
companion object {
fun initHands(
players: Set<Player>,
handSize: Int = 7,
): Deck {
val deck = new()
val playersHands = players.map { PlayerHand(it, deck.stack.take(handSize)) }
val allTakenCards = playersHands.flatMap { it.cards }
val newStack = deck.stack.filterNot { allTakenCards.contains(it) }.toSet()
return deck.copy(
stack = newStack,
playersHands = playersHands,
)
}
private fun new(): Deck =
listOf(Card.Color.Red, Card.Color.Blue, Card.Color.Yellow, Card.Color.Green)
.flatMap { color ->
((0..9) + (1..9)).map { Card.NumericCard(it, color) } +
(1..2).map { Card.Plus2Card(color) } +
(1..2).map { Card.ReverseCard(color) } +
(1..2).map { Card.PassCard(color) }
}.let {
(1..4).map { Card.Plus4Card() }
}.shuffled()
.toSet()
.let { Deck(it) }
}
}

View File

@@ -1,16 +0,0 @@
package eventDemo.shared.entity
import eventDemo.shared.GameId
import kotlinx.serialization.Serializable
/**
* Represent a Game
*/
@Serializable
data class Game(
val id: GameId,
) {
companion object {
fun new(): Game = Game(GameId())
}
}

View File

@@ -0,0 +1,36 @@
package eventDemo.shared.entity
import eventDemo.libs.event.AggregateId
import eventDemo.plugins.PlayerIdSerializer
import eventDemo.plugins.UUIDSerializer
import io.ktor.server.auth.Principal
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class Player(
val name: String,
@Serializable(with = PlayerIdSerializer::class)
val id: PlayerId = PlayerId(UUID.randomUUID()),
) : Principal {
constructor(id: String, name: String) : this(
name,
PlayerId(UUID.fromString(id)),
)
@JvmInline
value class PlayerId(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : AggregateId {
override fun toString(): String = id.toString()
}
}
@Serializable
data class PlayerHand(
val player: Player,
val cards: List<Card> = emptyList(),
) {
val count = lazy { cards.count() }
}

View File

@@ -3,11 +3,14 @@ package eventDemo.shared.event
import eventDemo.libs.event.Event import eventDemo.libs.event.Event
import eventDemo.shared.GameId import eventDemo.shared.GameId
import eventDemo.shared.entity.Card import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Game import eventDemo.shared.entity.Deck
import eventDemo.shared.entity.Player
import kotlinx.serialization.Serializable
/** /**
* An [Event] of a [Game]. * An [Event] of a Game.
*/ */
@Serializable
sealed interface GameEvent : Event<GameId> { sealed interface GameEvent : Event<GameId> {
override val id: GameId override val id: GameId
} }
@@ -18,4 +21,59 @@ sealed interface GameEvent : Event<GameId> {
data class CardIsPlayedEvent( data class CardIsPlayedEvent(
override val id: GameId, override val id: GameId,
val card: Card, val card: Card,
val player: Player,
) : GameEvent
/**
* An [Event] to represent a new player joining the game.
*/
data class NewPlayerEvent(
override val id: GameId,
val player: Player,
) : GameEvent
/**
* This [Event] is sent when a player is ready.
*/
data class PlayerReadyEvent(
override val id: GameId,
val player: Player,
) : GameEvent
/**
* This [Event] is sent when a player is ready.
*/
data class GameStartedEvent(
override val id: GameId,
val firstPlayer: Player,
val deck: Deck,
) : GameEvent {
companion object {
fun new(
id: GameId,
players: Set<Player>,
): GameStartedEvent =
GameStartedEvent(
id = id,
firstPlayer = players.random(),
deck = Deck.initHands(players).putOneCardOnDiscard(),
)
}
}
/**
* This [Event] is sent when a player can play.
*/
data class PlayerHavePassEvent(
override val id: GameId,
val player: Player,
) : GameEvent
/**
* This [Event] is sent when a player chose a color.
*/
data class PlayerChoseColorEvent(
override val id: GameId,
val player: Player,
val color: Card.Color,
) : GameEvent ) : GameEvent

View File

@@ -0,0 +1,8 @@
package eventDemo.shared.event
import eventDemo.libs.event.EventBus
import eventDemo.shared.GameId
class GameEventBus(
bus: EventBus<GameEvent, GameId>,
) : EventBus<GameEvent, GameId> by bus

View File

@@ -1,9 +1,18 @@
package eventDemo.shared.event package eventDemo.shared.event
import eventDemo.libs.event.EventStreamInMemory import eventDemo.libs.event.EventBus
import eventDemo.libs.event.EventStream
import eventDemo.shared.GameId import eventDemo.shared.GameId
/** /**
* A stream to publish and read the played card event. * A stream to publish and read the played card event.
*/ */
class GameEventStream : EventStreamInMemory<GameEvent, GameId>(GameEvent::class.java) class GameEventStream(
private val eventBus: EventBus<GameEvent, GameId>,
private val m: EventStream<GameEvent, GameId>,
) : EventStream<GameEvent, GameId> by m {
override fun publish(event: GameEvent) {
m.publish(event)
eventBus.publish(event)
}
}

View File

@@ -0,0 +1,71 @@
package eventDemo.shared.event
import eventDemo.libs.event.EventStream
import eventDemo.shared.GameId
import eventDemo.shared.entity.Card
fun GameId.buildStateFromEventStream(eventStream: EventStream<GameEvent, GameId>): GameState =
buildStateFromEvents(
eventStream.readAll(this),
)
private fun GameId.buildStateFromEvents(events: List<GameEvent>): GameState =
events.fold(GameState(this)) { state: GameState, event: GameEvent ->
when (event) {
is CardIsPlayedEvent -> {
val direction =
when (event.card) {
is Card.ReverseCard -> state.direction.revert()
else -> state.direction
}
val color =
when (event.card) {
is Card.ColorCard -> event.card.color
else -> state.lastColor
}
state.copy(
lastPlayer = event.player,
direction = direction,
lastColor = color,
)
}
is NewPlayerEvent -> {
if (state.isReady) error("The game is already started")
state.copy(
players = state.players + event.player,
)
}
is PlayerReadyEvent -> {
state.copy(
readyPlayers = state.readyPlayers + event.player,
)
}
is PlayerHavePassEvent -> {
state.copy(
lastPlayer = event.player,
)
}
is PlayerChoseColorEvent -> {
state.copy(
lastColor = event.color,
)
}
is GameStartedEvent -> {
state.copy(
lastColor = (event.deck.discard.first() as? Card.ColorCard)?.color,
lastCard = GameState.LastCard(event.deck.discard.first(), event.firstPlayer),
lastPlayer = event.firstPlayer,
deck = event.deck,
isStarted = true,
)
}
}
}

View File

@@ -3,6 +3,7 @@ package eventDemo.app.actions
import eventDemo.configure import eventDemo.configure
import eventDemo.shared.GameId import eventDemo.shared.GameId
import eventDemo.shared.entity.Card import eventDemo.shared.entity.Card
import eventDemo.shared.entity.Player
import eventDemo.shared.event.CardIsPlayedEvent import eventDemo.shared.event.CardIsPlayedEvent
import eventDemo.shared.event.GameEventStream import eventDemo.shared.event.GameEventStream
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
@@ -32,6 +33,7 @@ class CardTest :
val id = GameId() val id = GameId()
val card: Card = Card.NumericCard(1, Card.Color.Blue) val card: Card = Card.NumericCard(1, Card.Color.Blue)
val player = Player(name = "Nikola")
httpClient() httpClient()
.post("/game/$id/card") { .post("/game/$id/card") {
contentType(Json) contentType(Json)
@@ -41,7 +43,7 @@ class CardTest :
assertEquals(HttpStatusCode.OK, status, message = bodyAsText()) assertEquals(HttpStatusCode.OK, status, message = bodyAsText())
val eventStream = getKoin().get<GameEventStream>() val eventStream = getKoin().get<GameEventStream>()
assertEquals(CardIsPlayedEvent(id, card), eventStream.readLast(id)) assertEquals(CardIsPlayedEvent(id, card, player), eventStream.readLast(id))
} }
} }
} }
@@ -53,12 +55,14 @@ class CardTest :
application { application {
stopKoin() stopKoin()
configure() configure()
val eventStream by inject<GameEventStream>() val eventStream by inject<GameEventStream>()
val player = Player(name = "Nikola")
eventStream.publish( eventStream.publish(
CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow)), CardIsPlayedEvent(id, Card.NumericCard(2, Card.Color.Yellow), player),
CardIsPlayedEvent(id, card), CardIsPlayedEvent(id, card, player),
// Other game // Other game
CardIsPlayedEvent(GameId(), Card.NumericCard(2, Card.Color.Yellow)), CardIsPlayedEvent(GameId(), Card.NumericCard(2, Card.Color.Yellow), player),
) )
} }