Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bffcd3267
|
@@ -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/
|
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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/
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+142
-12
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Architecture
|
||||||
|
============
|
||||||
|
|
||||||
|
The Workflow
|
||||||
|
------------
|
||||||
|

|
||||||
|
|
||||||
|
The business Entities
|
||||||
|
---------------------
|
||||||
|
|
||||||
|

|
||||||
@@ -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
@@ -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/)
|
||||||
@@ -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 |
@@ -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 |
@@ -1,2 +0,0 @@
|
|||||||
POSTGRESQL_URL=jdbc:postgresql://postgresql/event-demo
|
|
||||||
RABBITMQ_URL=rabbitmq
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PGADMIN_DEFAULT_EMAIL=
|
||||||
+7
-9
@@ -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"]
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
||||||
@@ -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"
|
|
||||||
@@ -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,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-traefik.yaml
|
- parts/docker-compose-tools.yaml
|
||||||
|
- parts/docker-compose-traefik.yaml
|
||||||
services:
|
|
||||||
postgresql:
|
|
||||||
environment:
|
|
||||||
POSTGRES_PASSWORD: "changeit"
|
|
||||||
@@ -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:
|
||||||
@@ -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
|
|
||||||
@@ -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:
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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==
|
|
||||||
@@ -1,666 +1,62 @@
|
|||||||
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
|
$ref: "#/components/schemas/Card"
|
||||||
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:
|
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:
|
schemas:
|
||||||
LoginResponse:
|
Card:
|
||||||
type: object
|
oneOf:
|
||||||
required: [token]
|
- $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:
|
||||||
token:
|
number:
|
||||||
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
|
type: integer
|
||||||
invalidParams:
|
color:
|
||||||
type: array
|
$ref: "#/components/schemas/CardColor"
|
||||||
items:
|
ReverseCard:
|
||||||
$ref: "#/components/schemas/InvalidParam"
|
|
||||||
|
|
||||||
InvalidParam:
|
|
||||||
type: object
|
|
||||||
required: [name, reason]
|
|
||||||
properties:
|
properties:
|
||||||
name:
|
color:
|
||||||
type: string
|
$ref: "#/components/schemas/CardColor"
|
||||||
reason:
|
PassCard:
|
||||||
type: string
|
|
||||||
|
|
||||||
PlayerId:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
|
|
||||||
Player:
|
|
||||||
type: object
|
|
||||||
required: [name, userId, hand, id]
|
|
||||||
properties:
|
properties:
|
||||||
name:
|
color:
|
||||||
type: string
|
$ref: "#/components/schemas/CardColor"
|
||||||
userId:
|
Plus2Card:
|
||||||
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"
|
|
||||||
|
|
||||||
GameList:
|
|
||||||
type: object
|
|
||||||
required: [aggregateId, status, players, winners]
|
|
||||||
properties:
|
properties:
|
||||||
aggregateId:
|
color:
|
||||||
type: string
|
$ref: "#/components/schemas/CardColor"
|
||||||
format: uuid
|
Plus4Card:
|
||||||
status:
|
properties:
|
||||||
$ref: "#/components/schemas/GameStatus"
|
nextColor:
|
||||||
players:
|
$ref: "#/components/schemas/CardColor"
|
||||||
type: array
|
ChangeColorCard:
|
||||||
items:
|
properties:
|
||||||
$ref: "#/components/schemas/Player"
|
nextColor:
|
||||||
winners:
|
$ref: "#/components/schemas/CardColor"
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: "#/components/schemas/PlayerId"
|
|
||||||
|
|
||||||
GameStatus:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- OPENING
|
|
||||||
- IS_STARTED
|
|
||||||
- FINISH
|
|
||||||
- CANCELED
|
|
||||||
|
|
||||||
CardColor:
|
CardColor:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- Blue
|
- Blue
|
||||||
- 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"
|
|
||||||
@@ -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")
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
-13
@@ -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
|
|
||||||
}
|
|
||||||
-11
@@ -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
|
|
||||||
-13
@@ -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
|
|
||||||
-15
@@ -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
|
|
||||||
-13
@@ -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
|
|
||||||
-13
@@ -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
|
|
||||||
-13
@@ -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
|
|
||||||
-13
@@ -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
|
|
||||||
-13
@@ -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
|
|
||||||
-13
@@ -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
|
|
||||||
+2
@@ -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
@@ -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 {
|
||||||
+1
-1
@@ -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
-1
@@ -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?
|
||||||
+1
-1
@@ -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>
|
||||||
+1
-1
@@ -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
|
||||||
+2
-2
@@ -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
-1
@@ -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
|
||||||
+1
-1
@@ -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
|
||||||
+1
-1
@@ -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.
|
||||||
+1
-1
@@ -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
-1
@@ -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,
|
||||||
+4
-5
@@ -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()
|
||||||
}
|
}
|
||||||
+4
-4
@@ -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
|
||||||
+6
-6
@@ -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(
|
||||||
+12
-4
@@ -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,
|
||||||
+2
-2
@@ -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,
|
||||||
+2
-2
@@ -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,
|
||||||
+2
-2
@@ -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()
|
||||||
+2
-2
@@ -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()
|
||||||
+5
-5
@@ -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
|
||||||
+5
-5
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+8
-8
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+7
-7
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+8
-10
@@ -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
Reference in New Issue
Block a user