1 Commits
Author SHA1 Message Date
flecomte 256524f63e feat: increase timeout tests
Tests / build (push) Successful in 42s
Tests / lint (push) Successful in 4m15s
Tests / test (push) Failing after 1m23s
2026-03-14 01:31:24 +01:00
268 changed files with 4818 additions and 5176 deletions
+14 -14
View File
@@ -18,10 +18,10 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@@ -31,7 +31,7 @@ jobs:
run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
- name: Cache Gradle dependencies - name: Cache Gradle dependencies
uses: actions/cache@v6 uses: actions/cache@v3
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -48,16 +48,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
- name: Restore Gradle cache - name: Restore Gradle cache
uses: actions/cache@v6 uses: actions/cache@v3
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -73,7 +73,7 @@ jobs:
run: ./gradlew ktlintCheck run: ./gradlew ktlintCheck
- name: Publish ktlint report - name: Publish ktlint report
uses: yutailang0119/action-ktlint@v5 uses: yutailang0119/action-ktlint@v4
if: always() if: always()
with: with:
report-path: build/reports/ktlint/**/*.xml report-path: build/reports/ktlint/**/*.xml
@@ -84,16 +84,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
- name: Restore Gradle cache - name: Restore Gradle cache
uses: actions/cache@v6 uses: actions/cache@v3
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -106,20 +106,20 @@ jobs:
run: chmod +x gradlew run: chmod +x gradlew
- name: Start CI Docker Compose services - name: Start CI Docker Compose services
run: ./gradlew ciComposeUp -Pci run: ./gradlew composeUp -Pci
- name: Run tests - name: Run tests
run: ./gradlew test -x ciComposeUp -Pci --no-daemon run: ./gradlew test -x composeUp --no-daemon
- name: Upload test reports - name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: test-results name: test-results
path: build/reports/tests/test path: build/reports/tests/test
- name: Publish Test Report - name: Publish Test Report
uses: dorny/test-reporter@v3 uses: dorny/test-reporter@v1
if: always() if: always()
with: with:
name: JUnit Tests name: JUnit Tests
-32
View File
@@ -1,32 +0,0 @@
<DataSourcesHistory>
<DataSourceFromHistory isRemovedFromProject="false">
<data-source source="LOCAL" name="event-demo@localhost" uuid="af2eabb1-64f7-49de-a94f-be1560baa96a">
<database-info product="PostgreSQL" version="18.4 (Debian 18.4-1.pgdg13+1)" jdbc-version="4.2" driver-name="PostgreSQL JDBC Driver" driver-version="42.7.3" dbms="POSTGRES" exact-version="18.4" exact-driver-version="42.7">
<identifier-quote-string>&quot;</identifier-quote-string>
</database-info>
<case-sensitivity plain-identifiers="lower" quoted-identifiers="exact" />
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/event-demo</jdbc-url>
<secret-storage>master_key</secret-storage>
<user-name>event-demo</user-name>
<schema-mapping>
<introspection-scope>
<node negative="1">
<node kind="database" qname="@">
<node kind="schema" qname="@" />
</node>
<node kind="database" qname="event-demo">
<node kind="schema">
<name qname="auth" />
<name qname="game" />
</node>
</node>
</node>
</introspection-scope>
</schema-mapping>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</DataSourceFromHistory>
</DataSourcesHistory>
-27
View File
@@ -1,27 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="docker composeUp" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="composeUp" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<GradleProfilingDisabled>false</GradleProfilingDisabled>
<GradleCoverageDisabled>false</GradleCoverageDisabled>
<method v="2" />
</configuration>
</component>
+50 -63
View File
@@ -1,20 +1,21 @@
import com.avast.gradle.dockercompose.ComposeExtension @file:Suppress("PropertyName")
import org.jlleitschuh.gradle.ktlint.KtlintExtension import org.jlleitschuh.gradle.ktlint.KtlintExtension
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version") val ktor_version: String by project
val kotlinVersion: Provider<String> = providers.gradleProperty("kotlin_version") val kotlin_version: String by project
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version") val kotlin_serialization_version: String by project
val logbackVersion: Provider<String> = providers.gradleProperty("logback_version") val logback_version: String by project
val koinVersion: Provider<String> = providers.gradleProperty("koin_version") val koin_version: String by project
val kotlinLoggingVersion: Provider<String> = providers.gradleProperty("kotlin_logging_version") val kotlin_logging_version: String by project
val kotestVersion: Provider<String> = providers.gradleProperty("kotest_version") val kotest_version: String by project
plugins { plugins {
application application
kotlin("jvm") version "2.1.21" kotlin("jvm") version "2.1.10"
id("io.ktor.plugin") version "3.5.1" id("io.ktor.plugin") version "3.1.1"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
id("org.jlleitschuh.gradle.ktlint") version "14.2.0" id("org.jlleitschuh.gradle.ktlint") version "12.2.0"
id("com.avast.gradle.docker-compose") version "0.17.12" id("com.avast.gradle.docker-compose") version "0.17.12"
} }
@@ -28,7 +29,7 @@ application {
} }
configure<KtlintExtension> { configure<KtlintExtension> {
version.set("1.8.0") version.set("1.5.0")
} }
ktlint { ktlint {
reporters { reporters {
@@ -50,42 +51,29 @@ tasks.withType<Test>().configureEach {
useJUnitPlatform() useJUnitPlatform()
} }
configure<ComposeExtension> { dockerCompose {
createNested("test").apply { val composeFile =
useComposeFiles.set(listOf("docker/docker-compose-test.yaml")) if (project.hasProperty("ci")) {
// Use docker-compose-ci.yaml for the CI
"docker/docker-compose-ci.yaml"
} else {
// Use docker-compose-test.yaml for local tests
"docker/docker-compose-test.yaml"
}
useComposeFiles.set(listOf(composeFile))
setProjectName("event-demo-test") setProjectName("event-demo-test")
}
createNested("dev").apply {
useComposeFiles.set(listOf("docker/docker-compose-dev.yaml"))
setProjectName("event-demo-dev")
}
createNested("ci").apply {
useComposeFiles.set(listOf("docker/docker-compose-ci.yaml"))
setProjectName("event-demo-ci")
}
} }
tasks.test { tasks.test {
if (project.hasProperty("ci")) { dependsOn("composeUp")
dependsOn("ciComposeUp") dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
} else { dockerCompose.setProjectName("event-demo-test")
dependsOn("testComposeUp")
}
} }
tasks.named("run") { tasks.named("run") {
dependsOn("devComposeUp") dependsOn("composeUp")
} dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
tasks.composeUp { dockerCompose.setProjectName("event-demo-dev")
dependsOn("copyEnv")
}
tasks.named("devComposeUp") {
dependsOn("copyEnv")
}
tasks.named("testComposeUp") {
dependsOn("copyEnv")
}
tasks.named("ciComposeUp") {
dependsOn("copyEnv")
} }
tasks.register<Copy>("copyEnv") { tasks.register<Copy>("copyEnv") {
@@ -102,15 +90,13 @@ tasks.register<Copy>("copyEnv") {
exclude() exclude()
} }
} }
val files =
buildList {
add(File("docker/postgresql.secret"))
if (!project.hasProperty("ci")) {
add(File("docker/pgadmin.secret"))
}
}
outputs.files(*files.toTypedArray())
doLast { doLast {
val files =
listOf(
File("docker/pgadmin.secret"),
File("docker/postgresql.secret"),
)
files.forEach { files.forEach {
if (!it.exists()) { if (!it.exists()) {
it.writeText("changeit") it.writeText("changeit")
@@ -118,6 +104,9 @@ tasks.register<Copy>("copyEnv") {
} }
} }
} }
tasks.composeUp {
dependsOn("copyEnv")
}
dependencies { dependencies {
implementation("io.ktor:ktor-server-core-jvm") implementation("io.ktor:ktor-server-core-jvm")
@@ -135,25 +124,23 @@ dependencies {
implementation("io.ktor:ktor-server-data-conversion") implementation("io.ktor:ktor-server-data-conversion")
implementation("io.ktor:ktor-client-content-negotiation") implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-auth") implementation("io.ktor:ktor-client-auth")
implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}") implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.insert-koin:koin-ktor:${koinVersion.get()}") implementation("io.insert-koin:koin-ktor:$koin_version")
implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}") implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}") implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
implementation("redis.clients:jedis:5.2.0") implementation("redis.clients:jedis:5.2.0")
implementation("org.postgresql:postgresql:42.7.13") implementation("org.postgresql:postgresql:42.7.5")
implementation("com.zaxxer:HikariCP:6.3.0") implementation("com.zaxxer:HikariCP:6.3.0")
implementation("com.rabbitmq:amqp-client:5.25.0") implementation("com.rabbitmq:amqp-client:5.25.0")
implementation("com.password4j:password4j:1.8.4")
// Force version of sub library (for security) // Force version of sub library (for security)
implementation("commons-codec:commons-codec:1.13") implementation("commons-codec:commons-codec:1.13")
testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}") testImplementation("io.kotest:kotest-extensions-koin:$kotest_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}") testImplementation("io.ktor:ktor-server-test-host-jvm:$ktor_version")
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}") testImplementation("io.kotest:kotest-runner-junit5:$kotest_version")
testImplementation("io.mockk:mockk:1.13.17") testImplementation("io.mockk:mockk:1.13.17")
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
} }
-67
View File
@@ -1,67 +0,0 @@
# Exemple de structure
Les couches, du plus interne au plus externe
```
Domain (le cœur, ne dépend de RIEN d'externe)
Application (orchestre le Domain, ne connaît pas l'infra concrète)
Infrastructure (WebSocket, DB, event store — dépend de tout le reste)
```
```
src/
└── contexts/
├── auth/
└── ...
└── game/
├── domain/ ← Le cœur métier, zéro dépendance externe
│ ├── game/
│ │ ├── Game.ts ← Aggregate Root
│ │ ├── Player.ts ← Entity interne
│ │ ├── Card.ts ← Entity
│ │ ├── Color.ts ← Value Object
│ │ ├── Deck.ts ← VO ou petite structure
│ │ └── errors/
│ │ ├── InvalidMoveError.ts
│ │ └── ColorChoiceRequiredError.ts
│ └── events/ ← Events de DOMAINE (internes)
│ ├── CardPlayed.ts
│ ├── CardDrawn.ts
│ ├── TurnPassed.ts
│ └── DomainEvent.ts ← interface/type de base
├── application/ ← Orchestration, cas d'usage
│ ├── commands/ ← Les Commandes (intentions)
│ │ ├── PlayCardCommand.ts
│ │ └── DrawCardCommand.ts
│ ├── handlers/ ← Un handler par commande
│ │ ├── PlayCardHandler.ts ← charge l'aggregate, appelle game.playCard(), save
│ │ └── DrawCardHandler.ts
│ ├── projections/ ← LA LOGIQUE de construction des projections
│ │ ├── GameSummaryProjector.kt ← écoute les events, met à jour la vue
│ │ └── PlayerStatsProjector.kt
│ └── ports/ ← INTERFACES seulement (le "hexagone")
│ ├── GameRepository.ts ← interface, pas d'implémentation
│ ├── EventPublisher.ts ← interface, pas d'implémentation
│ └── ProjectionStore.kt ← interface, où lire/écrire la projection
├── infrastructure/ ← Tout ce qui est technique/externe
│ ├── persistence/
│ │ ├── EventStoreGameRepository.ts ← implémente GameRepository
│ │ ├── EventStore.ts
│ │ ├── projections/
│ │ │ ├── GameSummaryProjectionStore.kt ← implémentation concrète (DB, table dédiée)
│ │ │ └── models/
│ │ │ └── GameSummaryView.kt ← structure de la vue elle-même
│ ├── websocket/
│ │ ├── WebSocketServer.ts
│ │ ├── connectionManager.ts ← Map<gameId, Map<playerId, WebSocket>>
│ │ └── commandRouter.ts ← reçoit le message brut, dispatch vers le bon handler
│ └── eventPublisher/
│ └── WebSocketEventPublisher.ts ← implémente EventPublisher, fait le broadcast
└── presentation/ ← Traduction vers/depuis le client (le fameux DTO layer)
├── clientEvents/
│ ├── ClientEvent.ts ← types des events envoyés au front
│ └── toClientEvent.ts ← fonction de traduction domain event → client event
└── clientCommands/
└── parseIncomingCommand.ts ← valide/parse le message brut du client → Command
```
+2 -1
View File
@@ -1,5 +1,6 @@
name: event-demo-ci name: event-demo-test
include: include:
- path: - path:
- parts/docker-compose-databases.yaml - parts/docker-compose-databases.yaml
- parts/docker-compose-databases-expose.yaml - parts/docker-compose-databases-expose.yaml
- parts/docker-compose-traefik.yaml
-4
View File
@@ -1,4 +0,0 @@
name: event-demo-test
include:
- path:
- docker-compose-test.yaml
+9 -4
View File
@@ -1,6 +1,6 @@
services: services:
redis: redis:
image: redis/redis-stack:7.4.0-v8 image: redis/redis-stack:7.4.0-v3
healthcheck: healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ] test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
volumes: volumes:
@@ -19,19 +19,20 @@ services:
restart: no restart: no
postgresql: postgresql:
image: postgres:18.4 image: postgres:17.4
command: postgres -c 'max_connections=500' command: postgres -c 'max_connections=500'
environment: environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
POSTGRES_USER: event-demo POSTGRES_USER: event-demo
secrets:
- postgresql_password
healthcheck: healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"] test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"]
interval: 1s interval: 1s
timeout: 5s timeout: 5s
retries: 10 retries: 10
volumes: volumes:
- postgresql_data:/var/lib/postgresql - postgresql_data:/var/lib/postgresql/data
- ../postgresql.secret:/run/secrets/postgresql_password:ro
rabbitmq: rabbitmq:
image: rabbitmq:4-management-alpine image: rabbitmq:4-management-alpine
@@ -46,6 +47,10 @@ services:
volumes: volumes:
- rabbitmq_data:/var/lib/rabbitmq/ - rabbitmq_data:/var/lib/rabbitmq/
secrets:
postgresql_password:
file: ../postgresql.secret
volumes: volumes:
redis_data: redis_data:
redisinsight_data: redisinsight_data:
-31
View File
@@ -4,20 +4,10 @@ services:
environment: environment:
PGADMIN_DEFAULT_EMAIL: $PGADMIN_DEFAULT_EMAIL PGADMIN_DEFAULT_EMAIL: $PGADMIN_DEFAULT_EMAIL
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
secrets: secrets:
- pgadmin_password - pgadmin_password
volumes: volumes:
- pgadmin_data:/var/lib/pgadmin - pgadmin_data:/var/lib/pgadmin
configs:
- source: pgpass
target: /pgpass
mode: 0600
uid: "5050"
gid: "5050"
- source: servers_json
target: /pgadmin4/servers.json
labels: labels:
- "traefik.http.routers.pgadmin.rule=Host(`pgadmin.postgresql.traefik.me`)" - "traefik.http.routers.pgadmin.rule=Host(`pgadmin.postgresql.traefik.me`)"
- "traefik.http.services.pgadmin.loadbalancer.server.port=80" - "traefik.http.services.pgadmin.loadbalancer.server.port=80"
@@ -34,27 +24,6 @@ services:
- "traefik.http.routers.rabbitmq-management.service=rabbitmq-management" - "traefik.http.routers.rabbitmq-management.service=rabbitmq-management"
- "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672" - "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672"
configs:
pgpass:
content: |
*:*:*:event-demo:changeit
servers_json:
content: |
{
"Servers": {
"1": {
"Name": "Event demo",
"Group": "Servers",
"Host": "postgresql",
"Port": 5432,
"MaintenanceDB": "event-demo",
"Username": "event-demo",
"PassFile": "/pgpass",
"SSLMode": "prefer"
}
}
}
secrets: secrets:
pgadmin_password: pgadmin_password:
file: ../pgadmin.secret file: ../pgadmin.secret
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
traefik: traefik:
image: traefik:3.7.9 image: traefik:3.3.4
command: command:
- "--api.insecure=true" - "--api.insecure=true"
- "--api.dashboard=true" - "--api.dashboard=true"
+7 -7
View File
@@ -1,9 +1,9 @@
ktor_version=3.5.1 ktor_version=3.1.1
kotlin_version=2.1.21 kotlin_version=2.1.10
kotlin_serialization_version=1.11.0 kotlin_serialization_version=1.8.0
logback_version=1.5.38 logback_version=1.5.17
koin_version=4.2.1 koin_version=4.0.2
kotlin_logging_version=8.0.4 kotlin_logging_version=7.0.5
kotest_version=6.2.2 kotest_version=6.0.0.M2
kotlin.code.style=official kotlin.code.style=official
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
@@ -1,5 +1,4 @@
create schema game; create table event_stream (
create table game.game_event_stream (
id uuid not null primary key, id uuid not null primary key,
aggregate_id uuid not null, aggregate_id uuid not null,
version int not null, version int not null,
@@ -1,8 +0,0 @@
create schema auth;
create table auth.user_event_stream (
id uuid not null primary key,
aggregate_id uuid not null,
version int not null,
data jsonb not null,
unique(aggregate_id, version)
);
@@ -1,6 +0,0 @@
create table auth.user (
id uuid not null primary key,
username text not null,
unique(id),
unique(username)
);
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.infrastructure.persistence.eventBus package eventDemo.adapter.infrastructureLayer.event
import eventDemo.contexts.game.application.ports.GameEventBus import eventDemo.business.event.GameEventBus
import eventDemo.contexts.game.domain.events.GameEvent import eventDemo.business.event.event.GameEvent
import eventDemo.libs.bus.Bus import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInMemory import eventDemo.libs.bus.BusInMemory
import java.util.UUID import java.util.UUID
@@ -1,8 +1,8 @@
package eventDemo.contexts.game.infrastructure.persistence.eventBus package eventDemo.adapter.infrastructureLayer.event
import com.rabbitmq.client.ConnectionFactory import com.rabbitmq.client.ConnectionFactory
import eventDemo.contexts.game.application.ports.GameEventBus import eventDemo.business.event.GameEventBus
import eventDemo.contexts.game.domain.events.GameEvent import eventDemo.business.event.event.GameEvent
import eventDemo.libs.bus.Bus import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInRabbitMQ import eventDemo.libs.bus.BusInRabbitMQ
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -0,0 +1,14 @@
package eventDemo.adapter.infrastructureLayer.event
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventStore
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.event.EventStore
import eventDemo.libs.event.EventStoreInMemory
/**
* A stream to publish and read the played card event.
*/
class GameEventStoreInMemory :
GameEventStore,
EventStore<GameEvent, GameId> by EventStoreInMemory()
@@ -0,0 +1,21 @@
package eventDemo.adapter.infrastructureLayer.event
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventStore
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.event.EventStore
import eventDemo.libs.event.EventStoreInPostgresql
import kotlinx.serialization.json.Json
import javax.sql.DataSource
/**
* A stream to publish and read the played card event.
*/
class GameEventStoreInPostgresql(
dataSource: DataSource,
) : GameEventStore,
EventStore<GameEvent, GameId> by EventStoreInPostgresql(
dataSource,
{ Json.encodeToString(it) },
{ Json.decodeFromString(it) },
)
@@ -0,0 +1,44 @@
package eventDemo.adapter.infrastructureLayer.event.projection
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.projection.GameList
import eventDemo.business.event.projection.GameListRepository
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.business.event.projection.apply
import eventDemo.libs.event.projection.ProjectionRepositoryInMemory
import io.github.oshai.kotlinlogging.withLoggingContext
/**
* Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus].
*/
class GameListRepositoryInMemory : GameListRepository {
private val projectionsRepository =
ProjectionRepositoryInMemory(
applyToProjection = GameList::apply,
initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) },
)
fun subscribeToBus(
projectionBus: GameProjectionBus,
eventBus: GameEventBus,
) {
// On new event was received, build projection and publish it to the projection bus
eventBus.subscribe { event ->
withLoggingContext("event" to event.toString()) {
projectionsRepository
.applyAndSave(event)
.also { projectionBus.publish(it) }
}
}
}
/**
* 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> =
projectionsRepository.getList()
}
@@ -0,0 +1,51 @@
package eventDemo.adapter.infrastructureLayer.event.projection
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.projection.GameList
import eventDemo.business.event.projection.GameListRepository
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.business.event.projection.apply
import eventDemo.libs.event.projection.ProjectionRepositoryInRedis
import io.github.oshai.kotlinlogging.withLoggingContext
import kotlinx.serialization.json.Json
import redis.clients.jedis.UnifiedJedis
/**
* Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus].
*/
class GameListRepositoryInRedis(
jedis: UnifiedJedis,
) : GameListRepository {
private val projectionsRepository =
ProjectionRepositoryInRedis(
initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) },
projectionClass = GameList::class,
projectionToJson = { Json.encodeToString(GameList.serializer(), it) },
jsonToProjection = { Json.decodeFromString(GameList.serializer(), it) },
applyToProjection = GameList::apply,
jedis = jedis,
)
fun subscribeToBus(
projectionBus: GameProjectionBus,
eventBus: GameEventBus,
) {
eventBus.subscribe { event ->
withLoggingContext("event" to event.toString()) {
projectionsRepository
.applyAndSave(event)
.also { projectionBus.publish(it) }
}
}
}
/**
* 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> =
projectionsRepository.getList()
}
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.infrastructure.persistence.projections.bus package eventDemo.adapter.infrastructureLayer.event.projection
import eventDemo.contexts.game.application.ports.GameProjectionBus import eventDemo.business.event.projection.GameProjection
import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.libs.bus.Bus import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInMemory import eventDemo.libs.bus.BusInMemory
import java.util.UUID import java.util.UUID
@@ -1,8 +1,8 @@
package eventDemo.contexts.game.infrastructure.persistence.projections.bus package eventDemo.adapter.infrastructureLayer.event.projection
import com.rabbitmq.client.ConnectionFactory import com.rabbitmq.client.ConnectionFactory
import eventDemo.contexts.game.application.ports.GameProjectionBus import eventDemo.business.event.projection.GameProjection
import eventDemo.contexts.game.infrastructure.persistence.projections.models.GameProjection import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.libs.bus.Bus import eventDemo.libs.bus.Bus
import eventDemo.libs.bus.BusInRabbitMQ import eventDemo.libs.bus.BusInRabbitMQ
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -0,0 +1,41 @@
package eventDemo.adapter.infrastructureLayer.event.projection
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.business.event.projection.GameStateRepository
import eventDemo.business.event.projection.apply
import eventDemo.libs.event.projection.ProjectionRepositoryInMemory
import io.github.oshai.kotlinlogging.withLoggingContext
/**
* Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus].
*/
class GameStateRepositoryInMemory : GameStateRepository {
private val projectionsRepository =
ProjectionRepositoryInMemory(
applyToProjection = GameState::apply,
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
)
fun subscribeToBus(
projectionBus: GameProjectionBus,
eventBus: GameEventBus,
) {
// On new event was received, build projection and publish it to the projection bus
eventBus.subscribe { event ->
withLoggingContext("event" to event.toString()) {
projectionsRepository
.applyAndSave(event)
.also { projectionBus.publish(it) }
}
}
}
/**
* Get the [GameState].
*/
override fun get(gameId: GameId): GameState =
projectionsRepository.get(gameId)
}
@@ -0,0 +1,49 @@
package eventDemo.adapter.infrastructureLayer.event.projection
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.business.event.projection.GameStateRepository
import eventDemo.business.event.projection.apply
import eventDemo.libs.event.projection.ProjectionRepositoryInRedis
import io.github.oshai.kotlinlogging.withLoggingContext
import kotlinx.serialization.json.Json
import redis.clients.jedis.UnifiedJedis
/**
* Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus].
*/
class GameStateRepositoryInRedis(
jedis: UnifiedJedis,
) : GameStateRepository {
private val projectionsRepository =
ProjectionRepositoryInRedis(
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
projectionClass = GameState::class,
projectionToJson = { Json.encodeToString(GameState.serializer(), it) },
jsonToProjection = { Json.decodeFromString(GameState.serializer(), it) },
applyToProjection = GameState::apply,
jedis = jedis,
)
fun subscribeToBus(
projectionBus: GameProjectionBus,
eventBus: GameEventBus,
) {
// On new event was received, build projection and publish it to the projection bus
eventBus.subscribe { event ->
withLoggingContext("event" to event.toString()) {
projectionsRepository
.applyAndSave(event)
.also { projectionBus.publish(it) }
}
}
}
/**
* Get the [GameState].
*/
override fun get(gameId: GameId): GameState =
projectionsRepository.get(gameId)
}
@@ -0,0 +1,67 @@
package eventDemo.adapter.interfaceLayer.query
import eventDemo.business.command.GameCommandHandler
import eventDemo.business.command.command.GameCommand
import eventDemo.business.entity.GameId
import eventDemo.business.event.projection.projectionListener.PlayerNotificationListener
import eventDemo.business.notification.Notification
import eventDemo.libs.fromFrameChannel
import eventDemo.libs.toObjectChannel
import io.github.oshai.kotlinlogging.withLoggingContext
import io.ktor.server.auth.authenticate
import io.ktor.server.routing.Route
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.webSocket
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.launch
import java.util.UUID
@DelicateCoroutinesApi
fun Route.gameWebSocket(
playerNotificationListener: PlayerNotificationListener,
commandHandler: GameCommandHandler,
) {
authenticate {
webSocket("/games/new") {
runWebSocket(GameId(), commandHandler, playerNotificationListener)
}
webSocket("/games/{id}") {
val gameId = GameId(UUID.fromString(call.parameters["id"]!!))
runWebSocket(gameId, commandHandler, playerNotificationListener)
}
}
}
@DelicateCoroutinesApi
private fun DefaultWebSocketServerSession.runWebSocket(
gameId: GameId,
commandHandler: GameCommandHandler,
playerNotificationListener: PlayerNotificationListener,
) {
val currentPlayer = call.getPlayerCredentials()
val incomingFrameChannel: ReceiveChannel<GameCommand> = toObjectChannel(incoming)
val outgoingFrameChannel: SendChannel<Notification> = fromFrameChannel(outgoing)
withLoggingContext("currentPlayer" to currentPlayer.toString()) {
val notificationListener =
playerNotificationListener.startListening(
currentPlayer,
gameId,
) { outgoingFrameChannel.trySendBlocking(it) }
// TODO change GlobalScope
GlobalScope.launch {
commandHandler.handleIncomingPlayerCommands(
currentPlayer,
gameId,
incomingFrameChannel,
outgoingFrameChannel,
)
notificationListener.close()
}
}
}
@@ -1,6 +1,6 @@
package eventDemo.contexts.game.infrastructure.rest package eventDemo.adapter.interfaceLayer.query
import eventDemo.domain.event.projection.GameListRepository import eventDemo.business.event.projection.GameListRepository
import io.ktor.resources.Resource import io.ktor.resources.Resource
import io.ktor.server.auth.authenticate import io.ktor.server.auth.authenticate
import io.ktor.server.resources.get import io.ktor.server.resources.get
@@ -15,7 +15,7 @@ class Games
/** /**
* API routes to show all games. * API routes to show all games.
*/ */
fun Route.gamesListRoute(gameListRepository: GameListRepository) { fun Route.readGamesList(gameListRepository: GameListRepository) {
authenticate { authenticate {
// Read the last played card on the game. // Read the last played card on the game.
get<Games> { get<Games> {
@@ -0,0 +1,14 @@
package eventDemo.adapter.interfaceLayer.query
import eventDemo.business.entity.Player
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
internal fun ApplicationCall.getPlayerCredentials() =
principal<JWTPrincipal>()!!.run {
Player(
id = payload.getClaim("playerid").asString(),
name = payload.getClaim("username").asString(),
)
}
@@ -0,0 +1,53 @@
package eventDemo.adapter.interfaceLayer.query
import eventDemo.business.entity.GameId
import eventDemo.business.event.projection.GameStateRepository
import eventDemo.configuration.serializer.GameIdSerializer
import io.ktor.http.HttpStatusCode
import io.ktor.resources.Resource
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 kotlinx.serialization.Serializable
@Serializable
@Resource("/games/{id}")
class Game(
@Serializable(with = GameIdSerializer::class)
val id: GameId,
) {
@Serializable
@Resource("card/last")
class Card(
val game: Game,
)
@Serializable
@Resource("state")
class State(
val game: Game,
)
}
/**
* API routes to read the game state.
*/
fun Route.readTheGameState(gameStateRepository: GameStateRepository) {
authenticate {
// Read the last played card on the game.
get<Game.Card> { body ->
gameStateRepository
.get(body.game.id)
.cardOnCurrentStack
?.let { call.respond(it) }
?: call.response.status(HttpStatusCode.BadRequest)
}
// Read the last played card on the game.
get<Game.State> { body ->
val state = gameStateRepository.get(body.game.id)
call.respond(state)
}
}
}
@@ -1,4 +1,4 @@
package eventDemo.contexts.game.application.command.handlers package eventDemo.business.command
class CommandException( class CommandException(
override val message: String, override val message: String,
@@ -0,0 +1,27 @@
package eventDemo.business.command
import eventDemo.business.command.action.ICantPlay
import eventDemo.business.command.action.IWantToJoinTheGame
import eventDemo.business.command.action.IWantToPlayCard
import eventDemo.business.command.action.IamReadyToPlay
import eventDemo.business.command.command.GameCommand
import eventDemo.business.command.command.ICantPlayCommand
import eventDemo.business.command.command.IWantToJoinTheGameCommand
import eventDemo.business.command.command.IWantToPlayCardCommand
import eventDemo.business.command.command.IamReadyToPlayCommand
import eventDemo.business.event.event.GameEvent
class GameCommandActionRunner(
private val iWantToPlayCard: IWantToPlayCard,
private val iamReadyToPlay: IamReadyToPlay,
private val iWantToJoinTheGame: IWantToJoinTheGame,
private val iCantPlay: ICantPlay,
) {
fun run(command: GameCommand): (version: Int) -> GameEvent =
when (command) {
is IWantToPlayCardCommand -> iWantToPlayCard.run(command)
is IamReadyToPlayCommand -> iamReadyToPlay.run(command)
is IWantToJoinTheGameCommand -> iWantToJoinTheGame.run(command)
is ICantPlayCommand -> iCantPlay.run(command)
}
}
@@ -0,0 +1,139 @@
package eventDemo.business.command
import eventDemo.business.command.command.GameCommand
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.GameEventStore
import eventDemo.business.event.event.GameEvent
import eventDemo.business.notification.CommandErrorNotification
import eventDemo.business.notification.CommandSuccessNotification
import eventDemo.business.notification.Notification
import eventDemo.libs.command.CommandHandler
import eventDemo.libs.command.CommandRunnerController
import eventDemo.libs.event.EventHandlerImpl
import eventDemo.libs.event.VersionBuilder
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
/**
* Listen [GameCommand] on [GameEventBus], check the validity and execute an action.
*
* This action can be executing an action and produce a new [GameEvent] after verification.
*/
class GameCommandHandler(
eventBus: GameEventBus,
eventStore: GameEventStore,
versionBuilder: VersionBuilder,
runner: GameCommandActionRunner,
) {
private val logger = KotlinLogging.logger { }
private val eventHandler =
EventHandlerImpl(
eventBus,
eventStore,
versionBuilder,
)
private val commandHandler =
CommandHandler(
CommandRunnerController<GameCommand>(),
eventHandler,
) {
runner.run(it)
}
/**
* Subscribe to the [event bus][GameEventBus]
* to send success [notification][Notification] after save the [event][GameEvent].
*/
fun subscribeToBus(eventBus: GameEventBus) =
commandHandler.subscribeToBus(eventBus)
/**
* Lisent incoming [command][GameCommand] from the [channel][ReceiveChannel],
* run the command and publish the generated [event][GameEvent] to the bus.
*
* It restricts to run only once a command.
*
* If the command fail, send an [error notification][CommandErrorNotification],
* if success, send a [success notification][CommandSuccessNotification]
*/
suspend fun handleIncomingPlayerCommands(
player: Player,
gameId: GameId,
incomingCommandChannel: ReceiveChannel<GameCommand>,
channelNotification: SendChannel<Notification>,
) {
for (command in incomingCommandChannel) {
handle(
player,
gameId,
command,
channelNotification.sendSuccess(command),
channelNotification.sendError(command),
)
}
}
/**
* Run the [command] and publish the generated [event][GameEvent] to the bus.
*
* It restricts to run only once a command.
*
* If the command fail, send an [error notification][CommandErrorNotification],
* if success, send a [success notification][CommandSuccessNotification]
*/
fun handle(
player: Player,
gameId: GameId,
command: GameCommand,
sendSuccess: () -> Unit,
sendError: (message: String) -> Unit,
) {
if (command.payload.aggregateId.id != gameId.id) {
logger.warn { "Handle command Refuse, the gameId of the command is not the same" }
sendError("The gameId in the command does not match with your game")
return
}
if (command.payload.player.id != player.id) {
logger.warn { "Handle command Refuse, the player of the command is not the same" }
sendError("You are not the author of this command")
return
}
commandHandler.handle(gameId, command) { _, error ->
if (error != null) {
sendError(error.message) // Business
} else {
sendSuccess()
}
}
}
}
private fun SendChannel<Notification>.sendSuccess(command: GameCommand): () -> Unit =
{
val logger = KotlinLogging.logger { }
CommandSuccessNotification(commandId = command.id)
.also { notification ->
withLoggingContext("notification" to notification.toString(), "commandId" to command.id.toString()) {
logger.debug { "Notification SUCCESS sent" }
trySend(notification)
}
}
}
private fun SendChannel<Notification>.sendError(command: GameCommand): (message: String) -> Unit =
{
val logger = KotlinLogging.logger { }
CommandErrorNotification(message = it, command = command)
.also { notification ->
withLoggingContext("notification" to notification.toString(), "command" to command.toString()) {
logger.warn { "Notification ERROR sent: ${notification.message}" }
trySend(notification)
}
}
}
@@ -0,0 +1,8 @@
package eventDemo.business.command.action
import eventDemo.libs.command.Command
import eventDemo.libs.event.Event
sealed interface CommandAction<C : Command, E : Event<*>> {
fun run(command: C): (version: Int) -> E
}
@@ -0,0 +1,36 @@
package eventDemo.business.command.action
import eventDemo.business.command.CommandException
import eventDemo.business.command.command.ICantPlayCommand
import eventDemo.business.event.event.PlayerHavePassEvent
import eventDemo.business.event.projection.GameStateRepository
/**
* A command to perform an action to play a new card
*/
data class ICantPlay(
private val gameStateRepository: GameStateRepository,
) : CommandAction<ICantPlayCommand, PlayerHavePassEvent> {
override fun run(command: ICantPlayCommand): (version: Int) -> PlayerHavePassEvent {
val state = gameStateRepository.get(command.payload.aggregateId)
if (state.currentPlayerTurn != command.payload.player) {
throw CommandException("Its not your turn!")
}
val playableCards = state.playableCards(command.payload.player)
if (playableCards.isNotEmpty()) {
throw CommandException("You can and must play one card, like ${playableCards.first()::class.simpleName}")
}
val takenCard = state.deck.stack.first()
return { version ->
PlayerHavePassEvent(
aggregateId = command.payload.aggregateId,
player = command.payload.player,
takenCard = takenCard,
version = version,
)
}
}
}
@@ -0,0 +1,28 @@
package eventDemo.business.command.action
import eventDemo.business.command.CommandException
import eventDemo.business.command.command.IWantToJoinTheGameCommand
import eventDemo.business.event.event.NewPlayerEvent
import eventDemo.business.event.projection.GameStateRepository
/**
* A command to perform an action to play a new card
*/
data class IWantToJoinTheGame(
private val gameStateRepository: GameStateRepository,
) : CommandAction<IWantToJoinTheGameCommand, NewPlayerEvent> {
override fun run(command: IWantToJoinTheGameCommand): (version: Int) -> NewPlayerEvent {
val state = gameStateRepository.get(command.payload.aggregateId)
if (!state.isStarted) {
return {
NewPlayerEvent(
aggregateId = command.payload.aggregateId,
player = command.payload.player,
version = it,
)
}
} else {
throw CommandException("The game is already started")
}
}
}
@@ -0,0 +1,36 @@
package eventDemo.business.command.action
import eventDemo.business.command.CommandException
import eventDemo.business.command.command.IWantToPlayCardCommand
import eventDemo.business.event.event.CardIsPlayedEvent
import eventDemo.business.event.projection.GameStateRepository
/**
* A command to perform an action to play a new card
*/
data class IWantToPlayCard(
private val gameStateRepository: GameStateRepository,
) : CommandAction<IWantToPlayCardCommand, CardIsPlayedEvent> {
override fun run(command: IWantToPlayCardCommand): (version: Int) -> CardIsPlayedEvent {
val state = gameStateRepository.get(command.payload.aggregateId)
if (!state.isStarted) {
throw CommandException("The game is Not started")
}
if (state.currentPlayerTurn != command.payload.player) {
throw CommandException("Its not your turn!")
}
if (!state.canBePlayThisCard(command.payload.player, command.payload.card)) {
throw CommandException("You cannot play this card")
}
return { version ->
CardIsPlayedEvent(
aggregateId = command.payload.aggregateId,
card = command.payload.card,
player = command.payload.player,
version = version,
)
}
}
}
@@ -0,0 +1,36 @@
package eventDemo.business.command.action
import eventDemo.business.command.CommandException
import eventDemo.business.command.command.IamReadyToPlayCommand
import eventDemo.business.event.event.PlayerReadyEvent
import eventDemo.business.event.projection.GameStateRepository
/**
* A command to set as ready to play
*/
class IamReadyToPlay(
private val gameStateRepository: GameStateRepository,
) : CommandAction<IamReadyToPlayCommand, PlayerReadyEvent> {
@Throws(CommandException::class)
override fun run(command: IamReadyToPlayCommand): (version: Int) -> PlayerReadyEvent {
val state = gameStateRepository.get(command.payload.aggregateId)
val playerExist: Boolean = state.players.contains(command.payload.player)
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(command.payload.player)
if (state.isStarted) {
throw CommandException("The game is already started")
} else if (!playerExist) {
throw CommandException("You are not in the game")
} else if (playerIsAlreadyReady) {
throw CommandException("You are already ready")
} else {
return { version: Int ->
PlayerReadyEvent(
aggregateId = command.payload.aggregateId,
player = command.payload.player,
version = version,
)
}
}
}
}
@@ -0,0 +1,17 @@
package eventDemo.business.command.command
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.libs.command.Command
import kotlinx.serialization.Serializable
@Serializable
sealed interface GameCommand : Command {
val payload: Payload
@Serializable
sealed interface Payload {
val aggregateId: GameId
val player: Player
}
}
@@ -1,24 +1,22 @@
package eventDemo.contexts.game.application.command.models package eventDemo.business.command.command
import eventDemo.contexts.game.domain.game.GameId import eventDemo.business.entity.GameId
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer import eventDemo.business.entity.Player
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* A command to perform an action to play a new card * A command to perform an action to play a new card
*/ */
@Serializable @Serializable
data class JoinTheGameCommand( data class ICantPlayCommand(
override val userId: UserId,
override val payload: Payload, override val payload: Payload,
) : GameCommand { ) : GameCommand {
override val id: CommandId = CommandId() override val id: CommandId = CommandId()
@Serializable @Serializable
data class Payload( data class Payload(
@Serializable(with = GameIdSerializer::class)
override val aggregateId: GameId, override val aggregateId: GameId,
override val player: Player,
) : GameCommand.Payload ) : GameCommand.Payload
} }
@@ -0,0 +1,22 @@
package eventDemo.business.command.command
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
/**
* A command to perform an action to play a new card
*/
@Serializable
data class IWantToJoinTheGameCommand(
override val payload: Payload,
) : GameCommand {
override val id: CommandId = CommandId()
@Serializable
data class Payload(
override val aggregateId: GameId,
override val player: Player,
) : GameCommand.Payload
}
@@ -0,0 +1,24 @@
package eventDemo.business.command.command
import eventDemo.business.entity.Card
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
/**
* A command to perform an action to play a new card
*/
@Serializable
data class IWantToPlayCardCommand(
override val payload: Payload,
) : GameCommand {
override val id: CommandId = CommandId()
@Serializable
data class Payload(
override val aggregateId: GameId,
override val player: Player,
val card: Card,
) : GameCommand.Payload
}
@@ -0,0 +1,22 @@
package eventDemo.business.command.command
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
/**
* A command to set as ready to play
*/
@Serializable
data class IamReadyToPlayCommand(
override val payload: Payload,
) : GameCommand {
override val id: CommandId = CommandId()
@Serializable
data class Payload(
override val aggregateId: GameId,
override val player: Player,
) : GameCommand.Payload
}
@@ -1,6 +1,6 @@
package eventDemo.contexts.game.domain.game package eventDemo.business.entity
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -23,7 +23,7 @@ sealed interface Card {
Green, Green,
} }
sealed interface CardWithColor : Card { sealed interface ColorCard : Card {
val color: Color val color: Color
} }
@@ -38,14 +38,11 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Card, ) : Card,
CardWithColor { ColorCard {
init { init {
if (number > 9) error("Card number cannot be greater of 9") if (number > 9) error("Card number cannot be greater of 9")
if (number < 0) error("Card number cannot be lower of 0") if (number < 0) error("Card number cannot be lower of 0")
} }
override fun toString(): String =
"Numeric Card $number $color"
} }
sealed interface Special : Card sealed interface Special : Card
@@ -60,10 +57,7 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
CardWithColor { ColorCard
override fun toString(): String =
"Revert Card $color"
}
sealed interface PassTurnCard : Card sealed interface PassTurnCard : Card
@@ -77,11 +71,8 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
CardWithColor, ColorCard,
PassTurnCard { PassTurnCard
override fun toString(): String =
"Pass Card $color"
}
/** /**
* 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.
@@ -93,13 +84,10 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
CardWithColor, ColorCard,
PassTurnCard { PassTurnCard
override fun toString(): String =
"Plus2 Card $color"
}
sealed interface CardWith4Color : Card 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.
@@ -110,11 +98,8 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
CardWith4Color, AllColorCard,
PassTurnCard { PassTurnCard
override fun toString(): String =
"Plus4 Card"
}
/** /**
* A play card to change the color. * A play card to change the color.
@@ -125,8 +110,5 @@ sealed interface Card {
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : Special, ) : Special,
CardWith4Color { AllColorCard
override fun toString(): String =
"Change color Card"
}
} }
@@ -0,0 +1,133 @@
package eventDemo.business.entity
import kotlinx.serialization.Serializable
@Serializable
data class Deck(
val stack: Stack = Stack(),
val discard: Discard = Discard(),
val playersHands: PlayersHands = PlayersHands(),
) {
constructor(players: Set<Player>) :
this(playersHands = PlayersHands(players))
fun shuffle(): Deck =
copy(stack = stack.shuffle())
fun placeFirstCardOnDiscard(): Deck {
val takenCard = stack.first()
return copy(
stack = stack - takenCard,
discard = discard + takenCard,
)
}
fun takeOneCardFromStackTo(player: Player): Deck =
takeOne().let { (deck, newPlayerCard) ->
deck.copy(
playersHands = deck.playersHands.addCard(player, newPlayerCard),
)
}
fun putOneCardFromHand(
player: Player,
card: Card,
): Deck =
run {
// Validate parameters
val playerHand =
playersHands.getHand(player)
?: error("No player on this game")
if (playerHand.none { it == card }) {
error("No card exist on the player hand")
}
}.let {
copy(
discard = discard + card,
playersHands = playersHands.removeCard(player, card),
)
}
fun playerHasNoCardLeft(): List<Player.PlayerId> =
playersHands
.filter { (playerId, hand) -> hand.isEmpty() }
.map { (playerId, hand) -> playerId }
private fun take(n: Int): Pair<Deck, List<Card>> {
val takenCards = stack.take(n)
val newStack = stack.filterNot { takenCards.contains(it) }.toStack()
return Pair(copy(stack = newStack), takenCards)
}
private fun takeOne(): Pair<Deck, Card> =
take(1).let { (deck, cards) -> Pair(deck, cards.first()) }
companion object {
fun newWithoutPlayers(): 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 {
it + (1..4).map { Card.Plus4Card() }
}.toStack()
.let { Deck(it) }
}
}
fun Deck.initHands(
players: Set<Player>,
handSize: Int = 7,
): Deck {
// Copy cards from stack to the player hands
val deckWithEmptyHands = copy(playersHands = PlayersHands(players))
return players.fold(deckWithEmptyHands) { acc: Deck, player: Player ->
val hand = acc.stack.take(handSize)
val newStack = acc.stack.filterNot { card: Card -> hand.contains(card) }.toStack()
copy(
stack = newStack,
playersHands = acc.playersHands.addCards(player, hand),
)
}
}
@JvmInline
@Serializable
value class Stack(
private val cards: Set<Card> = emptySet(),
) : Set<Card> by cards {
operator fun plus(card: Card): Stack =
cards.plus(card).toStack()
operator fun minus(card: Card): Stack =
cards.minus(card).toStack()
fun shuffle(): Stack =
shuffled().toStack()
}
fun List<Card>.toStack(): Stack =
Stack(this.toSet())
fun Set<Card>.toStack(): Stack =
Stack(this)
@JvmInline
@Serializable
value class Discard(
private val cards: Set<Card> = emptySet(),
) : Set<Card> by cards {
operator fun plus(card: Card): Discard =
cards.plus(card).toDiscard()
operator fun minus(card: Card): Discard =
cards.minus(card).toDiscard()
}
fun List<Card>.toDiscard(): Discard =
Discard(this.toSet())
fun Set<Card>.toDiscard(): Discard =
Discard(this)
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.domain.game package eventDemo.business.entity
import eventDemo.libs.eventSource.AggregateId import eventDemo.configuration.serializer.GameIdSerializer
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.libs.event.AggregateId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -9,9 +9,8 @@ import java.util.UUID
* An [AggregateId] for a game. * An [AggregateId] for a game.
*/ */
@JvmInline @JvmInline
@Serializable @Serializable(with = GameIdSerializer::class)
value class GameId( value class GameId(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
) : AggregateId { ) : AggregateId {
override fun toString(): String = override fun toString(): String =
@@ -0,0 +1,29 @@
package eventDemo.business.entity
import eventDemo.configuration.serializer.PlayerIdSerializer
import eventDemo.configuration.serializer.UUIDSerializer
import eventDemo.libs.event.AggregateId
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()),
) {
constructor(id: String, name: String) : this(
name,
PlayerId(UUID.fromString(id)),
)
@Serializable
@JvmInline
value class PlayerId(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
) : AggregateId {
override fun toString(): String =
id.toString()
}
}
@@ -0,0 +1,50 @@
package eventDemo.business.entity
import kotlinx.serialization.Serializable
@Serializable
@JvmInline
value class PlayersHands(
private val map: Map<Player.PlayerId, List<Card>> = emptyMap(),
) : Map<Player.PlayerId, List<Card>> by map {
constructor(players: Set<Player>) :
this(players.map { it.id }.associateWith { emptyList<Card>() }.toPlayersHands())
fun getHand(player: Player): List<Card>? =
this[player.id]
fun removeCard(
player: Player,
card: Card,
): PlayersHands =
mapValues { (playerId, cards) ->
if (playerId == player.id) {
if (!cards.contains(card)) error("The hand no contain the card")
cards - card
} else {
cards
}
}.toPlayersHands()
fun addCard(
player: Player,
newCard: Card,
): PlayersHands =
addCards(player, listOf(newCard))
fun addCards(
player: Player,
newCards: List<Card>,
): PlayersHands =
mapValues { (p, cards) ->
if (p == player.id) {
if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card")
cards + newCards
} else {
cards
}
}.toPlayersHands()
}
fun Map<Player.PlayerId, List<Card>>.toPlayersHands(): PlayersHands =
PlayersHands(this)
@@ -0,0 +1,6 @@
package eventDemo.business.event
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.bus.Bus
interface GameEventBus : Bus<GameEvent>
@@ -0,0 +1,16 @@
package eventDemo.business.event
import eventDemo.business.entity.GameId
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.event.EventHandler
import eventDemo.libs.event.EventHandlerImpl
import eventDemo.libs.event.VersionBuilder
/**
* Handle the event to dispatch it to store, bus and projections builders
*/
class GameEventHandler(
private val eventBus: GameEventBus,
private val eventStore: GameEventStore,
private val versionBuilder: VersionBuilder,
) : EventHandler<GameEvent, GameId> by EventHandlerImpl(eventBus, eventStore, versionBuilder)
@@ -0,0 +1,7 @@
package eventDemo.business.event
import eventDemo.business.entity.GameId
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.event.EventStore
interface GameEventStore : EventStore<GameEvent, GameId>
@@ -0,0 +1,26 @@
package eventDemo.business.event.event
import eventDemo.business.entity.Card
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* An [GameEvent] to represent a played card.
*/
@Serializable
data class CardIsPlayedEvent(
override val aggregateId: GameId,
val card: Card,
override val player: Player,
override val version: Int,
) : GameEvent,
PlayerActionEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,16 @@
package eventDemo.business.event.event
import eventDemo.business.entity.GameId
import eventDemo.libs.event.Event
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* An [Event] of a Game.
*/
@Serializable
sealed interface GameEvent : Event<GameId> {
override val eventId: UUID
override val aggregateId: GameId
override val version: Int
}
@@ -0,0 +1,52 @@
package eventDemo.business.event.event
import eventDemo.business.entity.Deck
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.business.entity.initHands
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* This [GameEvent] is sent when all players are ready.
*/
@Serializable
data class GameStartedEvent(
override val aggregateId: GameId,
val firstPlayer: Player,
val deck: Deck,
override val version: Int,
) : GameEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
companion object {
fun new(
id: GameId,
players: Set<Player>,
version: Int,
shuffleIsDisabled: Boolean = isDisabled,
): GameStartedEvent =
GameStartedEvent(
aggregateId = id,
firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
deck =
Deck
.newWithoutPlayers()
.let { if (shuffleIsDisabled) it else it.shuffle() }
.initHands(players)
.placeFirstCardOnDiscard(),
version = version,
)
}
}
private var isDisabled = false
internal fun disableShuffleDeck() {
isDisabled = true
}
@@ -0,0 +1,23 @@
package eventDemo.business.event.event
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* An [GameEvent] to represent a new player joining the game.
*/
@Serializable
data class NewPlayerEvent(
override val aggregateId: GameId,
val player: Player,
override val version: Int,
) : GameEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,9 @@
package eventDemo.business.event.event
import eventDemo.business.entity.Player
import kotlinx.serialization.Serializable
@Serializable
sealed interface PlayerActionEvent : GameEvent {
val player: Player
}
@@ -0,0 +1,26 @@
package eventDemo.business.event.event
import eventDemo.business.entity.Card
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* This [GameEvent] is sent when a player chose a color.
*/
@Serializable
data class PlayerChoseColorEvent(
override val aggregateId: GameId,
override val player: Player,
val color: Card.Color,
override val version: Int,
) : GameEvent,
PlayerActionEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,26 @@
package eventDemo.business.event.event
import eventDemo.business.entity.Card
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* This [GameEvent] is sent when a player can play.
*/
@Serializable
data class PlayerHavePassEvent(
override val aggregateId: GameId,
override val player: Player,
val takenCard: Card,
override val version: Int,
) : GameEvent,
PlayerActionEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,23 @@
package eventDemo.business.event.event
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* This [GameEvent] is sent when a player is ready.
*/
@Serializable
data class PlayerReadyEvent(
override val aggregateId: GameId,
val player: Player,
override val version: Int,
) : GameEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,23 @@
package eventDemo.business.event.event
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* This [GameEvent] is sent when a player is ready.
*/
@Serializable
data class PlayerWinEvent(
override val aggregateId: GameId,
val player: Player,
override val version: Int,
) : GameEvent {
@Serializable(with = UUIDSerializer::class)
override val eventId: UUID = UUID.randomUUID()
override val createdAt: Instant = Clock.System.now()
}
@@ -0,0 +1,8 @@
package eventDemo.business.event.projection
import eventDemo.business.entity.GameId
import eventDemo.libs.event.projection.Projection
import kotlinx.serialization.Serializable
@Serializable
sealed interface GameProjection : Projection<GameId>
@@ -0,0 +1,5 @@
package eventDemo.business.event.projection
import eventDemo.libs.bus.Bus
interface GameProjectionBus : Bus<GameProjection>
@@ -1,7 +1,8 @@
package eventDemo.contexts.game.infrastructure.persistence.projections.models package eventDemo.business.event.projection
import eventDemo.contexts.game.domain.game.GameId import eventDemo.business.entity.GameId
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.libs.event.projection.Projection
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@@ -9,10 +10,11 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
data class GameList( data class GameList(
val aggregateId: GameId, override val aggregateId: GameId,
override val lastEventVersion: Int = 0,
val status: Status = Status.OPENING, val status: Status = Status.OPENING,
val players: Set<Player> = emptySet(), val players: Set<Player> = emptySet(),
val winners: Set<Player.PlayerId> = emptySet(), val winners: Set<Player> = emptySet(),
) : GameProjection { ) : GameProjection {
enum class Status { enum class Status {
OPENING, OPENING,
@@ -0,0 +1,51 @@
package eventDemo.business.event.projection
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,
)
@@ -0,0 +1,5 @@
package eventDemo.business.event.projection
interface GameListRepository {
fun getList(): List<GameList>
}
@@ -0,0 +1,182 @@
package eventDemo.business.event.projection
import eventDemo.business.entity.Card
import eventDemo.business.entity.Deck
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.business.event.event.GameEvent
import eventDemo.libs.event.projection.Projection
import kotlinx.serialization.Serializable
/**
* This [projection][Projection] is used for manage a game and theirs [card][Card]
*/
@Serializable
data class GameState(
override val aggregateId: GameId,
override val lastEventVersion: Int = 0,
val players: Set<Player> = emptySet(),
val currentPlayerTurn: Player? = null,
val lastCardPlayer: Player? = null,
val colorOnCurrentStack: Card.Color? = null,
val direction: Direction = Direction.CLOCKWISE,
val readyPlayers: Set<Player> = emptySet(),
val deck: Deck = Deck(players),
val isStarted: Boolean = false,
val playerWins: Set<Player> = emptySet(),
val lastEvent: GameEvent? = null,
) : GameProjection {
enum class Direction {
CLOCKWISE,
COUNTER_CLOCKWISE,
;
fun revert(): Direction =
if (this === CLOCKWISE) {
COUNTER_CLOCKWISE
} else {
CLOCKWISE
}
}
val cardOnCurrentStack: Card? = deck.discard.lastOrNull()
val isReady: Boolean get() {
return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) }
}
private val currentPlayerIndex: Int? get() {
val i = players.indexOf(currentPlayerTurn)
return if (i == -1) {
null
} else {
i
}
}
private fun nextPlayerIndex(direction: Direction): Int {
if (players.isEmpty()) return 0
return if (direction == Direction.CLOCKWISE) {
sidePlayerIndexClockwise
} else {
sidePlayerIndexCounterClockwise
}
}
fun nextPlayer(direction: Direction): Player =
players.elementAt(nextPlayerIndex(direction))
private val sidePlayerIndexClockwise: Int by lazy {
if (players.isEmpty()) {
0
} else {
((currentPlayerIndex ?: 0) + 1) % players.size
}
}
private val sidePlayerIndexCounterClockwise: Int by lazy {
if (players.isEmpty()) {
0
} else {
((currentPlayerIndex ?: 0) - 1) % players.size
}
}
val nextPlayerTurn: Player? by lazy {
if (players.isEmpty()) {
null
} else {
nextPlayer(direction)
}
}
private val Player.currentIndex: Int get() = players.indexOf(this)
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 (lastCardPlayer == null) error("No card")
return this.playerDiffIndex(lastCardPlayer) == 1
}
fun playableCards(player: Player): List<Card> =
deck
.playersHands
.getHand(player)
?.filter { canBePlayThisCard(player, it) }
?: emptyList()
fun playerHasNoCardLeft(): List<Player> =
deck.playerHasNoCardLeft().map { playerId ->
players.find { it.id == playerId } ?: error("inconsistency detected between players")
}
fun canBePlayThisCard(
player: Player,
card: Card,
): Boolean {
val cardOnBoard = cardOnCurrentStack ?: return false
return when (cardOnBoard) {
is Card.NumericCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
is Card.ReverseCard -> {
when (card) {
is Card.ReverseCard -> true
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
is Card.PassCard -> {
if (player.cardOnBoardIsForYou) {
false
} else {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == cardOnBoard.color
}
}
}
is Card.ChangeColorCard -> {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == colorOnCurrentStack
}
}
is Card.Plus2Card -> {
if (player.cardOnBoardIsForYou && card is Card.Plus2Card) {
true
} else {
when (card) {
is Card.Plus2Card -> true
else -> false
}
}
}
is Card.Plus4Card -> {
if (player.cardOnBoardIsForYou && card is Card.Plus4Card) {
true
} else {
when (card) {
is Card.AllColorCard -> true
is Card.ColorCard -> card.color == colorOnCurrentStack
}
}
}
}
}
}
@@ -0,0 +1,116 @@
package eventDemo.business.event.projection
import eventDemo.business.entity.Card
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.PlayerActionEvent
import eventDemo.business.event.event.PlayerChoseColorEvent
import eventDemo.business.event.event.PlayerHavePassEvent
import eventDemo.business.event.event.PlayerReadyEvent
import eventDemo.business.event.event.PlayerWinEvent
import io.github.oshai.kotlinlogging.KotlinLogging
fun GameState.apply(event: GameEvent): GameState =
this.let { state ->
val logger = KotlinLogging.logger { }
if (event is PlayerActionEvent) {
if (state.currentPlayerTurn != event.player) {
logger.atError {
message = "Inconsistent player turn"
payload =
mapOf(
"CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"),
"Player" to event.player,
)
}
}
}
when (event) {
is CardIsPlayedEvent -> {
val nextDirectionAfterPlay =
when (event.card) {
is Card.ReverseCard -> state.direction.revert()
else -> state.direction
}
val color =
when (event.card) {
is Card.ColorCard -> event.card.color
is Card.AllColorCard -> null
}
val currentPlayerAfterThePlay =
if (event.card is Card.AllColorCard) {
state.currentPlayerTurn
} else {
state.nextPlayer(nextDirectionAfterPlay)
}
state.copy(
currentPlayerTurn = currentPlayerAfterThePlay,
direction = nextDirectionAfterPlay,
colorOnCurrentStack = color,
lastCardPlayer = event.player,
deck = state.deck.putOneCardFromHand(event.player, event.card),
)
}
is NewPlayerEvent -> {
if (state.isStarted) {
logger.error { "The game is already started" }
}
state.copy(
players = state.players + event.player,
)
}
is PlayerReadyEvent -> {
if (state.isStarted) {
logger.error { "The game is already started" }
}
state.copy(
readyPlayers = state.readyPlayers + event.player,
)
}
is PlayerHavePassEvent -> {
if (event.takenCard != state.deck.stack.first()) {
logger.error { "taken card is not ot top of the stack: ${event.takenCard}" }
}
state.copy(
currentPlayerTurn = state.nextPlayerTurn,
deck = state.deck.takeOneCardFromStackTo(event.player),
)
}
is PlayerChoseColorEvent -> {
state.copy(
currentPlayerTurn = state.nextPlayerTurn,
colorOnCurrentStack = event.color,
)
}
is GameStartedEvent -> {
state.copy(
colorOnCurrentStack = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.colorOnCurrentStack,
lastCardPlayer = null,
currentPlayerTurn = event.firstPlayer,
deck = event.deck,
isStarted = true,
)
}
is PlayerWinEvent -> {
state.copy(
playerWins = state.playerWins + event.player,
)
}
}.copy(
lastEventVersion = event.version,
lastEvent = event,
)
}
@@ -0,0 +1,7 @@
package eventDemo.business.event.projection
import eventDemo.business.entity.GameId
interface GameStateRepository {
fun get(gameId: GameId): GameState
}
@@ -0,0 +1,149 @@
package eventDemo.business.event.projection.projectionListener
import eventDemo.business.entity.Card
import eventDemo.business.entity.GameId
import eventDemo.business.entity.Player
import eventDemo.business.event.event.CardIsPlayedEvent
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
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.business.notification.ItsTheTurnOfNotification
import eventDemo.business.notification.Notification
import eventDemo.business.notification.PlayerAsJoinTheGameNotification
import eventDemo.business.notification.PlayerAsPlayACardNotification
import eventDemo.business.notification.PlayerHavePassNotification
import eventDemo.business.notification.PlayerWasChoseTheCardColorNotification
import eventDemo.business.notification.PlayerWasReadyNotification
import eventDemo.business.notification.PlayerWinNotification
import eventDemo.business.notification.TheGameWasStartedNotification
import eventDemo.business.notification.WelcomeToTheGameNotification
import eventDemo.business.notification.YourNewCardNotification
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
class PlayerNotificationListener(
private val projectionBus: GameProjectionBus,
) {
private val logger = KotlinLogging.logger {}
/**
* Forward projection from [bus][GameProjectionBus] to the player [notification][outgoingNotification]
*/
fun startListening(
currentPlayer: Player,
gameId: GameId,
outgoingNotification: (Notification) -> Unit,
): AutoCloseable {
return projectionBus.subscribe { currentState ->
if (currentState !is GameState) return@subscribe
if (currentState.aggregateId != gameId) return@subscribe
withLoggingContext("currentPlayer" to currentPlayer.toString(), "projection" to currentState.toString()) {
fun Notification.send() {
withLoggingContext("notification" to this.toString()) {
if (currentState.players.contains(currentPlayer)) {
// Only notify players who have already joined the game.
outgoingNotification(this)
logger.info { "Notification was SEND" }
} else {
// Rare use case, when a connexion is created with the channel,
// but the player was not already join in the game
logger.warn { "Notification was SKIP, no player on the game" }
}
}
}
fun sendNextTurnNotif() =
ItsTheTurnOfNotification(
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
).send()
val event =
currentState.lastEvent
?: error("No last event in the GameState projection")
when (event) {
is NewPlayerEvent -> {
if (currentPlayer != event.player) {
PlayerAsJoinTheGameNotification(
player = event.player,
).send()
} else {
WelcomeToTheGameNotification(
players = currentState.players,
).send()
}
}
is CardIsPlayedEvent -> {
if (currentPlayer != event.player) {
PlayerAsPlayACardNotification(
player = event.player,
card = event.card,
).send()
}
if (event.card !is Card.AllColorCard) {
ItsTheTurnOfNotification(
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
).send()
}
}
is GameStartedEvent -> {
TheGameWasStartedNotification(
hand =
event.deck.playersHands.getHand(currentPlayer)
?: error("You are not in the game"),
).send()
sendNextTurnNotif()
}
is PlayerChoseColorEvent -> {
if (currentPlayer != event.player) {
PlayerWasChoseTheCardColorNotification(
player = event.player,
color = event.color,
).send()
}
sendNextTurnNotif()
}
is PlayerHavePassEvent -> {
if (currentPlayer == event.player) {
YourNewCardNotification(
card = event.takenCard,
).send()
} else {
PlayerHavePassNotification(
player = event.player,
).send()
}
sendNextTurnNotif()
}
is PlayerReadyEvent -> {
if (currentPlayer != event.player) {
PlayerWasReadyNotification(
player = event.player,
).send()
}
}
is PlayerWinEvent -> {
PlayerWinNotification(
player = event.player,
).send()
}
}
}
}
}
}
@@ -0,0 +1,75 @@
package eventDemo.business.event.projection.projectionListener
import eventDemo.business.entity.GameId
import eventDemo.business.event.GameEventHandler
import eventDemo.business.event.event.GameStartedEvent
import eventDemo.business.event.event.PlayerWinEvent
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameState
import eventDemo.libs.event.projection.Projection
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import java.util.concurrent.ConcurrentSkipListSet
class ReactionListener(
private val eventHandler: GameEventHandler,
) {
companion object Config {
val registeredListeners = ConcurrentSkipListSet<GameProjectionBus>()
}
private val logger = KotlinLogging.logger { }
fun subscribeToBus(projectionBus: GameProjectionBus) {
if (registeredListeners.add(projectionBus)) {
projectionBus.subscribe { projection: Projection<GameId> ->
if (projection !is GameState) return@subscribe
withLoggingContext("projection" to projection.toString()) {
sendStartGameEvent(projection)
sendWinnerEvent(projection)
}
}
} else {
"${this::class.simpleName} is already init for this bus".let {
logger.error { it }
error(it)
}
}
}
private fun sendStartGameEvent(state: GameState) {
if (state.isReady && !state.isStarted) {
val reactionEvent =
eventHandler.handle(state.aggregateId) {
GameStartedEvent.new(
id = state.aggregateId,
players = state.players,
version = it,
)
}
logger.atInfo {
message = "Reaction event was Send"
payload = mapOf("reactionEvent" to reactionEvent)
}
}
}
private fun sendWinnerEvent(state: GameState) {
val winner = state.playerHasNoCardLeft().firstOrNull()
if (winner != null) {
val reactionEvent =
eventHandler.handle(state.aggregateId) {
PlayerWinEvent(
aggregateId = state.aggregateId,
player = winner,
version = it,
)
}
logger.atInfo {
message = "Reaction event was Send"
payload = mapOf("reactionEvent" to reactionEvent)
}
}
}
}
@@ -0,0 +1,15 @@
package eventDemo.business.notification
import eventDemo.configuration.serializer.UUIDSerializer
import eventDemo.libs.command.Command
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class CommandErrorNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val message: String,
val command: Command,
) : Notification,
CommandNotification
@@ -0,0 +1,3 @@
package eventDemo.business.notification
sealed interface CommandNotification : Notification
@@ -0,0 +1,14 @@
package eventDemo.business.notification
import eventDemo.configuration.serializer.UUIDSerializer
import eventDemo.libs.command.CommandId
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class CommandSuccessNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val commandId: CommandId,
) : Notification,
CommandNotification
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -1,6 +1,6 @@
package eventDemo.contexts.game.application.notification.models package eventDemo.business.notification
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -1,8 +1,8 @@
package eventDemo.contexts.game.application.notification.models package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Card import eventDemo.business.entity.Card
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -10,6 +10,6 @@ import java.util.UUID
data class PlayerAsPlayACardNotification( data class PlayerAsPlayACardNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val playerId: Player.PlayerId, val player: Player,
val card: Card, val card: Card,
) : Notification ) : Notification
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class PlayerHavePassNotification( data class PlayerHavePassNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val playerId: Player.PlayerId, val player: Player,
) : Notification ) : Notification
@@ -0,0 +1,15 @@
package eventDemo.business.notification
import eventDemo.business.entity.Card
import eventDemo.business.entity.Player
import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class PlayerWasChoseTheCardColorNotification(
@Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(),
val player: Player,
val color: Card.Color,
) : Notification
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class PlayerWasReadyNotification( data class PlayerWasReadyNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val playerId: Player.PlayerId, val player: Player,
) : Notification ) : Notification
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class PlayerWinNotification( data class PlayerWinNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val playerId: Player.PlayerId, val player: Player,
) : Notification ) : Notification
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Card import eventDemo.business.entity.Card
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class TheGameWasStartedNotification( data class TheGameWasStartedNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val hand: Set<Card>, val hand: List<Card>,
) : Notification ) : Notification
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -1,7 +1,7 @@
package eventDemo.contexts.game.application.notification.models package eventDemo.business.notification
import eventDemo.contexts.game.domain.game.Card import eventDemo.business.entity.Card
import eventDemo.libs.serializer.UUIDSerializer import eventDemo.configuration.serializer.UUIDSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import java.util.UUID
@@ -9,5 +9,5 @@ import java.util.UUID
data class YourNewCardNotification( data class YourNewCardNotification(
@Serializable(with = UUIDSerializer::class) @Serializable(with = UUIDSerializer::class)
override val id: UUID = UUID.randomUUID(), override val id: UUID = UUID.randomUUID(),
val cards: Set<Card>, val card: Card,
) : Notification ) : Notification
@@ -1,46 +0,0 @@
package eventDemo.configuration
import io.ktor.server.config.ApplicationConfig
data class Configuration(
val redisUrl: String,
val jwtSecret: String,
val postgresql: Postgresql,
val rabbitmq: RabbitMQ,
) {
data class Postgresql(
val url: String,
val username: String,
val password: String,
)
data class RabbitMQ(
val url: String,
val port: Int,
val username: String,
val password: String,
)
}
val ApplicationConfig.configuration
get() =
Configuration(
redisUrl = getProperty("redis.url"),
jwtSecret = getProperty("jwt.secret"),
postgresql =
Configuration.Postgresql(
url = getProperty("postgresql.url"),
username = getProperty("postgresql.username"),
password = getProperty("postgresql.password"),
),
rabbitmq =
Configuration.RabbitMQ(
url = getProperty("rabbitmq.url"),
port = getProperty("rabbitmq.port").toInt(),
username = getProperty("rabbitmq.username"),
password = getProperty("rabbitmq.password"),
),
)
private fun ApplicationConfig.getProperty(path: String): String =
propertyOrNull(path)?.getString() ?: error("You must set the $path")
@@ -0,0 +1,29 @@
package eventDemo.configuration
import eventDemo.configuration.business.configureGameListener
import eventDemo.configuration.ktor.configureHttpRouting
import eventDemo.configuration.ktor.configureKoin
import eventDemo.configuration.ktor.configureSecurity
import eventDemo.configuration.ktor.configureSerialization
import eventDemo.configuration.ktor.configureWebSockets
import eventDemo.configuration.route.declareHttpGameRoute
import eventDemo.configuration.route.declareWebSocketsGameRoute
import io.ktor.server.application.Application
import org.koin.ktor.ext.get
import org.koin.ktor.ext.getKoin
fun Application.configure() {
configureKoin()
configureSecurity()
configureSerialization()
configureWebSockets()
declareWebSocketsGameRoute(get(), get())
configureHttpRouting()
declareHttpGameRoute()
getKoin().configureGameListener()
}
@@ -1,14 +0,0 @@
package eventDemo.configuration
import eventDemo.contexts.auth.infrastructure.configure.configureAuthDi
import eventDemo.contexts.game.infrastructure.configuration.injections.application.configureGameDIApplication
import eventDemo.contexts.game.infrastructure.configuration.injections.infrastructure.configureGameDIInfrastructure
import org.koin.dsl.module
fun appKoinModule(config: Configuration) =
module {
configureDIDataSource(config)
configureAuthDi()
configureGameDIInfrastructure()
configureGameDIApplication()
}
@@ -1,55 +0,0 @@
package eventDemo.configuration
import com.rabbitmq.client.ConnectionFactory
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.koin.core.module.Module
import org.koin.core.scope.Scope
import org.koin.core.scope.ScopeCallback
import org.koin.dsl.bind
import redis.clients.jedis.JedisPooled
import redis.clients.jedis.UnifiedJedis
import javax.sql.DataSource
fun Module.configureDIDataSource(config: Configuration) {
// PostgreSQL (for EventStore)
single {
hikariDataSource(config)
.apply {
registerCallback(
object : ScopeCallback {
override fun onScopeClose(scope: Scope) {
close()
}
},
)
}
} bind DataSource::class
// Redis (for Projections)
single {
JedisPooled(config.redisUrl)
} bind UnifiedJedis::class
// RabbitMQ (for EventBus)
factory {
ConnectionFactory().apply {
host = config.rabbitmq.url
port = config.rabbitmq.port
username = config.rabbitmq.username
password = config.rabbitmq.password
}
}
}
private fun hikariDataSource(config: Configuration): HikariDataSource =
HikariConfig()
.apply {
jdbcUrl = config.postgresql.url
username = config.postgresql.username
password = config.postgresql.password
maximumPoolSize = 10
minimumIdle = 10
}.let {
HikariDataSource(it)
}
@@ -1,16 +0,0 @@
package eventDemo.configuration
import io.ktor.server.application.Application
import io.ktor.server.application.install
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
fun Application.configureKoin() {
install(Koin) {
slf4jLogger()
modules(
appKoinModule(environment.config.configuration),
)
}
}
@@ -1,11 +0,0 @@
package eventDemo.configuration
import eventDemo.contexts.auth.infrastructure.configure.configureAuth
import eventDemo.contexts.game.infrastructure.configuration.ktor.configureUno
import io.ktor.server.application.Application
fun Application.configure() {
configureKoin()
configureAuth()
configureUno()
}
@@ -0,0 +1,21 @@
package eventDemo.configuration.business
import eventDemo.adapter.infrastructureLayer.event.projection.GameListRepositoryInRedis
import eventDemo.adapter.infrastructureLayer.event.projection.GameStateRepositoryInRedis
import eventDemo.business.command.GameCommandHandler
import eventDemo.business.event.projection.projectionListener.ReactionListener
import org.koin.core.Koin
fun Koin.configureGameListener() {
get<GameCommandHandler>()
.subscribeToBus(get())
get<GameStateRepositoryInRedis>()
.subscribeToBus(get(), get())
get<GameListRepositoryInRedis>()
.subscribeToBus(get(), get())
get<ReactionListener>()
.subscribeToBus(get())
}
@@ -0,0 +1,30 @@
package eventDemo.configuration.injection
import org.koin.dsl.module
fun appKoinModule(config: Configuration) =
module {
configureDIBusiness()
configureDIInfrastructure(config)
configureDILibs()
configureDICommandActions()
}
data class Configuration(
val redisUrl: String,
val postgresql: Postgresql,
val rabbitmq: RabbitMQ,
) {
data class Postgresql(
val url: String,
val username: String,
val password: String,
)
data class RabbitMQ(
val url: String,
val port: Int,
val username: String,
val password: String,
)
}
@@ -0,0 +1,18 @@
package eventDemo.configuration.injection
import eventDemo.business.command.action.ICantPlay
import eventDemo.business.command.action.IWantToJoinTheGame
import eventDemo.business.command.action.IWantToPlayCard
import eventDemo.business.command.action.IamReadyToPlay
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
/**
* Configure all actions
*/
fun Module.configureDICommandActions() {
singleOf(::IWantToPlayCard)
singleOf(::IamReadyToPlay)
singleOf(::IWantToJoinTheGame)
singleOf(::ICantPlay)
}
@@ -0,0 +1,19 @@
package eventDemo.configuration.injection
import eventDemo.business.command.GameCommandActionRunner
import eventDemo.business.command.GameCommandHandler
import eventDemo.business.event.GameEventHandler
import eventDemo.business.event.projection.projectionListener.PlayerNotificationListener
import eventDemo.business.event.projection.projectionListener.ReactionListener
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
fun Module.configureDIBusiness() {
single {
GameCommandHandler(get(), get(), get(), get())
}
singleOf(::GameEventHandler)
singleOf(::GameCommandActionRunner)
singleOf(::PlayerNotificationListener)
singleOf(::ReactionListener)
}
@@ -0,0 +1,73 @@
package eventDemo.configuration.injection
import com.rabbitmq.client.ConnectionFactory
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import eventDemo.adapter.infrastructureLayer.event.GameEventBusInRabbinMQ
import eventDemo.adapter.infrastructureLayer.event.GameEventStoreInPostgresql
import eventDemo.adapter.infrastructureLayer.event.projection.GameListRepositoryInRedis
import eventDemo.adapter.infrastructureLayer.event.projection.GameProjectionBusInRabbitMQ
import eventDemo.adapter.infrastructureLayer.event.projection.GameStateRepositoryInRedis
import eventDemo.business.event.GameEventBus
import eventDemo.business.event.GameEventStore
import eventDemo.business.event.projection.GameListRepository
import eventDemo.business.event.projection.GameProjectionBus
import eventDemo.business.event.projection.GameStateRepository
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.core.scope.Scope
import org.koin.core.scope.ScopeCallback
import org.koin.dsl.bind
import redis.clients.jedis.JedisPooled
import redis.clients.jedis.UnifiedJedis
import javax.sql.DataSource
fun Module.configureDIInfrastructure(config: Configuration) {
// Postgresql config
single {
JedisPooled(config.redisUrl)
} bind UnifiedJedis::class
single {
HikariConfig()
.apply {
jdbcUrl = config.postgresql.url
username = config.postgresql.username
password = config.postgresql.password
maximumPoolSize = 10
minimumIdle = 10
}.let {
HikariDataSource(it)
}.also { datasource ->
registerCallback(
object : ScopeCallback {
override fun onScopeClose(scope: Scope) {
datasource.close()
}
},
)
}
} bind DataSource::class
// RabbitMQ config
factory {
ConnectionFactory().apply {
host = config.rabbitmq.url
port = config.rabbitmq.port
username = config.rabbitmq.username
password = config.rabbitmq.password
}
}
singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class
singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class
singleOf(::GameProjectionBusInRabbitMQ) bind GameProjectionBus::class
single {
GameStateRepositoryInRedis(get())
} bind GameStateRepository::class
single {
GameListRepositoryInRedis(get())
} bind GameListRepository::class
}
@@ -0,0 +1,11 @@
package eventDemo.configuration.injection
import eventDemo.libs.event.VersionBuilder
import eventDemo.libs.event.VersionBuilderLocal
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
fun Module.configureDILibs() {
singleOf(::VersionBuilderLocal) bind VersionBuilder::class
}
@@ -1,21 +1,24 @@
package eventDemo.contexts.auth.infrastructure.configure package eventDemo.configuration.ktor
import com.auth0.jwt.JWT import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.algorithms.Algorithm
import eventDemo.configuration.configuration import eventDemo.business.entity.Player
import eventDemo.contexts.auth.domain.User
import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection
import eventDemo.sharedKernel.UserId
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.auth.authentication import io.ktor.server.auth.authentication
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.response.respond import io.ktor.server.response.respond
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import kotlinx.serialization.json.Json
import java.util.Date import java.util.Date
fun Application.configureKtorAuth() { private const val JWT_ISSUER = "PlayCardGame"
val jwtSecret = environment.config.configuration.jwtSecret
fun Application.configureSecurity() {
val jwtSecret = environment.config.propertyOrNull("jwt.secret")?.getString() ?: error("You must set a jwt secret")
authentication { authentication {
jwt { jwt {
realm = "Play card game" realm = "Play card game"
@@ -26,11 +29,7 @@ fun Application.configureKtorAuth() {
.build(), .build(),
) )
validate { credential -> validate { credential ->
if (credential.payload if (credential.payload.getClaim("username").asString() != "") {
.getClaim("username")
.asString()
.isNotEmpty()
) {
JWTPrincipal(credential.payload) JWTPrincipal(credential.payload)
} else { } else {
null null
@@ -41,25 +40,22 @@ fun Application.configureKtorAuth() {
} }
} }
} }
routing {
post("login/{username}") {
val username = call.parameters["username"]!!
val player = Player(name = username)
call.respond(hashMapOf("token" to player.makeJwt(jwtSecret)))
}
}
} }
private const val JWT_ISSUER = "PlayCardGame" fun Player.makeJwt(jwtSecret: String): String =
fun UserProjection.makeJwt(jwtSecret: String): String =
makeJwt(jwtSecret, id, username)
fun User.makeJwt(jwtSecret: String): String =
makeJwt(jwtSecret, id, username)
fun makeJwt(
jwtSecret: String,
id: UserId,
username: String,
): String =
JWT JWT
.create() .create()
.withIssuer(JWT_ISSUER) .withIssuer(JWT_ISSUER)
.withClaim("username", username) .withClaim("username", name)
.withClaim("userid", id.toString()) .withPayload(Json.encodeToString(this))
.withExpiresAt(Date(System.currentTimeMillis() + 60000)) .withExpiresAt(Date(System.currentTimeMillis() + 60000))
.sign(Algorithm.HMAC256(jwtSecret)) .sign(Algorithm.HMAC256(jwtSecret))
@@ -1,4 +1,4 @@
package eventDemo.contexts.game.infrastructure.configuration.ktor package eventDemo.configuration.ktor
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
@@ -0,0 +1,42 @@
package eventDemo.configuration.ktor
import eventDemo.configuration.injection.Configuration
import eventDemo.configuration.injection.appKoinModule
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.config.ApplicationConfig
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
fun Application.configureKoin() {
install(Koin) {
slf4jLogger()
modules(
appKoinModule(
environment.config.configuration(),
),
)
}
}
fun ApplicationConfig.configuration() =
Configuration(
redisUrl = getProperty("redis.url"),
postgresql =
Configuration.Postgresql(
url = getProperty("postgresql.url"),
username = getProperty("postgresql.username"),
password = getProperty("postgresql.password"),
),
rabbitmq =
Configuration.RabbitMQ(
url = getProperty("rabbitmq.url"),
port = getProperty("rabbitmq.port").toInt(),
username = getProperty("rabbitmq.username"),
password = getProperty("rabbitmq.password"),
),
)
private fun ApplicationConfig.getProperty(path: String): String =
propertyOrNull(path)?.getString() ?: error("You must set the $path")
@@ -1,14 +1,12 @@
package eventDemo.contexts.game.infrastructure.configuration.ktor package eventDemo.configuration.ktor
import eventDemo.contexts.game.domain.game.GameId import eventDemo.business.entity.GameId
import eventDemo.contexts.game.domain.game.Player import eventDemo.business.entity.Player
import eventDemo.contexts.game.infrastructure.persistence.serializers.CommandIdSerializer import eventDemo.configuration.serializer.CommandIdSerializer
import eventDemo.contexts.game.infrastructure.persistence.serializers.EventIdSerializer import eventDemo.configuration.serializer.GameIdSerializer
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer import eventDemo.configuration.serializer.PlayerIdSerializer
import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer import eventDemo.configuration.serializer.UUIDSerializer
import eventDemo.libs.command.CommandId import eventDemo.libs.command.CommandId
import eventDemo.libs.eventSource.EventId
import eventDemo.libs.serializer.UUIDSerializer
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
@@ -31,7 +29,6 @@ fun defaultJsonSerializer(): Json =
SerializersModule { SerializersModule {
contextual(UUID::class) { UUIDSerializer } contextual(UUID::class) { UUIDSerializer }
contextual(GameId::class) { GameIdSerializer } contextual(GameId::class) { GameIdSerializer }
contextual(EventId::class) { EventIdSerializer }
contextual(CommandId::class) { CommandIdSerializer } contextual(CommandId::class) { CommandIdSerializer }
contextual(Player.PlayerId::class) { PlayerIdSerializer } contextual(Player.PlayerId::class) { PlayerIdSerializer }
} }
@@ -1,4 +1,4 @@
package eventDemo.contexts.game.infrastructure.configuration.ktor package eventDemo.configuration.ktor
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install

Some files were not shown because too many files have changed in this diff Show More