Author SHA1 Message Date
flecomte 9bffcd3267 chore: fix postgresql.secret in ci
Tests / build (push) Successful in 6m11s
Tests / test (push) Failing after 14m7s
Tests / lint (push) Successful in 14m37s
2026-07-31 20:19:04 +02:00
224 changed files with 1555 additions and 2638 deletions
-26
View File
@@ -1,26 +0,0 @@
# Version control
.git
.github
# Gradle build outputs / caches (must always be rebuilt fresh inside the image)
.gradle
build/
.kotlin
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea
.vscode
.run
*.iml
*.iws
*.ipr
# Docker-only local files (secrets/env must never be baked into the image)
docker/.env
docker/*.env.docker
docker/*.secret
# Misc
*.hprof
.gradle-docker-cache/
-7
View File
@@ -1,7 +0,0 @@
* text=auto
* eol=lf
*.sh text eol=lf
*.png binary
*.jar binary
gradlew.bat eol=crlf
gradlew text eol=lf
+20 -39
View File
@@ -70,74 +70,55 @@ jobs:
run: chmod +x gradlew run: chmod +x gradlew
- name: Run lint - name: Run lint
# Path scoped to :backend on purpose: :composeApp applies the Android Gradle plugin, run: ./gradlew ktlintCheck
# which needs an Android SDK to even configure. Keeping every gradlew invocation on a
# fully-qualified project path (with org.gradle.configureondemand=true) lets CI skip
# configuring :composeApp entirely, so no Android SDK setup is needed on this runner.
run: ./gradlew :backend:ktlintCheck
- name: Publish ktlint report - name: Publish ktlint report
uses: yutailang0119/action-ktlint@v5 uses: yutailang0119/action-ktlint@v5
if: always() if: always()
with: with:
report-path: backend/build/reports/ktlint/**/*.xml report-path: build/reports/ktlint/**/*.xml
continue-on-error: false continue-on-error: false
test: test:
needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GRADLE_CACHE_DIR: ${{ github.workspace }}/.gradle-docker-cache
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Install a pinned Docker Compose version - name: Set up JDK 21
run: | uses: actions/setup-java@v5
mkdir -p ~/.docker/cli-plugins with:
curl -fSL https://github.com/docker/compose/releases/download/v5.1.4/docker-compose-linux-x86_64 \ distribution: 'temurin'
-o ~/.docker/cli-plugins/docker-compose java-version: '21'
chmod +x ~/.docker/cli-plugins/docker-compose
docker compose version
- name: Prepare docker secrets - name: Restore Gradle cache
run: |
[ -f docker/postgresql.secret ] || echo -n "changeit" > docker/postgresql.secret
- name: Generate cache key
id: cache-key-generator
run: echo "key=gradle-docker-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
- name: Restore Gradle cache (Docker)
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
path: ${{ env.GRADLE_CACHE_DIR }} path: |
key: ${{ steps.cache-key-generator.outputs.key }} ~/.gradle/caches
~/.gradle/wrapper
key: ${{ needs.build.outputs.cache-key }}
restore-keys: | restore-keys: |
gradle-docker-${{ runner.os }}- gradle-${{ runner.os }}-
- name: Prepare cache directory permissions - name: Grant execute permission to Gradle wrapper
run: | run: chmod +x gradlew
mkdir -p "$GRADLE_CACHE_DIR"
chmod -R 777 "$GRADLE_CACHE_DIR"
- name: Run tests in Docker - name: Run tests
run: docker compose -f docker/docker-compose-test.yaml run tests run: ./gradlew test --no-daemon
- name: Shut down Docker services
if: always()
run: docker compose -f docker/docker-compose-test.yaml down -v
- name: Upload test reports - name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: test-results name: test-results
path: backend/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@v3
if: always() if: always()
with: with:
name: JUnit Tests name: JUnit Tests
path: backend/build/test-results/test/TEST-*.xml path: build/test-results/test/TEST-*.xml
reporter: java-junit reporter: java-junit
-7
View File
@@ -1,15 +1,9 @@
.gradle .gradle
.kotlin/
build/ build/
!gradle/wrapper/gradle-wrapper.jar !gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/ !**/src/main/**/build/
!**/src/test/**/build/ !**/src/test/**/build/
### Android / KMP ###
local.properties
captures/
.cxx/
### STS ### ### STS ###
.apt_generated .apt_generated
.classpath .classpath
@@ -43,4 +37,3 @@ out/
/docker/.env /docker/.env
/docker/*.secret /docker/*.secret
*.hprof *.hprof
/.gradle-docker-cache/
-133
View File
@@ -1,133 +0,0 @@
# CLAUDE.md — event-demo
Ce fichier donne le contexte du projet pour toute session Claude Code future sur ce dépôt.
## Vue d'ensemble
`event-demo` est un projet démo personnel (Fabrice Lecomte) qui illustre plusieurs patterns
d'architecture backend :
- Event Sourcing
- Event-Driven (bus d'événements asynchrone)
- CQRS (séparation commandes / projections en lecture)
- Architecture Hexagonale (ports & adapters), un dossier par *bounded context*
Le cas d'usage servant de support est un jeu de cartes façon UNO (créer une partie, rejoindre,
jouer une carte, piocher, etc.), avec authentification des joueurs.
Dépôts distants configurés : `gitea` (auto-hébergé, git.gogn.synology.me — remote historique)
et `github` (`flecomte/event-demo`, miroir). Vérifier vers lequel pousser selon le contexte.
## Stack technique
- **Langage** : Kotlin 2.1.21, JDK 21 (toolchain Gradle)
- **Framework serveur** : Ktor 3.5.1 (Netty), DI via Koin 4.2.1
- **Sérialisation** : kotlinx.serialization (JSON)
- **Persistance** :
- PostgreSQL (event store, via HikariCP) + migrations Flyway (`migrations/events/`)
- RabbitMQ (bus d'événements / bus de commandes, via amqp-client)
- **Auth** : JWT (ktor-server-auth-jwt), hash de mot de passe via password4j
- **Infra dev/prod** : Docker Compose (fichiers `docker/docker-compose-{dev,test,prod}.yaml`
incluant des « parts » réutilisables dans `docker/parts/`), reverse proxy Træfik
- **Tests** : Kotest (runner JUnit5), MockK, kotest-extensions-koin, ArchUnit (test d'architecture)
- **Qualité** : ktlint (`ktlint_official`, standard + experimental activés), reporting checkstyle
- **CI** : GitHub Actions (`.github/workflows/tests.yml`) — build/cache Gradle, `ktlintCheck`,
puis tests exécutés **dans Docker** (`docker compose -f docker/docker-compose-test.yaml run tests`)
- **API** : documentée en OpenAPI (`resources/openapi/documentation.yaml`)
## Architecture
Un dossier par *bounded context* sous `src/main/kotlin/eventDemo/contexts/<context>/`, chacun
strictement découpé en 3 couches :
- `domain/` — aucune dépendance vers les autres couches
- `application/` — ne dépend que de `domain`
- `infrastructure/` — dépend de `domain` et `application`
Contexts actuels :
- **`auth`** : `User`, création de compte, login JWT, event store dédié (Postgresql),
projection utilisateur.
- **`game`** : cœur du jeu — `Card`, `DrawPile`/`DiscardPile`, `Player`, `GameId`, commandes
(`JoinTheGameCommand`, `PlayCardCommand`, `ReadyToPlayCommand`, `TakeCartFromDrawPileCommand`),
state machine du jeu via `sealed interface Game` (`GameInit``GameCreated``GameStarted`
`GameEnded`), notifications, projections (liste de parties), listeners/réactions.
Libs transverses dans `libs/` (indépendantes de tout contexte) :
- `bus/` — abstraction `Bus<E>` avec implémentations in-memory et RabbitMQ (fanout exchange)
- `command/``Command`, `CommandUnicityChecker` (empêche la double exécution d'une commande,
cache glissant de 10 min par défaut)
- `eventSource/``Event`, `EventStream` (append/lecture par version, gestion de
`VersionConflictException`), `EventStore` in-memory / Postgresql
- `helpers/`, `serializer/` — utilitaires (conversion de frames WebSocket, sérialiseurs UUID, etc.)
## Patterns notables dans le code
- **Event sourcing** : `Game.loadFromHistory(events)` reconstruit l'état en repliant
(`fold`) les événements sur une state machine scellée, en utilisant la syntaxe Kotlin 2.1
`when` avec garde `if` (ex. `is GameCreatedEvent if this is GameInit -> applyEvent(event)`).
- **CQRS** : écriture via les command handlers (`application/command/handlers`), lecture via des
projections dédiées (`application/projections`), propagées via le bus RabbitMQ, pas de couplage
direct avec l'écriture.
- **Event-driven** : réactions asynchrones (`ReactionListener`, `EventToNotificationSubscriber`)
déclenchées par le bus RabbitMQ (exchange fanout, une queue par abonné).
- **Exceptions métier** : hiérarchie `GameException` / `IllegalActionException` dans
`domain/game/errors`, une exception par règle métier violée (ex.
`NeedMorePlayersToStartGameException`, `ItsNotTheTurnException`).
## Commandes utiles
```shell
./gradlew build # build complet
./gradlew test # tests (JUnit5 via Kotest)
./gradlew ktlintCheck # lint
./gradlew ktlintFormat # auto-format
./gradlew buildFatJar # jar exécutable "all-in-one" (utilisé par le Dockerfile prod)
# Dépendances seules (Postgres, RabbitMQ, Træfik, pgAdmin...) pour lancer l'app en local hors docker
docker compose -f docker/docker-compose-dev.yaml up -d
# Stack de test façon CI
docker compose -f docker/docker-compose-test.yaml up -d
# ou directement (comme en CI) :
docker compose -f docker/docker-compose-test.yaml run tests
# Stack complète en prod
docker compose -f docker/docker-compose-prod.yaml -p event-demo up -d
```
URLs en dev (voir `doc/installation.md`, nécessite Træfik + résolution des `*.traefik.me`) :
API sur `http://api.traefik.me/`, dashboard
Træfik, pgAdmin et RabbitMQ management exposés via des sous-domaines `traefik.me`.
## Conventions de code
- ktlint en mode `ktlint_official` + règles `standard` et `experimental` activées
(voir `.editorconfig`), indentation **2 espaces**, virgules finales (*trailing commas*)
systématiques, wrapping forcé des expressions/signatures multi-lignes.
- Fins de ligne forcées en **LF** (`.gitattributes`), sauf `gradlew.bat` en CRLF.
- Code et identifiants en anglais.
- Style Kotlin idiomatique/fonctionnel : `fold`, `let`, `apply`, `when` exhaustifs, classes/interfaces
scellées (`sealed class`/`sealed interface`) pour modéliser états et événements plutôt que des enums
avec des champs optionnels.
## Pièges connus / choses à savoir avant de toucher au build ou à la CI
- **MockK/ByteBuddy en Docker** : l'auto-attach dynamique de MockK échoue dans les conteneurs
(le handshake SIGQUIT de l'AttachListener JVM time-out). Le `build.gradle.kts` charge donc
l'agent `byte-buddy-agent` de façon statique via `-javaagent` pour les tâches `Test`, afin
que MockK détecte l'instrumentation déjà présente et saute l'attach dynamique. Ne pas retirer
ce bloc sans repenser l'exécution des tests en Docker.
- **Secret Postgres en CI** : `docker/postgresql.secret` est généré à la volée par le workflow
GitHub Actions s'il n'existe pas (`echo -n "changeit" > docker/postgresql.secret`) — normal,
pas un fichier à committer.
- Les tests « officiels » de la CI tournent **dans Docker**, pas directement via `./gradlew test`
sur l'hôte — en cas de comportement différent entre local et CI, vérifier d'abord les
variables d'environnement/versions du `docker-compose-test.yaml`.
## Historique récent (pour contexte)
Le projet a connu un « Massive refactor to build the V2 » (commit `e2d7942`) : passage d'une
architecture par couches techniques plates (`adapter/presenter/domain`) à l'organisation actuelle
par bounded context (`auth`/`game`) avec 3 couches hexagonales chacune.
+27 -1
View File
@@ -2,6 +2,7 @@ Event Demo
========== ==========
- [Installation](./doc/installation.md) - [Installation](./doc/installation.md)
- [What's the demo for ?](#whats-the-demo-for-) - [What's the demo for ?](#whats-the-demo-for-)
- [What's in this demo](#whats-in-this-demo)
- [The stack](#the-stack) - [The stack](#the-stack)
- [Architecture](./doc/architecture.md) - [Architecture](./doc/architecture.md)
@@ -17,6 +18,31 @@ of different patterns and architectures.
- The CQRS pattern. - The CQRS pattern.
- With the Hexagonal architecture. - With the Hexagonal architecture.
What's in this demo
-------------------
- The **event sourcing** pattern.
- The **event driven** pattern.
- The **CQRS** pattern with **command** and **query**.
- A fully **asynchronous** architecture.Concurently process.
- A **pure Kotlin** implementation of **readmodel**/**projection**.
- A **Redis** implementation of **readmodel**/**projection**.
- A **pure Kotlin** implementation of **Event Store**.
- A **Postgresql** implementation of **Event Store**.
- A **pure Kotlin** implementation of **Event Bus**.
- A **RabbitMQ** implementation of **Event Bus**.
- A **Hexagonal** architecture.
- Use of **Web Sockets**.
- Use of the classic **Rest** route.
- Simple usage of the **JWT**.
- The **Ktor** framework.
- The **Koin** Dependency Injection framework
- Concurrently process.
- Use of coroutines.
- Using **docker compose** for the stack with **traefik**.
- Use of **flyway** to migrate the postgresql schema.
The stack The stack
--------- ---------
@@ -25,11 +51,11 @@ Language
Framework Framework
- Ktor - Ktor
- with Koin for Dependency Injection
Database Database
- Postgresql - Postgresql
- with Flyway - with Flyway
- Redis
- RabbitMQ - RabbitMQ
Infra Infra
-105
View File
@@ -1,105 +0,0 @@
import org.jlleitschuh.gradle.ktlint.KtlintExtension
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version")
val kotlinVersion: Provider<String> = providers.gradleProperty("kotlin_version")
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version")
val logbackVersion: Provider<String> = providers.gradleProperty("logback_version")
val koinVersion: Provider<String> = providers.gradleProperty("koin_version")
val kotlinLoggingVersion: Provider<String> = providers.gradleProperty("kotlin_logging_version")
val kotestVersion: Provider<String> = providers.gradleProperty("kotest_version")
plugins {
application
kotlin("jvm")
id("io.ktor.plugin") version "3.5.1"
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
}
group = "io.github.flecomte"
application {
mainClass.set("eventDemo.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
configure<KtlintExtension> {
version.set("1.8.0")
}
ktlint {
reporters {
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
}
}
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
jvmArgs("-Djdk.attach.allowAttachSelf=true", "-XX:+EnableDynamicAgentLoading")
// Dynamic self-attach (used by MockK/ByteBuddy) times out in Docker containers because the
// SIGQUIT-triggered AttachListener handshake never completes there. Loading the byte-buddy
// agent jar statically via -javaagent avoids the attach handshake entirely: MockK detects the
// pre-installed Instrumentation instance and skips dynamic attach.
doFirst {
val agentJar =
classpath.files.firstOrNull { it.name.startsWith("byte-buddy-agent") }
?: error("byte-buddy-agent jar not found on test classpath")
jvmArgs("-javaagent:$agentJar")
}
}
dependencies {
implementation(project(":shared"))
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-auth-jwt-jvm")
implementation("io.ktor:ktor-server-auto-head-response-jvm")
implementation("io.ktor:ktor-server-resources")
implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("io.ktor:ktor-server-websockets-jvm")
implementation("io.ktor:ktor-server-cors-jvm")
implementation("io.ktor:ktor-server-host-common-jvm")
implementation("io.ktor:ktor-server-status-pages-jvm")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("io.ktor:ktor-server-data-conversion")
implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-auth")
implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}")
implementation("io.insert-koin:koin-ktor:${koinVersion.get()}")
implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}")
implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
implementation("org.postgresql:postgresql:42.7.13")
implementation("com.zaxxer:HikariCP:6.3.0")
implementation("com.rabbitmq:amqp-client:5.25.0")
implementation("com.password4j:password4j:1.8.4")
// Force version of sub library (for security)
implementation("commons-codec:commons-codec:1.13")
testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}")
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}")
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}")
testImplementation("io.mockk:mockk:1.14.11")
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
}
@@ -1,16 +0,0 @@
package eventDemo.libs.eventSource
import eventDemo.shared.ids.AggregateId
import eventDemo.shared.ids.EventId
import kotlinx.datetime.Instant
/**
* The basic interface for an Event
* @see eventDemo.libs.eventSource.eventStore.EventStream
*/
interface Event<ID : AggregateId> {
val eventId: EventId
val aggregateId: ID
val createdAt: Instant
val version: Int
}
@@ -1,66 +0,0 @@
package eventDemo.architecture
import com.tngtech.archunit.core.importer.ClassFileImporter
import com.tngtech.archunit.core.importer.ImportOption
import com.tngtech.archunit.library.Architectures.layeredArchitecture
import org.junit.jupiter.api.Test
/**
* Vérifie le respect des frontières de l'architecture hexagonale (ports & adapters),
* pour chaque bounded context sous `eventDemo.contexts`.
*
* Les contexts ne sont pas listés en dur : ils sont déduits des classes réellement
* présentes sous `eventDemo.contexts.*`, de sorte que l'ajout d'un nouveau context
* (nouveau dossier `eventDemo.contexts.<xxx>`) soit automatiquement couvert par ce test,
* sans modification de ce fichier.
*
* Convention attendue, pour un contexte donné :
* eventDemo.contexts.<context>.domain
* eventDemo.contexts.<context>.application
* eventDemo.contexts.<context>.infrastructure
*
* Règles imposées :
* domain → ne dépend d'aucune autre couche (ni application, ni infrastructure)
* application → ne dépend que de domain (jamais d'infrastructure)
* infrastructure → ne dépend que de domain et application
*/
class HexagonalArchitectureTest {
private val rootPackage = "eventDemo.contexts"
private val classes =
ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages(rootPackage)
// Premier segment de package après "eventDemo.contexts." (ex. "auth", "game", ...),
// recalculé à chaque exécution à partir des classes importées.
private val contexts: Set<String> =
classes
.map { it.packageName }
.filter { it.startsWith("$rootPackage.") }
.map { it.removePrefix("$rootPackage.").substringBefore('.') }
.toSet()
@Test
fun `respecte les couches de l'architecture hexagonale`() {
check(contexts.isNotEmpty()) {
"Aucun context trouvé sous `$rootPackage` : le test ne vérifie rien, " +
"vérifiez que le package racine est correct."
}
contexts.forEach { context ->
val basePackage = "$rootPackage.$context"
@Suppress("ktlint:standard:chain-method-continuation")
layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("$basePackage.domain..")
.layer("Application").definedBy("$basePackage.application..")
.layer("Infrastructure").definedBy("$basePackage.infrastructure..")
.whereLayer("Domain").mayNotAccessAnyLayer()
.whereLayer("Application").mayOnlyAccessLayers("Domain")
.whereLayer("Infrastructure").mayOnlyAccessLayers("Domain", "Application")
.check(classes)
}
}
}
@@ -1,286 +0,0 @@
package eventDemo.contexts.game.application
import eventDemo.Tag
import eventDemo.contexts.auth.application.eventStores.UserRepository
import eventDemo.contexts.auth.domain.User
import eventDemo.contexts.game.application.channels.GameChannelsSubscriber
import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.domain.game.gameState.Game
import eventDemo.contexts.game.domain.game.gameState.GameStarted
import eventDemo.contexts.game.domain.game.gameState.disableRandomForTest
import eventDemo.shared.game.Card
import eventDemo.shared.game.command.GameCommand
import eventDemo.shared.game.notification.ItsTheTurnOfNotification
import eventDemo.shared.game.notification.Notification
import eventDemo.shared.game.notification.PlayerAsJoinTheGameNotification
import eventDemo.shared.game.notification.PlayerAsPlayACardNotification
import eventDemo.shared.game.notification.PlayerWasReadyNotification
import eventDemo.shared.game.notification.TheGameWasStartedNotification
import eventDemo.shared.game.notification.WelcomeToTheGameNotification
import eventDemo.shared.ids.GameId
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.createGameWithCommandsInChannels
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.joinTheGame
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.playCard
import eventDemo.testHelpers.CreateGameWithCommandsInChannelsHelpers.readyToPlay
import eventDemo.testHelpers.createNewUser
import eventDemo.testHelpers.testKoinApplicationWithConfig
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.assertions.nondeterministic.until
import io.kotest.assertions.retry
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.equals.shouldEqual
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.should
import io.kotest.matchers.types.shouldBeInstanceOf
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import org.junit.jupiter.api.assertInstanceOf
import kotlin.test.assertNotNull
import kotlin.time.Duration.Companion.seconds
@DelicateCoroutinesApi
class GameSimulationTest :
FunSpec({
tags(Tag.Postgresql)
test("Simulation of a game") {
should {
retry(maxRetry = 3, timeout = 20.seconds) {
disableRandomForTest()
val gameId = GameId()
val user1 = createNewUser("user1")
val user2 = createNewUser("user2")
val channelCommand1 = Channel<GameCommand>(Channel.BUFFERED)
val channelCommand2 = Channel<GameCommand>(Channel.BUFFERED)
val channelNotification1 = Channel<Notification>(Channel.BUFFERED)
val channelNotification2 = Channel<Notification>(Channel.BUFFERED)
var playedCard1: Card? = null
var playedCard2: Card? = null
var player1HasJoin = false
testKoinApplicationWithConfig {
val gameRepository = get<GameRepository>()
val userRepository = get<UserRepository>()
userRepository.run {
save(user1)
save(user2)
}
gameRepository.create(gameId)
// Run command/notification subscriber
// In the normal process, these subscriber is invoque on players connect to the websocket
GlobalScope.launch(Dispatchers.IO) {
get<GameChannelsSubscriber>().subscribePlayerToGameChannels(
gameId,
user1.id,
channelCommand1,
channelNotification1,
)
}
GlobalScope.launch(Dispatchers.IO) {
get<GameChannelsSubscriber>().subscribePlayerToGameChannels(
gameId,
user2.id,
channelCommand2,
channelNotification2,
)
}
// Consume etch notification of players, and put theses in a list.
// Is used later to control when other players can execute the next action
val player1Notifications = mutableListOf<Notification>()
val player2Notifications = mutableListOf<Notification>()
run {
GlobalScope.launch {
for (notification in channelNotification1) {
player1Notifications.add(notification)
}
}
GlobalScope.launch {
for (notification in channelNotification2) {
player2Notifications.add(notification)
}
}
}
// Player 1 actions
val player1Job =
launch {
createGameWithCommandsInChannels(channelCommand1, gameId, user1) {
joinTheGame()
player1Notifications.waitNotification<WelcomeToTheGameNotification> {
players.map { it.userId }.contains(user1.id)
}
player1HasJoin = true
player1Notifications.waitNotification<PlayerAsJoinTheGameNotification> {
player.userId == user2.id
}
readyToPlay()
player1Notifications.waitNotification<PlayerWasReadyNotification> {
playerId == getPlayer(user2).id
}
playedCard1 =
player1Notifications
.waitNotification<TheGameWasStartedNotification> { hand.size == 7 }
.hand
.first()
.apply {
this.shouldBeInstanceOf<Card.NumericCard>()
number shouldEqual 1
color shouldEqual Card.Color.Red
}
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
if (player.userId == user2.id) error("WRONG PLAYER TURN")
player.userId == user1.id
}
game
.shouldBeInstanceOf<GameStarted>()
.discardPile
.topCard
.shouldNotBeNull()
.shouldBeInstanceOf<Card.NumericCard> {
it.number shouldEqual 0
it.color shouldEqual Card.Color.Red
}
playCard(playedCard1!!)
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
player == getPlayer(user2)
}
player1Notifications.waitNotification<PlayerAsPlayACardNotification> {
playerId == getPlayer(user2).id && card == playedCard2
}
playedCard1 =
assertInstanceOf<GameStarted>(game)
.playableCards(currentPlayer.id)
.first()
playedCard1.run {
this.shouldBeInstanceOf<Card.NumericCard>()
number shouldEqual 2
color shouldEqual Card.Color.Red
}
playCard(playedCard1)
player1Notifications.waitNotification<ItsTheTurnOfNotification> {
player == getPlayer(user2)
}
}
}
// Player 2 actions
val player2Job =
launch {
createGameWithCommandsInChannels(channelCommand2, gameId, user2) {
// wait player 1 has joined the game
until(3.seconds) { player1HasJoin }
joinTheGame()
player2Notifications.waitNotification<WelcomeToTheGameNotification> {
players.map { it.userId }.contains(user1.id) &&
players.map { it.userId }.contains(user2.id)
}
player2Notifications.waitNotification<PlayerWasReadyNotification> { playerId == getPlayer(user1).id }
readyToPlay()
playedCard2 =
player2Notifications
.waitNotification<TheGameWasStartedNotification> { hand.size == 7 }
.hand
.first()
.apply {
this.shouldBeInstanceOf<Card.NumericCard>()
number shouldEqual 8
color shouldEqual Card.Color.Red
}
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
if (player.userId == user2.id) error("WRONG PLAYER TURN")
player.userId == user1.id
}
player2Notifications.waitNotification<PlayerAsPlayACardNotification> {
playerId == getPlayer(user1).id && card == playedCard1
}
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
player == currentPlayer
}
game
.shouldBeInstanceOf<GameStarted>()
.discardPile
.topCard
.shouldNotBeNull()
.shouldBeInstanceOf<Card.NumericCard> {
it.number shouldEqual 1
it.color shouldEqual Card.Color.Red
}
playCard(playedCard2)
player2Notifications.waitNotification<ItsTheTurnOfNotification> {
player.userId == user1.id
}
player2Notifications.waitNotification<PlayerAsPlayACardNotification> {
playerId == currentPlayer.id && card == playedCard2
}
}
}
// Wait the end of the game
joinAll(player1Job, player2Job)
// Build the last state from the event store
val game = gameRepository.get(gameId)
assertInstanceOf<GameStarted>(game)
// Check if the state is correct
game.aggregateId shouldBeEqual gameId
game.players.map { it.userId } shouldContainExactly setOf(user1.id, user2.id)
assertNotNull(game.players.find { it.userId == user1.id })
.hand.size shouldBeEqual 5
assertNotNull(game.players.find { it.userId == user2.id })
.hand.size shouldBeEqual 6
game.direction shouldBeEqual Game.Direction.CLOCKWISE
assertNotNull(game.lastPlayer?.userId) shouldBeEqual user1.id
assertNotNull(game.discardPile.topCard) shouldBeEqual assertNotNull(playedCard1)
}
}
}
}
})
context(user: User)
private suspend inline fun <reified T : Notification> MutableList<Notification>.waitNotification(crossinline block: T.() -> Boolean): T {
println("NOTIFICATION WAITING: ${T::class.simpleName} for user: ${user.username}")
return eventually(5.seconds) {
filterIsInstance<T>()
.first { block(it) }
.also { remove(it) }
}.also { println("NOTIFICATION RECEIVED: ${T::class.simpleName} for user: ${user.username}") }
}
@@ -1,44 +0,0 @@
package eventDemo.libs.bus
import com.rabbitmq.client.ConnectionFactory
import eventDemo.testHelpers.spyPing
import eventDemo.testHelpers.testKoinApplicationWithConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData
import io.kotest.matchers.string.shouldStartWith
import kotlin.random.Random
import kotlin.time.Duration.Companion.seconds
private data class ObjTest(
val value: String,
)
class BusTest :
FunSpec({
context("Pub/sub") {
testKoinApplicationWithConfig {
val list: Map<String, Bus<ObjTest>> =
mapOf(
BusInMemory::class.java.simpleName to BusInMemory(),
BusInRabbitMQ::class.java.simpleName to
BusInRabbitMQ(
get<ConnectionFactory>(),
"testExchange",
{ it.value },
{ ObjTest(it) },
),
)
withData(list) { bus ->
spyPing(exactly = 2, duration = 1.seconds) { ping ->
bus.subscribe { obj ->
ping()
obj.value shouldStartWith "testMessage"
}
bus.publish(ObjTest("testMessage${Random.nextInt()}"))
bus.publish(ObjTest("testMessage${Random.nextInt()}"))
}
}
}
}
})
+141 -11
View File
@@ -1,16 +1,146 @@
buildscript { import org.jlleitschuh.gradle.ktlint.KtlintExtension
configurations.classpath {
resolutionStrategy { val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version")
force("org.jetbrains:annotations:23.0.0") val kotlinVersion: Provider<String> = providers.gradleProperty("kotlin_version")
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version")
val logbackVersion: Provider<String> = providers.gradleProperty("logback_version")
val koinVersion: Provider<String> = providers.gradleProperty("koin_version")
val kotlinLoggingVersion: Provider<String> = providers.gradleProperty("kotlin_logging_version")
val kotestVersion: Provider<String> = providers.gradleProperty("kotest_version")
plugins {
application
kotlin("jvm") version "2.1.21"
id("io.ktor.plugin") version "3.5.1"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
id("com.avast.gradle.docker-compose") version "0.17.12"
} }
group = "io.github.flecomte"
application {
mainClass.set("eventDemo.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
configure<KtlintExtension> {
version.set("1.8.0")
}
ktlint {
reporters {
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
} }
} }
plugins { repositories {
kotlin("multiplatform") version "2.1.21" apply false mavenCentral()
kotlin("plugin.serialization") version "2.4.10" apply false }
kotlin("plugin.compose") version "2.1.21" apply false
id("com.android.application") version "9.2.0" apply false java {
id("com.android.library") version "9.2.0" apply false toolchain {
id("org.jetbrains.compose") version "1.8.2" apply false languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dockerCompose {
val composeFile =
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")
}
tasks.test {
dependsOn("composeUp")
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
dockerCompose.setProjectName("event-demo-test")
}
tasks.named("run") {
dependsOn("composeUp")
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
dockerCompose.setProjectName("event-demo-dev")
}
tasks.register<Copy>("copyEnv") {
group = "docker"
description = "copy the default dotenv file"
from("docker")
into("docker")
rename {
it.removeSuffix(".template")
}
include(".env.template")
eachFile {
if (File("docker/$name").exists()) {
exclude()
}
}
val files =
listOf(
File("docker/pgadmin.secret"),
File("docker/postgresql.secret"),
)
outputs.files(*files.toTypedArray())
doLast {
files.forEach {
if (!it.exists()) {
it.writeText("changeit")
}
}
}
}
tasks.composeUp {
dependsOn("copyEnv")
}
dependencies {
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-auth-jwt-jvm")
implementation("io.ktor:ktor-server-auto-head-response-jvm")
implementation("io.ktor:ktor-server-resources")
implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("io.ktor:ktor-server-websockets-jvm")
implementation("io.ktor:ktor-server-cors-jvm")
implementation("io.ktor:ktor-server-host-common-jvm")
implementation("io.ktor:ktor-server-status-pages-jvm")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("io.ktor:ktor-server-data-conversion")
implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-auth")
implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}")
implementation("io.insert-koin:koin-ktor:${koinVersion.get()}")
implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}")
implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
implementation("redis.clients:jedis:5.2.0")
implementation("org.postgresql:postgresql:42.7.13")
implementation("com.zaxxer:HikariCP:6.3.0")
implementation("com.rabbitmq:amqp-client:5.25.0")
implementation("com.password4j:password4j:1.8.4")
// Force version of sub library (for security)
implementation("commons-codec:commons-codec:1.13")
testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}")
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}")
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}")
testImplementation("io.mockk:mockk:1.13.17")
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
} }
-135
View File
@@ -1,135 +0,0 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version")
val kotlinxCoroutinesVersion: Provider<String> = providers.gradleProperty("kotlinx_coroutines_version")
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version")
val navigationComposeVersion: Provider<String> = providers.gradleProperty("navigation_compose_version")
val androidCompileSdk: Provider<String> = providers.gradleProperty("android_compile_sdk")
val androidTargetSdk: Provider<String> = providers.gradleProperty("android_target_sdk")
val androidMinSdk: Provider<String> = providers.gradleProperty("android_min_sdk")
plugins {
kotlin("multiplatform")
kotlin("plugin.compose")
kotlin("plugin.serialization")
id("org.jetbrains.compose")
id("com.android.application")
}
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
}
}
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
}
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
outputModuleName = "eventDemoApp"
browser {
commonWebpackConfig {
outputFileName = "eventDemoApp.js"
}
}
binaries.executable()
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
}
sourceSets {
commonMain.dependencies {
implementation(project(":shared"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation("org.jetbrains.androidx.navigation:navigation-compose:${navigationComposeVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${kotlinxCoroutinesVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${kotlinSerializationVersion.get()}")
implementation("io.ktor:ktor-client-core:${ktorVersion.get()}")
implementation("io.ktor:ktor-client-content-negotiation:${ktorVersion.get()}")
implementation("io.ktor:ktor-client-websockets:${ktorVersion.get()}")
implementation("io.ktor:ktor-serialization-kotlinx-json:${ktorVersion.get()}")
}
androidMain.dependencies {
implementation("androidx.activity:activity-compose:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${kotlinxCoroutinesVersion.get()}")
implementation("io.ktor:ktor-client-okhttp:${ktorVersion.get()}")
}
getByName("desktopMain").dependencies {
implementation(compose.desktop.currentOs)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:${kotlinxCoroutinesVersion.get()}")
implementation("io.ktor:ktor-client-cio:${ktorVersion.get()}")
}
wasmJsMain.dependencies {
implementation("io.ktor:ktor-client-js:${ktorVersion.get()}")
}
}
}
android {
namespace = "io.github.flecomte.eventdemo.app"
compileSdk = androidCompileSdk.get().toInt()
defaultConfig {
applicationId = "io.github.flecomte.eventdemo.app"
minSdk = androidMinSdk.get().toInt()
targetSdk = androidTargetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
compose.desktop {
application {
mainClass = "eventDemo.app.MainKt"
nativeDistributions {
targetFormats(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb,
)
packageName = "EventDemo"
packageVersion = "1.0.0"
}
}
}
@@ -1,23 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="EventDemo"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,14 +0,0 @@
package eventDemo.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}
@@ -1,154 +0,0 @@
package eventDemo.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import eventDemo.app.network.ApiClient
import eventDemo.shared.game.projection.GameList
import kotlinx.coroutines.launch
private enum class Screen { LOGIN, GAMES }
@Composable
fun App() {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val apiClient = remember { ApiClient() }
var screen by remember { mutableStateOf(Screen.LOGIN) }
when (screen) {
Screen.LOGIN -> LoginScreen(apiClient) { screen = Screen.GAMES }
Screen.GAMES -> GamesScreen(apiClient) { screen = Screen.LOGIN }
}
}
}
}
@Composable
private fun LoginScreen(
apiClient: ApiClient,
onLoggedIn: () -> Unit,
) {
val scope = rememberCoroutineScope()
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("EventDemo", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp))
}
Button(
onClick = {
errorMessage = null
isLoading = true
scope.launch {
apiClient
.login(username, password)
.onSuccess { onLoggedIn() }
.onFailure { errorMessage = it.message ?: "Login failed" }
isLoading = false
}
},
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.padding(top = 16.dp),
) {
Text(if (isLoading) "Signing in..." else "Sign in")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GamesScreen(
apiClient: ApiClient,
onLoggedOut: () -> Unit,
) {
val scope = rememberCoroutineScope()
var games by remember { mutableStateOf<List<GameList>>(emptyList()) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(true) }
fun refresh() {
isLoading = true
scope.launch {
apiClient
.listGames()
.onSuccess { games = it }
.onFailure { errorMessage = it.message ?: "Could not load games" }
isLoading = false
}
}
remember { refresh() }
Scaffold(
topBar = { TopAppBar(title = { Text("Games") }) },
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
when {
isLoading -> CircularProgressIndicator()
errorMessage != null -> Text(errorMessage ?: "")
games.isEmpty() -> Text("No game yet.")
else ->
LazyColumn {
items(games) { game ->
Text("${game.aggregateId} - ${game.status} - ${game.players.size} player(s)")
}
}
}
Button(onClick = { refresh() }, modifier = Modifier.padding(top = 16.dp)) {
Text("Refresh")
}
Button(
onClick = {
apiClient.logout()
onLoggedOut()
},
modifier = Modifier.padding(top = 8.dp),
) {
Text("Sign out")
}
}
}
}
@@ -1,63 +0,0 @@
package eventDemo.app.network
import eventDemo.shared.game.projection.GameList
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Talks to the Ktor backend over plain HTTP.
* `baseUrl` defaults to the dev Traefik route documented in doc/installation.md.
*/
class ApiClient(
private val baseUrl: String = "http://api.traefik.me",
) {
private var token: String? = null
private val client =
HttpClient {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
},
)
}
}
val isAuthenticated: Boolean
get() = token != null
suspend fun login(
username: String,
password: String,
): Result<Unit> =
runCatching {
val response: Map<String, String> =
client
.post("$baseUrl/login/$username") {
parameter("password", password)
}.body()
token = response["token"] ?: error("Missing token in login response")
}
suspend fun listGames(): Result<List<GameList>> =
runCatching {
val currentToken = token ?: error("Not authenticated")
client
.get("$baseUrl/games") {
header(HttpHeaders.Authorization, "Bearer $currentToken")
}.body()
}
fun logout() {
token = null
}
}
@@ -1,11 +0,0 @@
package eventDemo.app
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() =
application {
Window(onCloseRequest = ::exitApplication, title = "EventDemo") {
App()
}
}
@@ -1,12 +0,0 @@
package eventDemo.app
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport(document.body!!) {
App()
}
}
@@ -1,17 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>EventDemo</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
}
</style>
</head>
<body>
<script src="eventDemoApp.js"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
Architecture
============
The Workflow
------------
![Workflow Diagram](./schemas/Workflow.svg)
The business Entities
---------------------
![Entities and Projections Diagram](./schemas/Entities.svg)
+67
View File
@@ -0,0 +1,67 @@
# 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
```
+5 -15
View File
@@ -1,33 +1,23 @@
Installation Installation
============ ============
To run the stack in production: To run the stack:
```shell ```shell
docker compose -f docker\docker-compose-prod.yaml -p event-demo up -d docker compose -f docker\docker-compose-prod.yaml -p event-demo up -d
``` ```
To run only the app dependencies in development mode and run the app localy (not in docker):
```shell
docker compose -f docker\docker-compose-dev.yaml up -d
```
To run the tests in docker (it's designed for the CI):
```shell
docker compose -f docker\docker-compose-test.yaml up -d
```
Api url: Api url:
- [Backend API](http://api.traefik.me/) - [Backend API](http://api.traefik.me/)
- [Frontend web site](http://app.traefik.me/) - [Frontend web site](http://app.traefik.me/) (WIP)
Exposed url on dev env: Exposed url on test env:
- [PostgreSql](http://localhost:5432/) - [PostgreSql](http://localhost:5432/)
- [Redis](http://localhost:6379/)
- [RabbitMQ](http://localhost:15672/) - [RabbitMQ](http://localhost:15672/)
Admin service URL: Admin service URL:
- [Træfik dashboard](http://traefik.traefik.me/) - [Træfik dashboard](http://traefik.traefik.me/)
- [Redis insight](http://insight.redis.traefik.me/)
- [pgAdmin](http://pgadmin.postgresql.traefik.me/) - [pgAdmin](http://pgadmin.postgresql.traefik.me/)
- [RabbitMQ management](http://management.rabbitmq.traefik.me/) - [RabbitMQ management](http://management.rabbitmq.traefik.me/)
+93
View File
@@ -0,0 +1,93 @@
@startuml
'https://plantuml.com/class-diagram
left to right direction
class GameList <<Projection>> {
+ status: Status
}
class GameState <<Projection>> {
+ players: List<Player>
+ currentPlayerTurn: Player
+ lastCardPlayer: Player
+ colorOnCurrentStack: Color
+ direction: Direction
+ readyPlayers: List<Player>
+ deck: Deck
+ isStarted: Boolean
+ playerWins: List<Player>
}
interface Card {
+ id: UUID
}
enum Color {
+ Blue
+ Red
+ Yellow
+ Green
}
class GameId {
+ id: UUID
}
class Player {
+ id: PlayerId
+ name: String
}
class Deck {
+ stack: Stack
+ discard: Discard
+ playersHands: PlayersHands
}
class Stack {
+ cards: List<Card>
+ shuffle()
}
class Discard {
+ cards: List<Card>
}
class PlayerHands {
+ map: Map<PlayerId, List<Card>>
}
class NumericCard {
+ number: Int
+ color: Color
}
class ReverseCard {
+ color: Color
}
class PassCard {
+ color: Color
}
class Plus2Card {
+ color: Color
}
class Plus4Card
class ChangeColorCard
GameState *-- Deck
GameState o-- "many" Player
Deck *-- PlayerHands
PlayerHands *-- "many" Card
PlayerHands o-- "many" Player
Stack *-- "many" Card
Discard *-- "many" Card
Deck *-- Stack
Deck *-- Discard
GameState *-- GameId
Card <|--- NumericCard
Card <|--- ReverseCard
Card <|--- PassCard
Card <|--- ChangeColorCard
Card <|--- Plus2Card
Card <|--- Plus4Card
ReverseCard o-- Color
NumericCard o-- Color
PassCard o-- Color
Plus2Card o-- Color
GameList *-- GameId
GameList o-- "many" Player
@enduml
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

+84
View File
@@ -0,0 +1,84 @@
@startuml
'https://plantuml.com/use-case-diagram
package Legend {
usecase (Queries) #7693C4
usecase (Projections) #AB64C9
usecase (Events) #5FAD56
}
actor User
entity Query #7693C4
entity Command #5FAD56
entity Event #5FAD56
entity Projection #AB64C9
database Postgresql
database Redis
queue RabbitMQ
usecase (Web socket adapter) #5FAD56
usecase (Command handler) #5FAD56
usecase/ (Action) #5FAD56
usecase (Event handler) #5FAD56
usecase (Version builder) #5FAD56
usecase (Event store) #5FAD56
usecase (Event stream) #5FAD56
usecase (Event bus) #5FAD56
usecase/ (Reaction listener) #5FAD56
usecase/ (Projection builder) #AB64C9
usecase (Projection repository) #AB64C9
usecase (Projection bus) #AB64C9
usecase (Controller) #7693C4
User -> Query : <<create>>
Command <- User : <<create>>
User ---> (Controller) : Get \nprojection
User <-- (Controller) : Returns \nprojection
(Controller) --------> (Projection repository) : Get \nprojection
User -> (Web socket adapter) : Send \ncommand
(Web socket adapter) --> (Command handler) : Send \ncommand
(Web socket adapter) ...> User : Send notification \n(error or success)
(Command handler) ..> (Web socket adapter) : Send \nnotification
(Command handler) -> (Action) : Execute action
(Command handler) <- (Action) : Returns \nevent builder
(Command handler) ---> (Event handler) : Dispatch \nevent \n(send an event builder)
(Command handler) --> Event : <<Create>>
(Event handler) --> (Event store) : Publish \nevent
(Event handler) <-- (Reaction listener) : Dispatch \n new event
(Version builder) <- (Event handler) : build next version
note "Acquire a lock, \nget the next event version, \nand then, build the event " as EventHandlerNote
EventHandlerNote <-- (Event handler)
(Event store) -left-> (Event stream)
(Event store) ---> (Event bus) : Publish \nevent
(Event stream) --> Postgresql : Persist \nevent
(Event bus) -> RabbitMQ : Publish \nevent
(Event bus) -> RabbitMQ : Subscribe \nto event
(Event bus) <. RabbitMQ : Emit event
(Reaction listener) ---> (Projection bus) : Subscribe
(Reaction listener) <.. (Projection bus) : Emit projection
(Projection bus) <- (Projection repository) : Publish \nprojection
RabbitMQ <- (Projection bus) : Publish \nprojection
RabbitMQ <- (Projection bus) : Subscribe \nto projection
RabbitMQ .> (Projection bus) : Emit projection
(Event bus) <---- (Projection repository) : Subscribe
(Event bus) ..> (Projection repository) : Emit event
(Projection repository) --> Redis : Persist \nprojection
(Projection repository) <- Redis : Get \nprojection
(Projection repository) -> (Projection builder) : Build \nprojection
(Projection builder) --> Projection : <<create projection>>
@enduml
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

-2
View File
@@ -1,2 +0,0 @@
POSTGRESQL_URL=jdbc:postgresql://postgresql/event-demo
RABBITMQ_URL=rabbitmq
+1
View File
@@ -0,0 +1 @@
PGADMIN_DEFAULT_EMAIL=
+7 -9
View File
@@ -1,25 +1,23 @@
# Stage 1: Cache Gradle dependencies # Stage 1: Cache Gradle dependencies
FROM gradle:9.6.1-jdk21-alpine AS cache FROM gradle:latest AS cache
RUN mkdir -p /home/gradle/cache_home RUN mkdir -p /home/gradle/cache_home
ENV GRADLE_USER_HOME=/home/gradle/cache_home ENV GRADLE_USER_HOME=/home/gradle/cache_home
COPY settings.gradle.kts build.gradle.kts gradle.properties /home/gradle/app/ COPY build.gradle.* gradle.properties /home/gradle/app/
COPY backend/build.gradle.kts /home/gradle/app/backend/
COPY shared/build.gradle.kts /home/gradle/app/shared/
WORKDIR /home/gradle/app WORKDIR /home/gradle/app
RUN gradle :backend:build -i -x check RUN gradle build -i -x check
# Stage 2: Build Application # Stage 2: Build Application
FROM gradle:9.6.1-jdk21-alpine AS build FROM gradle:latest AS build
COPY --from=cache /home/gradle/cache_home /home/gradle/.gradle COPY --from=cache /home/gradle/cache_home /home/gradle/.gradle
COPY --chown=gradle:gradle . /home/gradle/src COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src WORKDIR /home/gradle/src
# Build the fat JAR, Gradle also supports shadow # Build the fat JAR, Gradle also supports shadow
# and boot JAR by default. # and boot JAR by default.
RUN gradle :backend:buildFatJar --no-daemon RUN gradle buildFatJar --no-daemon
# Stage 3: Create the Runtime Image # Stage 3: Create the Runtime Image
FROM eclipse-temurin:21-jre-alpine AS runtime FROM amazoncorretto:21 AS runtime
EXPOSE 8080 EXPOSE 8080
RUN mkdir /app RUN mkdir /app
COPY --from=build /home/gradle/src/backend/build/libs/*-all.jar /app/event-demo-all.jar COPY --from=build /home/gradle/src/build/libs/*.jar /app/event-demo-all.jar
ENTRYPOINT ["java","-jar","/app/event-demo-all.jar"] ENTRYPOINT ["java","-jar","/app/event-demo-all.jar"]
-14
View File
@@ -1,14 +0,0 @@
# Image officielle Gradle avec JDK 21 déjà installé
FROM gradle:9.6.1-jdk21
WORKDIR /app
# Copie du wrapper et des fichiers de config en premier pour profiter du cache Docker
COPY build.gradle.kts settings.gradle.kts gradle.properties ./
# Lance les tests Kotlin.
# Scope volontairement limite a :backend et :shared : :composeApp applique le plugin Android
# (com.android.application), qui echoue a la configuration sans SDK Android installe dans
# cette image de test. Ne pas passer a un `gradle test` non scope sans ajouter le SDK Android
# a l'image, ou sans configuration-on-demand garantissant que :composeApp n'est pas evalue.
CMD ["gradle", ":backend:test", ":shared:test", "--no-daemon"]
+6
View File
@@ -0,0 +1,6 @@
name: event-demo-test
include:
- path:
- parts/docker-compose-databases.yaml
- parts/docker-compose-databases-expose.yaml
- parts/docker-compose-traefik.yaml
-17
View File
@@ -1,17 +0,0 @@
name: event-demo-dev
include:
- path:
- parts/docker-compose-databases.yaml
- parts/docker-compose-databases-expose.yaml
- parts/docker-compose-tools.yaml
- parts/docker-compose-tools-local.yaml
- parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD: "changeit"
pgadmin:
environment:
PGADMIN_DEFAULT_PASSWORD: "changeit"
-13
View File
@@ -5,16 +5,3 @@ include:
- parts/docker-compose-app.yaml - parts/docker-compose-app.yaml
- parts/docker-compose-tools.yaml - parts/docker-compose-tools.yaml
- parts/docker-compose-traefik.yaml - parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
volumes:
- ./postgresql.secret:/run/secrets/postgresql_password:ro
pgadmin:
environment:
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
volumes:
- ./pgadmin.secret:/run/secrets/pgadmin_password:ro
+2 -6
View File
@@ -2,10 +2,6 @@ name: event-demo-test
include: include:
- path: - path:
- parts/docker-compose-databases.yaml - parts/docker-compose-databases.yaml
- parts/docker-compose-test.yaml - parts/docker-compose-databases-expose.yaml
- parts/docker-compose-tools.yaml
- parts/docker-compose-traefik.yaml - parts/docker-compose-traefik.yaml
services:
postgresql:
environment:
POSTGRES_PASSWORD: "changeit"
+2 -2
View File
@@ -10,8 +10,8 @@ services:
condition: service_healthy condition: service_healthy
rabbitmq: rabbitmq:
condition: service_healthy condition: service_healthy
env_file: redis:
- ../.env.docker condition: service_healthy
labels: labels:
- "traefik.http.routers.api.rule=Host(`api.traefik.me`)" - "traefik.http.routers.api.rule=Host(`api.traefik.me`)"
- "traefik.http.services.api.loadbalancer.server.port=8080" - "traefik.http.services.api.loadbalancer.server.port=8080"
@@ -1,4 +1,8 @@
services: services:
redis:
ports:
- "6379:6379"
postgresql: postgresql:
ports: ports:
- "5432:5432" - "5432:5432"
@@ -1,4 +1,12 @@
services: services:
redis:
image: redis/redis-stack:7.4.0-v8
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
volumes:
- redis_data:/data
- redisinsight_data:/redisinsight
flyway: flyway:
image: flyway/flyway image: flyway/flyway
command: migrate command: migrate
@@ -14,7 +22,10 @@ services:
image: postgres:18.4 image: postgres:18.4
command: postgres -c 'max_connections=500' command: postgres -c 'max_connections=500'
environment: environment:
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
@@ -36,6 +47,12 @@ services:
volumes: volumes:
- rabbitmq_data:/var/lib/rabbitmq/ - rabbitmq_data:/var/lib/rabbitmq/
secrets:
postgresql_password:
file: ../postgresql.secret
volumes: volumes:
redis_data:
redisinsight_data:
postgresql_data: postgresql_data:
rabbitmq_data: rabbitmq_data:
-20
View File
@@ -1,20 +0,0 @@
services:
tests:
build:
context: ../..
dockerfile: docker/DockerfileTest
volumes:
- ${GRADLE_CACHE_DIR:-gradle-cache}:/home/gradle/.gradle
- ../..:/app
depends_on:
flyway:
condition: service_completed_successfully
postgresql:
condition: service_healthy
rabbitmq:
condition: service_healthy
env_file:
- ../.env.docker
volumes:
gradle-cache:
@@ -1,18 +0,0 @@
services:
pgadmin:
environment:
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
configs:
- source: pgpass
target: /pgpass
mode: 0600
uid: "5050"
gid: "5050"
- source: servers_json
target: /pgadmin4/servers.json
configs:
pgpass:
content: |
*:*:*:event-demo:changeit
+24 -1
View File
@@ -2,16 +2,32 @@ services:
pgadmin: pgadmin:
image: dpage/pgadmin4 image: dpage/pgadmin4
environment: environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@event-demo.dev} PGADMIN_DEFAULT_EMAIL: $PGADMIN_DEFAULT_EMAIL
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
secrets:
- pgadmin_password
volumes: volumes:
- pgadmin_data:/var/lib/pgadmin - pgadmin_data:/var/lib/pgadmin
configs: configs:
- source: pgpass
target: /pgpass
mode: 0600
uid: "5050"
gid: "5050"
- source: servers_json - source: servers_json
target: /pgadmin4/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"
redis:
labels:
- "traefik.http.routers.redisinsight.rule=Host(`insight.redis.traefik.me`)"
- "traefik.http.routers.redisinsight.service=redisinsight"
- "traefik.http.services.redisinsight.loadbalancer.server.port=8001"
rabbitmq: rabbitmq:
labels: labels:
- "traefik.http.routers.rabbitmq-management.rule=Host(`management.rabbitmq.traefik.me`)" - "traefik.http.routers.rabbitmq-management.rule=Host(`management.rabbitmq.traefik.me`)"
@@ -19,6 +35,9 @@ services:
- "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672" - "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672"
configs: configs:
pgpass:
content: |
*:*:*:event-demo:changeit
servers_json: servers_json:
content: | content: |
{ {
@@ -36,5 +55,9 @@ configs:
} }
} }
secrets:
pgadmin_password:
file: ../pgadmin.secret
volumes: volumes:
pgadmin_data: pgadmin_data:
-19
View File
@@ -6,23 +6,4 @@ koin_version=4.2.1
kotlin_logging_version=8.0.4 kotlin_logging_version=8.0.4
kotest_version=6.2.2 kotest_version=6.2.2
compose_multiplatform_version=1.8.2
agp_version=9.2.0
kotlinx_coroutines_version=1.10.2
navigation_compose_version=2.9.2
android_compile_sdk=36
android_target_sdk=36
android_min_sdk=24
kotlin.code.style=official kotlin.code.style=official
org.gradle.jvmargs=-Xmx4g
kotlin.daemon.jvmargs=-Xmx4g
org.gradle.configureondemand=true
android.useAndroidX=true
android.nonTransitiveRClass=true
# AGP 9.x's classic com.android.library/application plugins no longer work together with
# org.jetbrains.kotlin.multiplatform in the same module (they require the newer
# com.android.kotlin.multiplatform.library plugin instead). Until shared/composeApp are migrated
# to that new plugin, this AGP-recommended flag keeps the classic DSL usable.
android.builtInKotlin=false
android.newDsl=false
-18
View File
@@ -1,18 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@js-joda/core@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
format-util@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271"
integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==
ws@8.20.1:
version "8.20.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb"
integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==
+40 -644
View File
@@ -1,264 +1,58 @@
openapi: "3.0.3" openapi: "3.0.3"
info: info:
title: "event_demo API" title: "event_demo API"
description: | description: "event_demo API"
API for the event_demo project: a small event-sourced Uno-like card game. version: "1.0.0"
The API is split in two parts:
- a classic REST API used to authenticate, create users, list games and
replay a game's history.
- a WebSocket endpoint (`/games/{id}`) used to play a game in real time:
the client sends `GameCommand` messages and receives `Notification`
messages back. See the `x-websocket-channels` section below for details,
since WebSockets are not natively described by OpenAPI 3.0.
Authentication is done with a JWT bearer token obtained from `/login/{username}`.
Note that most polymorphic messages (`GameCommand`, `Notification`) are
discriminated by a `type` field whose value is the fully-qualified Kotlin
class name of the payload (since no custom serial name is declared for
these types), while `Card` uses short discriminator values
(`Simple`, `Reverse`, `Pass`, `Plus2`, `Plus4`, `ChangeColor`).
version: "2.0.0"
servers: servers:
- url: "https://event_demo" - url: "https://event_demo"
tags:
- name: Auth
description: User registration and authentication
- name: Games
description: Listing games and reading/playing their state
security:
- bearerAuth: []
paths: paths:
"/login/{username}": "/game/{id}/card/last":
post:
tags: [Auth]
summary: Log in and obtain a JWT
security: []
parameters:
- name: username
in: path
required: true
schema:
type: string
- name: password
in: query
description: The user's plain-text password.
required: true
schema:
type: string
responses:
200:
description: Successful login
content:
application/json:
schema:
$ref: "#/components/schemas/LoginResponse"
400:
description: Unknown username or invalid password
"/users/create":
post:
tags: [Auth]
summary: Create a new user account
description: Requires a valid JWT (any authenticated user can create new users).
parameters:
- name: username
in: query
required: true
schema:
type: string
- name: password
in: query
description: The plain-text password, hashed server-side before being stored.
required: true
schema:
type: string
responses:
200:
description: The newly created user
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserResponse"
401:
$ref: "#/components/responses/Unauthorized"
"/games":
get: get:
tags: [Games] description: get the last card played
summary: List all known games
description: Returns up to the 100 most recent games (pagination is not yet exposed on this route).
responses: responses:
200: 200:
description: The list of games description: The last card
content: content:
application/json: application/json:
schema: schema:
type: array
items:
$ref: "#/components/schemas/GameList"
401:
$ref: "#/components/responses/Unauthorized"
"/games/{id}":
get:
tags: [Games]
summary: Replay a game's full notification history
description: |
Rebuilds every notification that would have been sent to the calling
player since the beginning of the game (from its event stream), so a
client reconnecting can catch up on the current game state.
This same path also accepts a WebSocket upgrade to play the game live,
see `x-websocket-channels` at the root of this document.
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
description: The full list of notifications for this game, from this player's point of view
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Notification"
401:
$ref: "#/components/responses/Unauthorized"
404:
description: No game found for this id
x-websocket-channels:
"/games/{id}":
description: |
WebSocket endpoint to join and play a game in real time. Requires the
same JWT bearer authentication as the REST routes (sent the same way,
e.g. via the `Authorization` header during the WebSocket handshake).
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
send:
description: Commands sent by the client to act on the game.
schema:
$ref: "#/components/schemas/GameCommand"
receive:
description: Notifications sent by the server as the game progresses.
schema:
$ref: "#/components/schemas/Notification"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: >
JWT obtained from `POST /login/{username}`. It carries the `username`
and `userid` claims and currently expires 60 seconds after issuance.
responses:
Unauthorized:
description: Missing, invalid or expired JWT
content:
text/plain:
schema:
type: string
example: "Token is not valid or has expired"
schemas:
LoginResponse:
type: object
required: [token]
properties:
token:
type: string
description: JWT bearer token to use on subsequent requests.
CreateUserResponse:
type: object
required: [id]
properties:
id:
type: string
format: uuid
HttpErrorBadRequest:
type: object
description: Generic problem-details style error body used by some validation failures.
properties:
title:
type: string
statusCode:
type: integer
invalidParams:
type: array
items:
$ref: "#/components/schemas/InvalidParam"
InvalidParam:
type: object
required: [name, reason]
properties:
name:
type: string
reason:
type: string
PlayerId:
type: string
format: uuid
Player:
type: object
required: [name, userId, hand, id]
properties:
name:
type: string
userId:
type: string
format: uuid
id:
$ref: "#/components/schemas/PlayerId"
hand:
description: The set of cards currently held by the player.
type: array
items:
$ref: "#/components/schemas/Card" $ref: "#/components/schemas/Card"
GameList: components:
type: object schemas:
required: [aggregateId, status, players, winners] Card:
oneOf:
- $ref: "#/components/schemas/SimpleCard"
- $ref: "#/components/schemas/ReverseCard"
- $ref: "#/components/schemas/PassCard"
- $ref: "#/components/schemas/Plus2Card"
- $ref: "#/components/schemas/Plus4Card"
- $ref: "#/components/schemas/ChangeColorCard"
SimpleCard:
properties: properties:
aggregateId: number:
type: string type: integer
format: uuid color:
status: $ref: "#/components/schemas/CardColor"
$ref: "#/components/schemas/GameStatus" ReverseCard:
players: properties:
type: array color:
items: $ref: "#/components/schemas/CardColor"
$ref: "#/components/schemas/Player" PassCard:
winners: properties:
type: array color:
items: $ref: "#/components/schemas/CardColor"
$ref: "#/components/schemas/PlayerId" Plus2Card:
properties:
GameStatus: color:
type: string $ref: "#/components/schemas/CardColor"
enum: Plus4Card:
- OPENING properties:
- IS_STARTED nextColor:
- FINISH $ref: "#/components/schemas/CardColor"
- CANCELED ChangeColorCard:
properties:
nextColor:
$ref: "#/components/schemas/CardColor"
CardColor: CardColor:
type: string type: string
enum: enum:
@@ -266,401 +60,3 @@ components:
- Red - Red
- Yellow - Yellow
- Green - Green
Card:
description: >
A playing card. Discriminated by the "type" field using the short
names declared on each Kotlin subtype (@SerialName), unlike
GameCommand/Notification below.
oneOf:
- $ref: "#/components/schemas/NumericCard"
- $ref: "#/components/schemas/ReverseCard"
- $ref: "#/components/schemas/PassCard"
- $ref: "#/components/schemas/Plus2Card"
- $ref: "#/components/schemas/Plus4Card"
- $ref: "#/components/schemas/ChangeColorCard"
discriminator:
propertyName: type
mapping:
Simple: "#/components/schemas/NumericCard"
Reverse: "#/components/schemas/ReverseCard"
Pass: "#/components/schemas/PassCard"
Plus2: "#/components/schemas/Plus2Card"
Plus4: "#/components/schemas/Plus4Card"
ChangeColor: "#/components/schemas/ChangeColorCard"
NumericCard:
description: A numbered card (0-9) of a given color.
type: object
required: [type, id, number, color]
properties:
type:
type: string
enum: [Simple]
id:
type: string
format: uuid
number:
type: integer
minimum: 0
maximum: 9
color:
$ref: "#/components/schemas/CardColor"
ReverseCard:
description: Reverses the turn order.
type: object
required: [type, id, color]
properties:
type:
type: string
enum: [Reverse]
id:
type: string
format: uuid
color:
$ref: "#/components/schemas/CardColor"
PassCard:
description: Skips the next player's turn.
type: object
required: [type, id, color]
properties:
type:
type: string
enum: [Pass]
id:
type: string
format: uuid
color:
$ref: "#/components/schemas/CardColor"
Plus2Card:
description: Forces the next player to draw 2 cards and skips their turn.
type: object
required: [type, id, color]
properties:
type:
type: string
enum: [Plus2]
id:
type: string
format: uuid
color:
$ref: "#/components/schemas/CardColor"
Plus4Card:
description: >
Forces the next player to draw 4 cards and skips their turn. The new
color is chosen separately, via the `chosenColor` field of
PlayCardCommand, and is not part of the card itself.
type: object
required: [type, id]
properties:
type:
type: string
enum: [Plus4]
id:
type: string
format: uuid
ChangeColorCard:
description: >
Changes the current color. The new color is chosen separately, via
the `chosenColor` field of PlayCardCommand, and is not part of the
card itself.
type: object
required: [type, id]
properties:
type:
type: string
enum: [ChangeColor]
id:
type: string
format: uuid
GameCommand:
description: >
A command sent by the client over the game WebSocket to act on a game.
Discriminated by "type", whose value is the fully-qualified Kotlin
class name of the command (no `@SerialName` is declared on these
types).
oneOf:
- $ref: "#/components/schemas/JoinTheGameCommand"
- $ref: "#/components/schemas/PlayCardCommand"
- $ref: "#/components/schemas/ReadyToPlayCommand"
- $ref: "#/components/schemas/TakeCartFromDrawPileCommand"
discriminator:
propertyName: type
mapping:
eventDemo.contexts.game.application.command.models.JoinTheGameCommand: "#/components/schemas/JoinTheGameCommand"
eventDemo.contexts.game.application.command.models.PlayCardCommand: "#/components/schemas/PlayCardCommand"
eventDemo.contexts.game.application.command.models.ReadyToPlayCommand: "#/components/schemas/ReadyToPlayCommand"
eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand: "#/components/schemas/TakeCartFromDrawPileCommand"
JoinTheGameCommand:
description: Join an existing (not yet started) game.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.JoinTheGameCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId]
properties:
aggregateId:
type: string
format: uuid
description: The id of the game to join.
ReadyToPlayCommand:
description: Mark the calling player as ready, so the game can start once everyone is ready.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.ReadyToPlayCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId, playerId]
properties:
aggregateId:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
TakeCartFromDrawPileCommand:
description: Draw a card from the draw pile.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId, playerId]
properties:
aggregateId:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
PlayCardCommand:
description: Play a card from the calling player's hand.
type: object
required: [type, userId, payload]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.command.models.PlayCardCommand]
userId:
type: string
format: uuid
payload:
type: object
required: [aggregateId, playerId, card]
properties:
aggregateId:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
card:
$ref: "#/components/schemas/Card"
chosenColor:
description: The color to switch to, only required when playing a Plus4Card or ChangeColorCard.
nullable: true
allOf:
- $ref: "#/components/schemas/CardColor"
Notification:
description: >
A notification sent by the server, either replayed by `GET /games/{id}`
or streamed live over the game WebSocket. Discriminated by "type",
whose value is the fully-qualified Kotlin class name of the
notification (no `@SerialName` is declared on these types).
oneOf:
- $ref: "#/components/schemas/WelcomeToTheGameNotification"
- $ref: "#/components/schemas/PlayerAsJoinTheGameNotification"
- $ref: "#/components/schemas/PlayerWasReadyNotification"
- $ref: "#/components/schemas/TheGameWasStartedNotification"
- $ref: "#/components/schemas/ItsTheTurnOfNotification"
- $ref: "#/components/schemas/PlayerAsPlayACardNotification"
- $ref: "#/components/schemas/YourNewCardNotification"
- $ref: "#/components/schemas/PlayerHavePassNotification"
- $ref: "#/components/schemas/PilesShuffledNotification"
- $ref: "#/components/schemas/PlayerWinNotification"
discriminator:
propertyName: type
mapping:
eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification: "#/components/schemas/WelcomeToTheGameNotification"
eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification: "#/components/schemas/PlayerAsJoinTheGameNotification"
eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification: "#/components/schemas/PlayerWasReadyNotification"
eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification: "#/components/schemas/TheGameWasStartedNotification"
eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification: "#/components/schemas/ItsTheTurnOfNotification"
eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification: "#/components/schemas/PlayerAsPlayACardNotification"
eventDemo.contexts.game.application.notification.models.YourNewCardNotification: "#/components/schemas/YourNewCardNotification"
eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification: "#/components/schemas/PlayerHavePassNotification"
eventDemo.contexts.game.application.notification.models.PilesShuffledNotification: "#/components/schemas/PilesShuffledNotification"
eventDemo.contexts.game.application.notification.models.PlayerWinNotification: "#/components/schemas/PlayerWinNotification"
WelcomeToTheGameNotification:
description: Sent to a player right after they join a game, listing all players currently in it.
type: object
required: [type, id, players]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification]
id:
type: string
format: uuid
players:
type: array
items:
$ref: "#/components/schemas/Player"
PlayerAsJoinTheGameNotification:
description: Sent to the other players when a new player joins the game.
type: object
required: [type, id, player]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification]
id:
type: string
format: uuid
player:
$ref: "#/components/schemas/Player"
PlayerWasReadyNotification:
description: Sent to all players when a player marks themselves as ready.
type: object
required: [type, id, playerId]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
TheGameWasStartedNotification:
description: Sent to each player when the game starts, with their initial hand.
type: object
required: [type, id, hand]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification]
id:
type: string
format: uuid
hand:
type: array
items:
$ref: "#/components/schemas/Card"
ItsTheTurnOfNotification:
description: Sent to all players to indicate whose turn it now is.
type: object
required: [type, id, player]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification]
id:
type: string
format: uuid
player:
$ref: "#/components/schemas/Player"
PlayerAsPlayACardNotification:
description: Sent to all players when a player plays a card.
type: object
required: [type, id, playerId, card]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
card:
$ref: "#/components/schemas/Card"
YourNewCardNotification:
description: Sent to a player with the cards they just drew from the draw pile.
type: object
required: [type, id, cards]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.YourNewCardNotification]
id:
type: string
format: uuid
cards:
type: array
items:
$ref: "#/components/schemas/Card"
PlayerHavePassNotification:
description: Sent to the other players when a player draws a card and passes their turn.
type: object
required: [type, id, playerId]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
PilesShuffledNotification:
description: Sent to all players when the discard pile is reshuffled into the draw pile.
type: object
required: [type, id]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PilesShuffledNotification]
id:
type: string
format: uuid
PlayerWinNotification:
description: Sent to all players when a player wins the game.
type: object
required: [type, id, playerId]
properties:
type:
type: string
enum: [eventDemo.contexts.game.application.notification.models.PlayerWinNotification]
id:
type: string
format: uuid
playerId:
$ref: "#/components/schemas/PlayerId"
-31
View File
@@ -1,32 +1 @@
pluginManagement {
repositories {
google {
content {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google {
content {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
}
}
rootProject.name = "event-demo" rootProject.name = "event-demo"
include(":backend")
include(":shared")
include(":composeApp")
-46
View File
@@ -1,46 +0,0 @@
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version")
val androidCompileSdk: Provider<String> = providers.gradleProperty("android_compile_sdk")
val androidMinSdk: Provider<String> = providers.gradleProperty("android_min_sdk")
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.android.library")
}
kotlin {
jvm()
androidTarget {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
}
}
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
}
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${kotlinSerializationVersion.get()}")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
android {
namespace = "io.github.flecomte.eventdemo.shared"
compileSdk = androidCompileSdk.get().toInt()
defaultConfig {
minSdk = androidMinSdk.get().toInt()
}
}
@@ -1,17 +0,0 @@
package eventDemo.shared.command
import eventDemo.shared.ids.CommandId
/**
* Interface to represent a Command.
*
* A command is a request for an action.
*
* Moved to `shared` (deviating slightly from the original plan of leaving it backend-only)
* because [eventDemo.shared.game.command.GameCommand] - a wire type that must live in `shared`
* for multiplatform client reuse - implements it. Since `shared` cannot depend on `backend`,
* this minimal marker interface has to live wherever its implementers live.
*/
interface Command {
val id: CommandId
}
@@ -1,13 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.game.Player
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class ItsTheTurnOfNotification(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
val player: Player,
) : Notification
@@ -1,11 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
sealed interface Notification {
@Serializable(with = UUIDSerializer::class)
val id: Uuid
}
@@ -1,11 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class PilesShuffledNotification(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
) : Notification
@@ -1,13 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.game.Player
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class PlayerAsJoinTheGameNotification(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
val player: Player,
) : Notification
@@ -1,15 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.game.Card
import eventDemo.shared.game.Player
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class PlayerAsPlayACardNotification(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
val playerId: Player.PlayerId,
val card: Card,
) : Notification
@@ -1,13 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.game.Player
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class PlayerHavePassNotification(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
val playerId: Player.PlayerId,
) : Notification
@@ -1,13 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.game.Player
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class PlayerWasReadyNotification(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
val playerId: Player.PlayerId,
) : Notification
@@ -1,13 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.game.Player
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class PlayerWinNotification(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
val playerId: Player.PlayerId,
) : Notification
@@ -1,13 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.game.Card
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class TheGameWasStartedNotification(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
val hand: Set<Card>,
) : Notification
@@ -1,13 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.game.Player
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class WelcomeToTheGameNotification(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
val players: Set<Player>,
) : Notification
@@ -1,13 +0,0 @@
package eventDemo.shared.game.notification
import eventDemo.shared.game.Card
import eventDemo.shared.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class YourNewCardNotification(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
val cards: Set<Card>,
) : Notification
@@ -1,19 +0,0 @@
package eventDemo.shared.http
import kotlinx.serialization.Serializable
@Serializable
class HttpErrorBadRequest(
val title: String = "Bad Request",
val invalidParams: List<InvalidParam> = emptyList(),
) {
// Hardcoded rather than derived from io.ktor.http.HttpStatusCode.BadRequest, since `shared`
// (multiplatform, no Ktor dependency) cannot depend on the Ktor server APIs backend uses.
val statusCode: Int = 400
@Serializable
data class InvalidParam(
val name: String,
val reason: String,
)
}
@@ -1,11 +0,0 @@
package eventDemo.shared.ids
import kotlin.uuid.Uuid
/**
* Represent an ID for one aggregate, and it used in events
* @see eventDemo.libs.eventSource.Event
*/
interface AggregateId {
val id: Uuid
}
@@ -1,20 +0,0 @@
package eventDemo.shared.ids
import eventDemo.shared.serializers.CommandIdSerializer
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
/**
* An ID for the [eventDemo.shared.command.Command]
*/
@JvmInline
@Serializable(with = CommandIdSerializer::class)
value class CommandId(
private val id: Uuid = Uuid.random(),
) {
constructor(id: String) : this(Uuid.parse(id))
override fun toString(): String =
id.toString()
}
@@ -1,13 +0,0 @@
package eventDemo.shared.ids
import eventDemo.shared.serializers.UUIDSerializer
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@JvmInline
@Serializable
value class EventId(
@Serializable(with = UUIDSerializer::class)
val id: Uuid = Uuid.random(),
)
@@ -1,13 +0,0 @@
package eventDemo.shared.ids
import eventDemo.shared.serializers.UUIDSerializer
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
@JvmInline
value class UserId(
@Serializable(with = UUIDSerializer::class)
override val id: Uuid = Uuid.random(),
) : AggregateId
@@ -3,6 +3,7 @@ package eventDemo.configuration
import io.ktor.server.config.ApplicationConfig import io.ktor.server.config.ApplicationConfig
data class Configuration( data class Configuration(
val redisUrl: String,
val jwtSecret: String, val jwtSecret: String,
val postgresql: Postgresql, val postgresql: Postgresql,
val rabbitmq: RabbitMQ, val rabbitmq: RabbitMQ,
@@ -24,6 +25,7 @@ data class Configuration(
val ApplicationConfig.configuration val ApplicationConfig.configuration
get() = get() =
Configuration( Configuration(
redisUrl = getProperty("redis.url"),
jwtSecret = getProperty("jwt.secret"), jwtSecret = getProperty("jwt.secret"),
postgresql = postgresql =
Configuration.Postgresql( Configuration.Postgresql(
@@ -7,6 +7,8 @@ import org.koin.core.module.Module
import org.koin.core.scope.Scope import org.koin.core.scope.Scope
import org.koin.core.scope.ScopeCallback import org.koin.core.scope.ScopeCallback
import org.koin.dsl.bind import org.koin.dsl.bind
import redis.clients.jedis.JedisPooled
import redis.clients.jedis.UnifiedJedis
import javax.sql.DataSource import javax.sql.DataSource
fun Module.configureDIDataSource(config: Configuration) { fun Module.configureDIDataSource(config: Configuration) {
@@ -24,6 +26,11 @@ fun Module.configureDIDataSource(config: Configuration) {
} }
} bind DataSource::class } bind DataSource::class
// Redis (for Projections)
single {
JedisPooled(config.redisUrl)
} bind UnifiedJedis::class
// RabbitMQ (for EventBus) // RabbitMQ (for EventBus)
factory { factory {
ConnectionFactory().apply { ConnectionFactory().apply {
@@ -2,7 +2,7 @@ package eventDemo.contexts.auth.application.eventStores
import eventDemo.contexts.auth.application.ports.UserEventStore import eventDemo.contexts.auth.application.ports.UserEventStore
import eventDemo.contexts.auth.domain.User import eventDemo.contexts.auth.domain.User
import eventDemo.shared.ids.UserId import eventDemo.sharedKernel.UserId
class UserEventStoreRepository( class UserEventStoreRepository(
val eventStore: UserEventStore, val eventStore: UserEventStore,
@@ -1,7 +1,7 @@
package eventDemo.contexts.auth.application.eventStores package eventDemo.contexts.auth.application.eventStores
import eventDemo.contexts.auth.domain.User import eventDemo.contexts.auth.domain.User
import eventDemo.shared.ids.UserId import eventDemo.sharedKernel.UserId
interface UserRepository { interface UserRepository {
fun get(id: UserId): User? fun get(id: UserId): User?
@@ -2,6 +2,6 @@ package eventDemo.contexts.auth.application.ports
import eventDemo.contexts.auth.domain.events.UserEvent import eventDemo.contexts.auth.domain.events.UserEvent
import eventDemo.libs.eventSource.eventStore.EventStore import eventDemo.libs.eventSource.eventStore.EventStore
import eventDemo.shared.ids.UserId import eventDemo.sharedKernel.UserId
interface UserEventStore : EventStore<UserEvent, UserId> interface UserEventStore : EventStore<UserEvent, UserId>
@@ -2,7 +2,7 @@ package eventDemo.contexts.auth.domain
import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent
import eventDemo.contexts.auth.domain.events.UserEvent import eventDemo.contexts.auth.domain.events.UserEvent
import eventDemo.shared.ids.UserId import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -1,7 +1,7 @@
package eventDemo.contexts.auth.domain.events package eventDemo.contexts.auth.domain.events
import eventDemo.shared.ids.EventId import eventDemo.libs.eventSource.EventId
import eventDemo.shared.ids.UserId import eventDemo.sharedKernel.UserId
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -1,7 +1,7 @@
package eventDemo.contexts.auth.domain.events package eventDemo.contexts.auth.domain.events
import eventDemo.libs.eventSource.Event import eventDemo.libs.eventSource.Event
import eventDemo.shared.ids.UserId import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -5,7 +5,7 @@ import com.auth0.jwt.algorithms.Algorithm
import eventDemo.configuration.configuration import eventDemo.configuration.configuration
import eventDemo.contexts.auth.domain.User import eventDemo.contexts.auth.domain.User
import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection
import eventDemo.shared.ids.UserId 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
@@ -4,7 +4,7 @@ import eventDemo.contexts.auth.application.ports.UserEventStore
import eventDemo.contexts.auth.domain.events.UserEvent import eventDemo.contexts.auth.domain.events.UserEvent
import eventDemo.libs.eventSource.eventStore.EventStore import eventDemo.libs.eventSource.eventStore.EventStore
import eventDemo.libs.eventSource.eventStore.EventStoreInMemory import eventDemo.libs.eventSource.eventStore.EventStoreInMemory
import eventDemo.shared.ids.UserId import eventDemo.sharedKernel.UserId
/** /**
* A stream to publish and read the user events. * A stream to publish and read the user events.
@@ -4,7 +4,7 @@ import eventDemo.contexts.auth.application.ports.UserEventStore
import eventDemo.contexts.auth.domain.events.UserEvent import eventDemo.contexts.auth.domain.events.UserEvent
import eventDemo.libs.eventSource.eventStore.EventStore import eventDemo.libs.eventSource.eventStore.EventStore
import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql
import eventDemo.shared.ids.UserId import eventDemo.sharedKernel.UserId
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import javax.sql.DataSource import javax.sql.DataSource
@@ -1,6 +1,6 @@
package eventDemo.contexts.auth.infrastructure.persistence.projection package eventDemo.contexts.auth.infrastructure.persistence.projection
import eventDemo.shared.ids.UserId import eventDemo.sharedKernel.UserId
data class UserProjection( data class UserProjection(
val id: UserId, val id: UserId,
@@ -3,10 +3,9 @@ package eventDemo.contexts.auth.infrastructure.persistence.projection
import eventDemo.contexts.auth.application.ports.UserProjectionRepository import eventDemo.contexts.auth.application.ports.UserProjectionRepository
import eventDemo.contexts.auth.infrastructure.checkPassword import eventDemo.contexts.auth.infrastructure.checkPassword
import eventDemo.contexts.auth.infrastructure.hashPassword import eventDemo.contexts.auth.infrastructure.hashPassword
import eventDemo.shared.ids.UserId import eventDemo.sharedKernel.UserId
import java.util.UUID
import javax.sql.DataSource import javax.sql.DataSource
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
class UserProjectionRepositoryInPostgresql( class UserProjectionRepositoryInPostgresql(
val dataSource: DataSource, val dataSource: DataSource,
@@ -25,7 +24,7 @@ class UserProjectionRepositoryInPostgresql(
}.use { resultSet -> }.use { resultSet ->
if (resultSet.next()) { if (resultSet.next()) {
UserProjection( UserProjection(
id = UserId(Uuid.parse(resultSet.getString("id"))), id = UserId(UUID.fromString(resultSet.getString("id"))),
username = resultSet.getString("username"), username = resultSet.getString("username"),
password = resultSet.getString("password"), password = resultSet.getString("password"),
) )
@@ -43,7 +42,7 @@ class UserProjectionRepositoryInPostgresql(
values (?, ?) values (?, ?)
""".trimIndent(), """.trimIndent(),
).use { ).use {
it.setObject(1, user.id.id.toJavaUuid()) it.setObject(1, user.id)
it.setString(2, user.username) it.setString(2, user.username)
it.executeUpdate() it.executeUpdate()
} }
@@ -1,11 +1,11 @@
package eventDemo.contexts.game.application.channels package eventDemo.contexts.game.application.channels
import eventDemo.contexts.game.application.command.models.GameCommand
import eventDemo.contexts.game.application.notification.CommandSubscriber import eventDemo.contexts.game.application.notification.CommandSubscriber
import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber
import eventDemo.shared.game.command.GameCommand import eventDemo.contexts.game.application.notification.models.Notification
import eventDemo.shared.game.notification.Notification import eventDemo.contexts.game.domain.game.GameId
import eventDemo.shared.ids.GameId import eventDemo.sharedKernel.UserId
import eventDemo.shared.ids.UserId
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.SendChannel
@@ -1,11 +1,11 @@
package eventDemo.contexts.game.application.command.handlers package eventDemo.contexts.game.application.command.handlers
import eventDemo.shared.game.command.GameCommand import eventDemo.contexts.game.application.command.models.GameCommand
import eventDemo.shared.game.command.JoinTheGameCommand import eventDemo.contexts.game.application.command.models.JoinTheGameCommand
import eventDemo.shared.game.command.PlayCardCommand import eventDemo.contexts.game.application.command.models.PlayCardCommand
import eventDemo.shared.game.command.ReadyToPlayCommand import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand
import eventDemo.shared.game.command.TakeCartFromDrawPileCommand import eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand
import eventDemo.shared.ids.GameId import eventDemo.contexts.game.domain.game.GameId
import java.util.Collections import java.util.Collections
class GameCommandHandlerDispatcher( class GameCommandHandlerDispatcher(
@@ -1,12 +1,12 @@
package eventDemo.contexts.game.application.command.handlers package eventDemo.contexts.game.application.command.handlers
import eventDemo.contexts.game.application.command.models.GameCommand
import eventDemo.contexts.game.application.eventStores.GameRepository import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.application.ports.GameEventBus import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.events.GameEvent import eventDemo.contexts.game.domain.events.GameEvent
import eventDemo.contexts.game.domain.game.gameState.Game import eventDemo.contexts.game.domain.game.gameState.Game
import eventDemo.libs.command.Command
import eventDemo.libs.eventSource.eventStore.VersionConflictException import eventDemo.libs.eventSource.eventStore.VersionConflictException
import eventDemo.shared.command.Command
import eventDemo.shared.game.command.GameCommand
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlin.reflect.KClass import kotlin.reflect.KClass
@@ -37,8 +37,16 @@ abstract class GameEventManager(
return this return this
} }
protected inline fun <reified G : Game> Game.isStatusOrFail(message: String): G = protected fun <G : Game> Game.isStatusOrFail(
this as? G ?: throw CommandException(message) kClass: KClass<G>,
message: String,
): G {
if (kClass.isInstance(this)) {
return this as G
} else {
throw CommandException(message)
}
}
protected fun <T> retry( protected fun <T> retry(
mapAttempts: Int = 5, mapAttempts: Int = 5,
@@ -1,10 +1,10 @@
package eventDemo.contexts.game.application.command.handlers package eventDemo.contexts.game.application.command.handlers
import eventDemo.contexts.auth.application.eventStores.UserRepository import eventDemo.contexts.auth.application.eventStores.UserRepository
import eventDemo.contexts.game.application.command.models.JoinTheGameCommand
import eventDemo.contexts.game.application.eventStores.GameRepository import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.application.ports.GameEventBus import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.game.gameState.GameCreated import eventDemo.contexts.game.domain.game.gameState.GameCreated
import eventDemo.shared.game.command.JoinTheGameCommand
/** /**
* A command to perform an action to play a new card * A command to perform an action to play a new card
@@ -20,7 +20,7 @@ class JoinTheGameHandler(
retry { retry {
command command
.getGame() .getGame()
.isStatusOrFail<GameCreated>("The game is started") .isStatusOrFail(GameCreated::class, "The game is started")
.userJoinTheGame( .userJoinTheGame(
userId = command.userId, userId = command.userId,
name = user.username, name = user.username,
@@ -1,9 +1,9 @@
package eventDemo.contexts.game.application.command.handlers package eventDemo.contexts.game.application.command.handlers
import eventDemo.contexts.game.application.command.models.PlayCardCommand
import eventDemo.contexts.game.application.eventStores.GameRepository import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.application.ports.GameEventBus import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.game.gameState.GameStarted import eventDemo.contexts.game.domain.game.gameState.GameStarted
import eventDemo.shared.game.command.PlayCardCommand
/** /**
* A command to perform an action to play a new card * A command to perform an action to play a new card
@@ -16,7 +16,7 @@ class PlayCardHandler(
override fun handle(command: PlayCardCommand) { override fun handle(command: PlayCardCommand) {
command command
.getGame() .getGame()
.isStatusOrFail<GameStarted>("The game is not started") .isStatusOrFail(GameStarted::class, "The game is not started")
.playTheCard( .playTheCard(
card = command.payload.card, card = command.payload.card,
playerId = command.payload.playerId, playerId = command.payload.playerId,
@@ -1,9 +1,9 @@
package eventDemo.contexts.game.application.command.handlers package eventDemo.contexts.game.application.command.handlers
import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand
import eventDemo.contexts.game.application.eventStores.GameRepository import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.application.ports.GameEventBus import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.game.gameState.GameCreated import eventDemo.contexts.game.domain.game.gameState.GameCreated
import eventDemo.shared.game.command.ReadyToPlayCommand
/** /**
* A command to set as ready to play * A command to set as ready to play
@@ -16,7 +16,7 @@ class ReadyToPlayHandler(
override fun handle(command: ReadyToPlayCommand) { override fun handle(command: ReadyToPlayCommand) {
command command
.getGame() .getGame()
.isStatusOrFail<GameCreated>("The game is started") .isStatusOrFail(GameCreated::class, "The game is started")
.setReadyPlayer(command.payload.playerId) .setReadyPlayer(command.payload.playerId)
.saveEvents() .saveEvents()
.publishEvents() .publishEvents()
@@ -1,9 +1,9 @@
package eventDemo.contexts.game.application.command.handlers package eventDemo.contexts.game.application.command.handlers
import eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand
import eventDemo.contexts.game.application.eventStores.GameRepository import eventDemo.contexts.game.application.eventStores.GameRepository
import eventDemo.contexts.game.application.ports.GameEventBus import eventDemo.contexts.game.application.ports.GameEventBus
import eventDemo.contexts.game.domain.game.gameState.GameStarted import eventDemo.contexts.game.domain.game.gameState.GameStarted
import eventDemo.shared.game.command.TakeCartFromDrawPileCommand
/** /**
* A command to draw card on draw pile. * A command to draw card on draw pile.
@@ -18,7 +18,7 @@ class TakeCartFromDrawPileHandler(
override fun handle(command: TakeCartFromDrawPileCommand) { override fun handle(command: TakeCartFromDrawPileCommand) {
command command
.getGame() .getGame()
.isStatusOrFail<GameStarted>("The game is not started") .isStatusOrFail(GameStarted::class, "The game is not started")
.playerTakeCartFromDrawPile(command.payload.playerId, 1) .playerTakeCartFromDrawPile(command.payload.playerId, 1)
.saveEvents() .saveEvents()
.publishEvents() .publishEvents()
@@ -1,9 +1,9 @@
package eventDemo.shared.game.command package eventDemo.contexts.game.application.command.models
import eventDemo.shared.command.Command import eventDemo.contexts.game.domain.game.GameId
import eventDemo.shared.ids.GameId import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
import eventDemo.shared.ids.UserId import eventDemo.libs.command.Command
import eventDemo.shared.serializers.GameIdSerializer import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -1,9 +1,9 @@
package eventDemo.shared.game.command package eventDemo.contexts.game.application.command.models
import eventDemo.shared.ids.CommandId import eventDemo.contexts.game.domain.game.GameId
import eventDemo.shared.ids.GameId import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
import eventDemo.shared.ids.UserId import eventDemo.libs.command.CommandId
import eventDemo.shared.serializers.GameIdSerializer import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@@ -1,12 +1,12 @@
package eventDemo.shared.game.command package eventDemo.contexts.game.application.command.models
import eventDemo.shared.game.Card import eventDemo.contexts.game.domain.game.Card
import eventDemo.shared.game.Player import eventDemo.contexts.game.domain.game.GameId
import eventDemo.shared.ids.CommandId import eventDemo.contexts.game.domain.game.Player
import eventDemo.shared.ids.GameId import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
import eventDemo.shared.ids.UserId import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
import eventDemo.shared.serializers.GameIdSerializer import eventDemo.libs.command.CommandId
import eventDemo.shared.serializers.PlayerIdSerializer import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@@ -1,11 +1,11 @@
package eventDemo.shared.game.command package eventDemo.contexts.game.application.command.models
import eventDemo.shared.game.Player import eventDemo.contexts.game.domain.game.GameId
import eventDemo.shared.ids.CommandId import eventDemo.contexts.game.domain.game.Player
import eventDemo.shared.ids.GameId import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
import eventDemo.shared.ids.UserId import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
import eventDemo.shared.serializers.GameIdSerializer import eventDemo.libs.command.CommandId
import eventDemo.shared.serializers.PlayerIdSerializer import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@@ -1,17 +1,15 @@
package eventDemo.shared.game.command package eventDemo.contexts.game.application.command.models
import eventDemo.shared.game.Player import eventDemo.contexts.game.domain.game.GameId
import eventDemo.shared.ids.CommandId import eventDemo.contexts.game.domain.game.Player
import eventDemo.shared.ids.GameId import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
import eventDemo.shared.ids.UserId import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
import eventDemo.shared.serializers.GameIdSerializer import eventDemo.libs.command.CommandId
import eventDemo.shared.serializers.PlayerIdSerializer import eventDemo.sharedKernel.UserId
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* A command to draw card on draw pile. * A command to perform an action to play a new card
*
* Is can be triggered when you cannot play any card in your hand.
*/ */
@Serializable @Serializable
data class TakeCartFromDrawPileCommand( data class TakeCartFromDrawPileCommand(

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