Compare commits
20
Commits
256524f63e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
505cfe38f0
|
||
|
|
85bcdcadb8
|
||
|
|
e67c475c38
|
||
|
|
07983dc5b0
|
||
|
|
5e9587b93f
|
||
|
|
fd62f2574d
|
||
|
|
828bd0639e
|
||
|
|
da13b0d2b8
|
||
|
|
d632dc0f6b
|
||
|
|
b60e4aa457
|
||
|
|
e262f35b27
|
||
|
|
77e4cab8ad
|
||
|
|
7291419c9f
|
||
|
|
b2b8fcf92f
|
||
|
|
4e4b307275
|
||
|
|
e87a36caa5
|
||
|
|
e2d7942c7e
|
||
|
|
b313b39cf4
|
||
|
|
78956ce84e
|
||
|
|
7fb488ab56
|
@@ -0,0 +1,26 @@
|
|||||||
|
# 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/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
* text=auto
|
||||||
|
* eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.jar binary
|
||||||
|
gradlew.bat eol=crlf
|
||||||
|
gradlew text eol=lf
|
||||||
+41
-29
@@ -18,10 +18,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
|
run: echo "key=gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache Gradle dependencies
|
- name: Cache Gradle dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -48,16 +48,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Restore Gradle cache
|
- name: Restore Gradle cache
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -73,53 +73,65 @@ jobs:
|
|||||||
run: ./gradlew ktlintCheck
|
run: ./gradlew ktlintCheck
|
||||||
|
|
||||||
- name: Publish ktlint report
|
- name: Publish ktlint report
|
||||||
uses: yutailang0119/action-ktlint@v4
|
uses: yutailang0119/action-ktlint@v5
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
report-path: 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@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Install a pinned Docker Compose version
|
||||||
uses: actions/setup-java@v4
|
run: |
|
||||||
with:
|
mkdir -p ~/.docker/cli-plugins
|
||||||
distribution: 'temurin'
|
curl -fSL https://github.com/docker/compose/releases/download/v5.1.4/docker-compose-linux-x86_64 \
|
||||||
java-version: '21'
|
-o ~/.docker/cli-plugins/docker-compose
|
||||||
|
chmod +x ~/.docker/cli-plugins/docker-compose
|
||||||
|
docker compose version
|
||||||
|
|
||||||
- name: Restore Gradle cache
|
- name: Prepare docker secrets
|
||||||
uses: actions/cache@v3
|
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
|
||||||
with:
|
with:
|
||||||
path: |
|
path: ${{ env.GRADLE_CACHE_DIR }}
|
||||||
~/.gradle/caches
|
key: ${{ steps.cache-key-generator.outputs.key }}
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ needs.build.outputs.cache-key }}
|
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
gradle-${{ runner.os }}-
|
gradle-docker-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Grant execute permission to Gradle wrapper
|
- name: Prepare cache directory permissions
|
||||||
run: chmod +x gradlew
|
run: |
|
||||||
|
mkdir -p "$GRADLE_CACHE_DIR"
|
||||||
|
chmod -R 777 "$GRADLE_CACHE_DIR"
|
||||||
|
|
||||||
- name: Start CI Docker Compose services
|
- name: Run tests in Docker
|
||||||
run: ./gradlew composeUp -Pci
|
run: docker compose -f docker/docker-compose-test.yaml run tests
|
||||||
|
|
||||||
- name: Run tests
|
- name: Shut down Docker services
|
||||||
run: ./gradlew test -x composeUp --no-daemon
|
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@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
path: build/reports/tests/test
|
path: build/reports/tests/test
|
||||||
|
|
||||||
- name: Publish Test Report
|
- name: Publish Test Report
|
||||||
uses: dorny/test-reporter@v1
|
uses: dorny/test-reporter@v3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: JUnit Tests
|
name: JUnit Tests
|
||||||
|
|||||||
@@ -37,3 +37,4 @@ out/
|
|||||||
/docker/.env
|
/docker/.env
|
||||||
/docker/*.secret
|
/docker/*.secret
|
||||||
*.hprof
|
*.hprof
|
||||||
|
/.gradle-docker-cache/
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<DataSourcesHistory>
|
||||||
|
<DataSourceFromHistory isRemovedFromProject="false">
|
||||||
|
<data-source source="LOCAL" name="event-demo@localhost" uuid="af2eabb1-64f7-49de-a94f-be1560baa96a">
|
||||||
|
<database-info product="PostgreSQL" version="18.4 (Debian 18.4-1.pgdg13+1)" jdbc-version="4.2" driver-name="PostgreSQL JDBC Driver" driver-version="42.7.3" dbms="POSTGRES" exact-version="18.4" exact-driver-version="42.7">
|
||||||
|
<identifier-quote-string>"</identifier-quote-string>
|
||||||
|
</database-info>
|
||||||
|
<case-sensitivity plain-identifiers="lower" quoted-identifiers="exact" />
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:5432/event-demo</jdbc-url>
|
||||||
|
<secret-storage>master_key</secret-storage>
|
||||||
|
<user-name>event-demo</user-name>
|
||||||
|
<schema-mapping>
|
||||||
|
<introspection-scope>
|
||||||
|
<node negative="1">
|
||||||
|
<node kind="database" qname="@">
|
||||||
|
<node kind="schema" qname="@" />
|
||||||
|
</node>
|
||||||
|
<node kind="database" qname="event-demo">
|
||||||
|
<node kind="schema">
|
||||||
|
<name qname="auth" />
|
||||||
|
<name qname="game" />
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</introspection-scope>
|
||||||
|
</schema-mapping>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</DataSourceFromHistory>
|
||||||
|
</DataSourcesHistory>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="docker composeUp" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value="composeUp" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<GradleProfilingDisabled>false</GradleProfilingDisabled>
|
||||||
|
<GradleCoverageDisabled>false</GradleCoverageDisabled>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 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,7 +2,6 @@ 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)
|
||||||
|
|
||||||
@@ -18,31 +17,6 @@ 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
|
||||||
---------
|
---------
|
||||||
|
|
||||||
@@ -51,11 +25,11 @@ Language
|
|||||||
|
|
||||||
Framework
|
Framework
|
||||||
- Ktor
|
- Ktor
|
||||||
|
- with Koin for Dependency Injection
|
||||||
|
|
||||||
Database
|
Database
|
||||||
- Postgresql
|
- Postgresql
|
||||||
- with Flyway
|
- with Flyway
|
||||||
- Redis
|
|
||||||
- RabbitMQ
|
- RabbitMQ
|
||||||
|
|
||||||
Infra
|
Infra
|
||||||
|
|||||||
+35
-83
@@ -1,22 +1,19 @@
|
|||||||
@file:Suppress("PropertyName")
|
|
||||||
|
|
||||||
import org.jlleitschuh.gradle.ktlint.KtlintExtension
|
import org.jlleitschuh.gradle.ktlint.KtlintExtension
|
||||||
|
|
||||||
val ktor_version: String by project
|
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version")
|
||||||
val kotlin_version: String by project
|
val kotlinVersion: Provider<String> = providers.gradleProperty("kotlin_version")
|
||||||
val kotlin_serialization_version: String by project
|
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version")
|
||||||
val logback_version: String by project
|
val logbackVersion: Provider<String> = providers.gradleProperty("logback_version")
|
||||||
val koin_version: String by project
|
val koinVersion: Provider<String> = providers.gradleProperty("koin_version")
|
||||||
val kotlin_logging_version: String by project
|
val kotlinLoggingVersion: Provider<String> = providers.gradleProperty("kotlin_logging_version")
|
||||||
val kotest_version: String by project
|
val kotestVersion: Provider<String> = providers.gradleProperty("kotest_version")
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
application
|
application
|
||||||
kotlin("jvm") version "2.1.10"
|
kotlin("jvm") version "2.1.21"
|
||||||
id("io.ktor.plugin") version "3.1.1"
|
id("io.ktor.plugin") version "3.5.1"
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
|
||||||
id("org.jlleitschuh.gradle.ktlint") version "12.2.0"
|
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
|
||||||
id("com.avast.gradle.docker-compose") version "0.17.12"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "io.github.flecomte"
|
group = "io.github.flecomte"
|
||||||
@@ -29,7 +26,7 @@ application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configure<KtlintExtension> {
|
configure<KtlintExtension> {
|
||||||
version.set("1.5.0")
|
version.set("1.8.0")
|
||||||
}
|
}
|
||||||
ktlint {
|
ktlint {
|
||||||
reporters {
|
reporters {
|
||||||
@@ -49,63 +46,17 @@ java {
|
|||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
jvmArgs("-Djdk.attach.allowAttachSelf=true", "-XX:+EnableDynamicAgentLoading")
|
||||||
|
// Dynamic self-attach (used by MockK/ByteBuddy) times out in Docker containers because the
|
||||||
dockerCompose {
|
// SIGQUIT-triggered AttachListener handshake never completes there. Loading the byte-buddy
|
||||||
val composeFile =
|
// agent jar statically via -javaagent avoids the attach handshake entirely: MockK detects the
|
||||||
if (project.hasProperty("ci")) {
|
// pre-installed Instrumentation instance and skips dynamic attach.
|
||||||
// Use docker-compose-ci.yaml for the CI
|
doFirst {
|
||||||
"docker/docker-compose-ci.yaml"
|
val agentJar =
|
||||||
} else {
|
classpath.files.firstOrNull { it.name.startsWith("byte-buddy-agent") }
|
||||||
// Use docker-compose-test.yaml for local tests
|
?: error("byte-buddy-agent jar not found on test classpath")
|
||||||
"docker/docker-compose-test.yaml"
|
jvmArgs("-javaagent:$agentJar")
|
||||||
}
|
}
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
doLast {
|
|
||||||
val files =
|
|
||||||
listOf(
|
|
||||||
File("docker/pgadmin.secret"),
|
|
||||||
File("docker/postgresql.secret"),
|
|
||||||
)
|
|
||||||
|
|
||||||
files.forEach {
|
|
||||||
if (!it.exists()) {
|
|
||||||
it.writeText("changeit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tasks.composeUp {
|
|
||||||
dependsOn("copyEnv")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -124,23 +75,24 @@ dependencies {
|
|||||||
implementation("io.ktor:ktor-server-data-conversion")
|
implementation("io.ktor:ktor-server-data-conversion")
|
||||||
implementation("io.ktor:ktor-client-content-negotiation")
|
implementation("io.ktor:ktor-client-content-negotiation")
|
||||||
implementation("io.ktor:ktor-client-auth")
|
implementation("io.ktor:ktor-client-auth")
|
||||||
implementation("ch.qos.logback:logback-classic:$logback_version")
|
implementation("ch.qos.logback:logback-classic:${logbackVersion.get()}")
|
||||||
implementation("io.insert-koin:koin-ktor:$koin_version")
|
implementation("io.insert-koin:koin-ktor:${koinVersion.get()}")
|
||||||
implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
|
implementation("io.insert-koin:koin-logger-slf4j:${koinVersion.get()}")
|
||||||
implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version")
|
implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlin_serialization_version")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
|
||||||
implementation("redis.clients:jedis:5.2.0")
|
implementation("org.postgresql:postgresql:42.7.13")
|
||||||
implementation("org.postgresql:postgresql:42.7.5")
|
|
||||||
implementation("com.zaxxer:HikariCP:6.3.0")
|
implementation("com.zaxxer:HikariCP:6.3.0")
|
||||||
implementation("com.rabbitmq:amqp-client:5.25.0")
|
implementation("com.rabbitmq:amqp-client:5.25.0")
|
||||||
|
implementation("com.password4j:password4j:1.8.4")
|
||||||
|
|
||||||
// Force version of sub library (for security)
|
// Force version of sub library (for security)
|
||||||
implementation("commons-codec:commons-codec:1.13")
|
implementation("commons-codec:commons-codec:1.13")
|
||||||
|
|
||||||
testImplementation("io.kotest:kotest-extensions-koin:$kotest_version")
|
testImplementation("io.kotest:kotest-extensions-koin:${kotestVersion.get()}")
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}")
|
||||||
testImplementation("io.ktor:ktor-server-test-host-jvm:$ktor_version")
|
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}")
|
||||||
testImplementation("io.kotest:kotest-runner-junit5:$kotest_version")
|
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}")
|
||||||
testImplementation("io.mockk:mockk:1.13.17")
|
testImplementation("io.mockk:mockk:1.14.11")
|
||||||
|
testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
Architecture
|
|
||||||
============
|
|
||||||
|
|
||||||
The Workflow
|
|
||||||
------------
|
|
||||||

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

|
|
||||||
+15
-5
@@ -1,23 +1,33 @@
|
|||||||
Installation
|
Installation
|
||||||
============
|
============
|
||||||
|
|
||||||
To run the stack:
|
To run the stack in production:
|
||||||
|
|
||||||
```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/) (WIP)
|
- [Frontend web site](http://app.traefik.me/)
|
||||||
|
|
||||||
Exposed url on test env:
|
Exposed url on dev 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/)
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
@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
|
Before Width: | Height: | Size: 48 KiB |
@@ -1,84 +0,0 @@
|
|||||||
@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
|
Before Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
POSTGRESQL_URL=jdbc:postgresql://postgresql/event-demo
|
||||||
|
RABBITMQ_URL=rabbitmq
|
||||||
@@ -1 +0,0 @@
|
|||||||
PGADMIN_DEFAULT_EMAIL=
|
|
||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Cache Gradle dependencies
|
# Stage 1: Cache Gradle dependencies
|
||||||
FROM gradle:latest AS cache
|
FROM gradle:9.6.1-jdk21-alpine 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 build.gradle.* gradle.properties /home/gradle/app/
|
COPY build.gradle.* gradle.properties /home/gradle/app/
|
||||||
@@ -7,7 +7,7 @@ WORKDIR /home/gradle/app
|
|||||||
RUN gradle build -i -x check
|
RUN gradle build -i -x check
|
||||||
|
|
||||||
# Stage 2: Build Application
|
# Stage 2: Build Application
|
||||||
FROM gradle:latest AS build
|
FROM gradle:9.6.1-jdk21-alpine 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
|
||||||
@@ -16,8 +16,8 @@ WORKDIR /home/gradle/src
|
|||||||
RUN gradle buildFatJar --no-daemon
|
RUN gradle buildFatJar --no-daemon
|
||||||
|
|
||||||
# Stage 3: Create the Runtime Image
|
# Stage 3: Create the Runtime Image
|
||||||
FROM amazoncorretto:21 AS runtime
|
FROM eclipse-temurin:21-jre-alpine AS runtime
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
RUN mkdir /app
|
RUN mkdir /app
|
||||||
COPY --from=build /home/gradle/src/build/libs/*.jar /app/event-demo-all.jar
|
COPY --from=build /home/gradle/src/build/libs/*-all.jar /app/event-demo-all.jar
|
||||||
ENTRYPOINT ["java","-jar","/app/event-demo-all.jar"]
|
ENTRYPOINT ["java","-jar","/app/event-demo-all.jar"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 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 ./
|
||||||
|
|
||||||
|
# Lance les tests Kotlin
|
||||||
|
CMD ["gradle", "test", "--no-daemon"]
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
name: event-demo-test
|
|
||||||
include:
|
|
||||||
- path:
|
|
||||||
- parts/docker-compose-databases.yaml
|
|
||||||
- parts/docker-compose-databases-expose.yaml
|
|
||||||
- parts/docker-compose-traefik.yaml
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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,3 +5,16 @@ include:
|
|||||||
- parts/docker-compose-app.yaml
|
- parts/docker-compose-app.yaml
|
||||||
- parts/docker-compose-tools.yaml
|
- parts/docker-compose-tools.yaml
|
||||||
- parts/docker-compose-traefik.yaml
|
- parts/docker-compose-traefik.yaml
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgresql:
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
|
||||||
|
volumes:
|
||||||
|
- ./postgresql.secret:/run/secrets/postgresql_password:ro
|
||||||
|
|
||||||
|
pgadmin:
|
||||||
|
environment:
|
||||||
|
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
|
||||||
|
volumes:
|
||||||
|
- ./pgadmin.secret:/run/secrets/pgadmin_password:ro
|
||||||
@@ -2,6 +2,10 @@ name: event-demo-test
|
|||||||
include:
|
include:
|
||||||
- path:
|
- path:
|
||||||
- parts/docker-compose-databases.yaml
|
- parts/docker-compose-databases.yaml
|
||||||
- parts/docker-compose-databases-expose.yaml
|
- parts/docker-compose-test.yaml
|
||||||
- parts/docker-compose-tools.yaml
|
|
||||||
- parts/docker-compose-traefik.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
|
||||||
redis:
|
env_file:
|
||||||
condition: service_healthy
|
- ../.env.docker
|
||||||
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,8 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
redis:
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
|
|
||||||
postgresql:
|
postgresql:
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
redis:
|
|
||||||
image: redis/redis-stack:7.4.0-v3
|
|
||||||
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
|
||||||
@@ -19,20 +11,17 @@ services:
|
|||||||
restart: no
|
restart: no
|
||||||
|
|
||||||
postgresql:
|
postgresql:
|
||||||
image: postgres:17.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
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
volumes:
|
volumes:
|
||||||
- postgresql_data:/var/lib/postgresql/data
|
- postgresql_data:/var/lib/postgresql
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:4-management-alpine
|
image: rabbitmq:4-management-alpine
|
||||||
@@ -47,12 +36,6 @@ 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:
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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:
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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,31 +2,39 @@ services:
|
|||||||
pgadmin:
|
pgadmin:
|
||||||
image: dpage/pgadmin4
|
image: dpage/pgadmin4
|
||||||
environment:
|
environment:
|
||||||
PGADMIN_DEFAULT_EMAIL: $PGADMIN_DEFAULT_EMAIL
|
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@event-demo.dev}
|
||||||
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
|
|
||||||
secrets:
|
|
||||||
- pgadmin_password
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgadmin_data:/var/lib/pgadmin
|
- pgadmin_data:/var/lib/pgadmin
|
||||||
|
configs:
|
||||||
|
- source: servers_json
|
||||||
|
target: /pgadmin4/servers.json
|
||||||
labels:
|
labels:
|
||||||
- "traefik.http.routers.pgadmin.rule=Host(`pgadmin.postgresql.traefik.me`)"
|
- "traefik.http.routers.pgadmin.rule=Host(`pgadmin.postgresql.traefik.me`)"
|
||||||
- "traefik.http.services.pgadmin.loadbalancer.server.port=80"
|
- "traefik.http.services.pgadmin.loadbalancer.server.port=80"
|
||||||
|
|
||||||
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`)"
|
||||||
- "traefik.http.routers.rabbitmq-management.service=rabbitmq-management"
|
- "traefik.http.routers.rabbitmq-management.service=rabbitmq-management"
|
||||||
- "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672"
|
- "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672"
|
||||||
|
|
||||||
secrets:
|
configs:
|
||||||
pgadmin_password:
|
servers_json:
|
||||||
file: ../pgadmin.secret
|
content: |
|
||||||
|
{
|
||||||
|
"Servers": {
|
||||||
|
"1": {
|
||||||
|
"Name": "Event demo",
|
||||||
|
"Group": "Servers",
|
||||||
|
"Host": "postgresql",
|
||||||
|
"Port": 5432,
|
||||||
|
"MaintenanceDB": "event-demo",
|
||||||
|
"Username": "event-demo",
|
||||||
|
"PassFile": "/pgpass",
|
||||||
|
"SSLMode": "prefer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgadmin_data:
|
pgadmin_data:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:3.3.4
|
image: traefik:3.7.9
|
||||||
command:
|
command:
|
||||||
- "--api.insecure=true"
|
- "--api.insecure=true"
|
||||||
- "--api.dashboard=true"
|
- "--api.dashboard=true"
|
||||||
|
|||||||
+7
-7
@@ -1,9 +1,9 @@
|
|||||||
ktor_version=3.1.1
|
ktor_version=3.5.1
|
||||||
kotlin_version=2.1.10
|
kotlin_version=2.1.21
|
||||||
kotlin_serialization_version=1.8.0
|
kotlin_serialization_version=1.11.0
|
||||||
logback_version=1.5.17
|
logback_version=1.5.38
|
||||||
koin_version=4.0.2
|
koin_version=4.2.1
|
||||||
kotlin_logging_version=7.0.5
|
kotlin_logging_version=8.0.4
|
||||||
kotest_version=6.0.0.M2
|
kotest_version=6.2.2
|
||||||
|
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
create table event_stream (
|
create schema game;
|
||||||
|
create table game.game_event_stream (
|
||||||
id uuid not null primary key,
|
id uuid not null primary key,
|
||||||
aggregate_id uuid not null,
|
aggregate_id uuid not null,
|
||||||
version int not null,
|
version int not null,
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
create schema auth;
|
||||||
|
create table auth.user_event_stream (
|
||||||
|
id uuid not null primary key,
|
||||||
|
aggregate_id uuid not null,
|
||||||
|
version int not null,
|
||||||
|
data jsonb not null,
|
||||||
|
unique(aggregate_id, version)
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
create table auth.user (
|
||||||
|
id uuid not null primary key,
|
||||||
|
username text not null,
|
||||||
|
unique(id),
|
||||||
|
unique(username)
|
||||||
|
);
|
||||||
@@ -1,58 +1,264 @@
|
|||||||
openapi: "3.0.3"
|
openapi: "3.0.3"
|
||||||
info:
|
info:
|
||||||
title: "event_demo API"
|
title: "event_demo API"
|
||||||
description: "event_demo API"
|
description: |
|
||||||
version: "1.0.0"
|
API for the event_demo project: a small event-sourced Uno-like card game.
|
||||||
|
|
||||||
|
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:
|
||||||
"/game/{id}/card/last":
|
"/login/{username}":
|
||||||
get:
|
post:
|
||||||
description: get the last card played
|
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:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: The last card
|
description: Successful login
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Card"
|
$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:
|
||||||
|
tags: [Games]
|
||||||
|
summary: List all known games
|
||||||
|
description: Returns up to the 100 most recent games (pagination is not yet exposed on this route).
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: The list of games
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/GameList"
|
||||||
|
401:
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"/games/{id}":
|
||||||
|
get:
|
||||||
|
tags: [Games]
|
||||||
|
summary: Replay a game's full notification history
|
||||||
|
description: |
|
||||||
|
Rebuilds every notification that would have been sent to the calling
|
||||||
|
player since the beginning of the game (from its event stream), so a
|
||||||
|
client reconnecting can catch up on the current game state.
|
||||||
|
|
||||||
|
This same path also accepts a WebSocket upgrade to play the game live,
|
||||||
|
see `x-websocket-channels` at the root of this document.
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: The full list of notifications for this game, from this player's point of view
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Notification"
|
||||||
|
401:
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
404:
|
||||||
|
description: No game found for this id
|
||||||
|
|
||||||
|
x-websocket-channels:
|
||||||
|
"/games/{id}":
|
||||||
|
description: |
|
||||||
|
WebSocket endpoint to join and play a game in real time. Requires the
|
||||||
|
same JWT bearer authentication as the REST routes (sent the same way,
|
||||||
|
e.g. via the `Authorization` header during the WebSocket handshake).
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
send:
|
||||||
|
description: Commands sent by the client to act on the game.
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GameCommand"
|
||||||
|
receive:
|
||||||
|
description: Notifications sent by the server as the game progresses.
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Notification"
|
||||||
|
|
||||||
components:
|
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:
|
||||||
Card:
|
LoginResponse:
|
||||||
oneOf:
|
type: object
|
||||||
- $ref: "#/components/schemas/SimpleCard"
|
required: [token]
|
||||||
- $ref: "#/components/schemas/ReverseCard"
|
|
||||||
- $ref: "#/components/schemas/PassCard"
|
|
||||||
- $ref: "#/components/schemas/Plus2Card"
|
|
||||||
- $ref: "#/components/schemas/Plus4Card"
|
|
||||||
- $ref: "#/components/schemas/ChangeColorCard"
|
|
||||||
SimpleCard:
|
|
||||||
properties:
|
properties:
|
||||||
number:
|
token:
|
||||||
|
type: string
|
||||||
|
description: JWT bearer token to use on subsequent requests.
|
||||||
|
|
||||||
|
CreateUserResponse:
|
||||||
|
type: object
|
||||||
|
required: [id]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
|
||||||
|
HttpErrorBadRequest:
|
||||||
|
type: object
|
||||||
|
description: Generic problem-details style error body used by some validation failures.
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
statusCode:
|
||||||
type: integer
|
type: integer
|
||||||
color:
|
invalidParams:
|
||||||
$ref: "#/components/schemas/CardColor"
|
type: array
|
||||||
ReverseCard:
|
items:
|
||||||
|
$ref: "#/components/schemas/InvalidParam"
|
||||||
|
|
||||||
|
InvalidParam:
|
||||||
|
type: object
|
||||||
|
required: [name, reason]
|
||||||
properties:
|
properties:
|
||||||
color:
|
name:
|
||||||
$ref: "#/components/schemas/CardColor"
|
type: string
|
||||||
PassCard:
|
reason:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
PlayerId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
|
||||||
|
Player:
|
||||||
|
type: object
|
||||||
|
required: [name, userId, hand, id]
|
||||||
properties:
|
properties:
|
||||||
color:
|
name:
|
||||||
$ref: "#/components/schemas/CardColor"
|
type: string
|
||||||
Plus2Card:
|
userId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
id:
|
||||||
|
$ref: "#/components/schemas/PlayerId"
|
||||||
|
hand:
|
||||||
|
description: The set of cards currently held by the player.
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Card"
|
||||||
|
|
||||||
|
GameList:
|
||||||
|
type: object
|
||||||
|
required: [aggregateId, status, players, winners]
|
||||||
properties:
|
properties:
|
||||||
color:
|
aggregateId:
|
||||||
$ref: "#/components/schemas/CardColor"
|
type: string
|
||||||
Plus4Card:
|
format: uuid
|
||||||
properties:
|
status:
|
||||||
nextColor:
|
$ref: "#/components/schemas/GameStatus"
|
||||||
$ref: "#/components/schemas/CardColor"
|
players:
|
||||||
ChangeColorCard:
|
type: array
|
||||||
properties:
|
items:
|
||||||
nextColor:
|
$ref: "#/components/schemas/Player"
|
||||||
$ref: "#/components/schemas/CardColor"
|
winners:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/PlayerId"
|
||||||
|
|
||||||
|
GameStatus:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- OPENING
|
||||||
|
- IS_STARTED
|
||||||
|
- FINISH
|
||||||
|
- CANCELED
|
||||||
|
|
||||||
CardColor:
|
CardColor:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -60,3 +266,401 @@ components:
|
|||||||
- Red
|
- Red
|
||||||
- Yellow
|
- Yellow
|
||||||
- Green
|
- Green
|
||||||
|
|
||||||
|
Card:
|
||||||
|
description: >
|
||||||
|
A playing card. Discriminated by the "type" field using the short
|
||||||
|
names declared on each Kotlin subtype (@SerialName), unlike
|
||||||
|
GameCommand/Notification below.
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/NumericCard"
|
||||||
|
- $ref: "#/components/schemas/ReverseCard"
|
||||||
|
- $ref: "#/components/schemas/PassCard"
|
||||||
|
- $ref: "#/components/schemas/Plus2Card"
|
||||||
|
- $ref: "#/components/schemas/Plus4Card"
|
||||||
|
- $ref: "#/components/schemas/ChangeColorCard"
|
||||||
|
discriminator:
|
||||||
|
propertyName: type
|
||||||
|
mapping:
|
||||||
|
Simple: "#/components/schemas/NumericCard"
|
||||||
|
Reverse: "#/components/schemas/ReverseCard"
|
||||||
|
Pass: "#/components/schemas/PassCard"
|
||||||
|
Plus2: "#/components/schemas/Plus2Card"
|
||||||
|
Plus4: "#/components/schemas/Plus4Card"
|
||||||
|
ChangeColor: "#/components/schemas/ChangeColorCard"
|
||||||
|
|
||||||
|
NumericCard:
|
||||||
|
description: A numbered card (0-9) of a given color.
|
||||||
|
type: object
|
||||||
|
required: [type, id, number, color]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [Simple]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
number:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 9
|
||||||
|
color:
|
||||||
|
$ref: "#/components/schemas/CardColor"
|
||||||
|
|
||||||
|
ReverseCard:
|
||||||
|
description: Reverses the turn order.
|
||||||
|
type: object
|
||||||
|
required: [type, id, color]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [Reverse]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
color:
|
||||||
|
$ref: "#/components/schemas/CardColor"
|
||||||
|
|
||||||
|
PassCard:
|
||||||
|
description: Skips the next player's turn.
|
||||||
|
type: object
|
||||||
|
required: [type, id, color]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [Pass]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
color:
|
||||||
|
$ref: "#/components/schemas/CardColor"
|
||||||
|
|
||||||
|
Plus2Card:
|
||||||
|
description: Forces the next player to draw 2 cards and skips their turn.
|
||||||
|
type: object
|
||||||
|
required: [type, id, color]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [Plus2]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
color:
|
||||||
|
$ref: "#/components/schemas/CardColor"
|
||||||
|
|
||||||
|
Plus4Card:
|
||||||
|
description: >
|
||||||
|
Forces the next player to draw 4 cards and skips their turn. The new
|
||||||
|
color is chosen separately, via the `chosenColor` field of
|
||||||
|
PlayCardCommand, and is not part of the card itself.
|
||||||
|
type: object
|
||||||
|
required: [type, id]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [Plus4]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
|
||||||
|
ChangeColorCard:
|
||||||
|
description: >
|
||||||
|
Changes the current color. The new color is chosen separately, via
|
||||||
|
the `chosenColor` field of PlayCardCommand, and is not part of the
|
||||||
|
card itself.
|
||||||
|
type: object
|
||||||
|
required: [type, id]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [ChangeColor]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
|
||||||
|
GameCommand:
|
||||||
|
description: >
|
||||||
|
A command sent by the client over the game WebSocket to act on a game.
|
||||||
|
Discriminated by "type", whose value is the fully-qualified Kotlin
|
||||||
|
class name of the command (no `@SerialName` is declared on these
|
||||||
|
types).
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/JoinTheGameCommand"
|
||||||
|
- $ref: "#/components/schemas/PlayCardCommand"
|
||||||
|
- $ref: "#/components/schemas/ReadyToPlayCommand"
|
||||||
|
- $ref: "#/components/schemas/TakeCartFromDrawPileCommand"
|
||||||
|
discriminator:
|
||||||
|
propertyName: type
|
||||||
|
mapping:
|
||||||
|
eventDemo.contexts.game.application.command.models.JoinTheGameCommand: "#/components/schemas/JoinTheGameCommand"
|
||||||
|
eventDemo.contexts.game.application.command.models.PlayCardCommand: "#/components/schemas/PlayCardCommand"
|
||||||
|
eventDemo.contexts.game.application.command.models.ReadyToPlayCommand: "#/components/schemas/ReadyToPlayCommand"
|
||||||
|
eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand: "#/components/schemas/TakeCartFromDrawPileCommand"
|
||||||
|
|
||||||
|
JoinTheGameCommand:
|
||||||
|
description: Join an existing (not yet started) game.
|
||||||
|
type: object
|
||||||
|
required: [type, userId, payload]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.command.models.JoinTheGameCommand]
|
||||||
|
userId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
payload:
|
||||||
|
type: object
|
||||||
|
required: [aggregateId]
|
||||||
|
properties:
|
||||||
|
aggregateId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: The id of the game to join.
|
||||||
|
|
||||||
|
ReadyToPlayCommand:
|
||||||
|
description: Mark the calling player as ready, so the game can start once everyone is ready.
|
||||||
|
type: object
|
||||||
|
required: [type, userId, payload]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.command.models.ReadyToPlayCommand]
|
||||||
|
userId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
payload:
|
||||||
|
type: object
|
||||||
|
required: [aggregateId, playerId]
|
||||||
|
properties:
|
||||||
|
aggregateId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
playerId:
|
||||||
|
$ref: "#/components/schemas/PlayerId"
|
||||||
|
|
||||||
|
TakeCartFromDrawPileCommand:
|
||||||
|
description: Draw a card from the draw pile.
|
||||||
|
type: object
|
||||||
|
required: [type, userId, payload]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand]
|
||||||
|
userId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
payload:
|
||||||
|
type: object
|
||||||
|
required: [aggregateId, playerId]
|
||||||
|
properties:
|
||||||
|
aggregateId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
playerId:
|
||||||
|
$ref: "#/components/schemas/PlayerId"
|
||||||
|
|
||||||
|
PlayCardCommand:
|
||||||
|
description: Play a card from the calling player's hand.
|
||||||
|
type: object
|
||||||
|
required: [type, userId, payload]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.command.models.PlayCardCommand]
|
||||||
|
userId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
payload:
|
||||||
|
type: object
|
||||||
|
required: [aggregateId, playerId, card]
|
||||||
|
properties:
|
||||||
|
aggregateId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
playerId:
|
||||||
|
$ref: "#/components/schemas/PlayerId"
|
||||||
|
card:
|
||||||
|
$ref: "#/components/schemas/Card"
|
||||||
|
chosenColor:
|
||||||
|
description: The color to switch to, only required when playing a Plus4Card or ChangeColorCard.
|
||||||
|
nullable: true
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/CardColor"
|
||||||
|
|
||||||
|
Notification:
|
||||||
|
description: >
|
||||||
|
A notification sent by the server, either replayed by `GET /games/{id}`
|
||||||
|
or streamed live over the game WebSocket. Discriminated by "type",
|
||||||
|
whose value is the fully-qualified Kotlin class name of the
|
||||||
|
notification (no `@SerialName` is declared on these types).
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/WelcomeToTheGameNotification"
|
||||||
|
- $ref: "#/components/schemas/PlayerAsJoinTheGameNotification"
|
||||||
|
- $ref: "#/components/schemas/PlayerWasReadyNotification"
|
||||||
|
- $ref: "#/components/schemas/TheGameWasStartedNotification"
|
||||||
|
- $ref: "#/components/schemas/ItsTheTurnOfNotification"
|
||||||
|
- $ref: "#/components/schemas/PlayerAsPlayACardNotification"
|
||||||
|
- $ref: "#/components/schemas/YourNewCardNotification"
|
||||||
|
- $ref: "#/components/schemas/PlayerHavePassNotification"
|
||||||
|
- $ref: "#/components/schemas/PilesShuffledNotification"
|
||||||
|
- $ref: "#/components/schemas/PlayerWinNotification"
|
||||||
|
discriminator:
|
||||||
|
propertyName: type
|
||||||
|
mapping:
|
||||||
|
eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification: "#/components/schemas/WelcomeToTheGameNotification"
|
||||||
|
eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification: "#/components/schemas/PlayerAsJoinTheGameNotification"
|
||||||
|
eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification: "#/components/schemas/PlayerWasReadyNotification"
|
||||||
|
eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification: "#/components/schemas/TheGameWasStartedNotification"
|
||||||
|
eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification: "#/components/schemas/ItsTheTurnOfNotification"
|
||||||
|
eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification: "#/components/schemas/PlayerAsPlayACardNotification"
|
||||||
|
eventDemo.contexts.game.application.notification.models.YourNewCardNotification: "#/components/schemas/YourNewCardNotification"
|
||||||
|
eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification: "#/components/schemas/PlayerHavePassNotification"
|
||||||
|
eventDemo.contexts.game.application.notification.models.PilesShuffledNotification: "#/components/schemas/PilesShuffledNotification"
|
||||||
|
eventDemo.contexts.game.application.notification.models.PlayerWinNotification: "#/components/schemas/PlayerWinNotification"
|
||||||
|
|
||||||
|
WelcomeToTheGameNotification:
|
||||||
|
description: Sent to a player right after they join a game, listing all players currently in it.
|
||||||
|
type: object
|
||||||
|
required: [type, id, players]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
players:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Player"
|
||||||
|
|
||||||
|
PlayerAsJoinTheGameNotification:
|
||||||
|
description: Sent to the other players when a new player joins the game.
|
||||||
|
type: object
|
||||||
|
required: [type, id, player]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
player:
|
||||||
|
$ref: "#/components/schemas/Player"
|
||||||
|
|
||||||
|
PlayerWasReadyNotification:
|
||||||
|
description: Sent to all players when a player marks themselves as ready.
|
||||||
|
type: object
|
||||||
|
required: [type, id, playerId]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
playerId:
|
||||||
|
$ref: "#/components/schemas/PlayerId"
|
||||||
|
|
||||||
|
TheGameWasStartedNotification:
|
||||||
|
description: Sent to each player when the game starts, with their initial hand.
|
||||||
|
type: object
|
||||||
|
required: [type, id, hand]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
hand:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Card"
|
||||||
|
|
||||||
|
ItsTheTurnOfNotification:
|
||||||
|
description: Sent to all players to indicate whose turn it now is.
|
||||||
|
type: object
|
||||||
|
required: [type, id, player]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
player:
|
||||||
|
$ref: "#/components/schemas/Player"
|
||||||
|
|
||||||
|
PlayerAsPlayACardNotification:
|
||||||
|
description: Sent to all players when a player plays a card.
|
||||||
|
type: object
|
||||||
|
required: [type, id, playerId, card]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
playerId:
|
||||||
|
$ref: "#/components/schemas/PlayerId"
|
||||||
|
card:
|
||||||
|
$ref: "#/components/schemas/Card"
|
||||||
|
|
||||||
|
YourNewCardNotification:
|
||||||
|
description: Sent to a player with the cards they just drew from the draw pile.
|
||||||
|
type: object
|
||||||
|
required: [type, id, cards]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.notification.models.YourNewCardNotification]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
cards:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Card"
|
||||||
|
|
||||||
|
PlayerHavePassNotification:
|
||||||
|
description: Sent to the other players when a player draws a card and passes their turn.
|
||||||
|
type: object
|
||||||
|
required: [type, id, playerId]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
playerId:
|
||||||
|
$ref: "#/components/schemas/PlayerId"
|
||||||
|
|
||||||
|
PilesShuffledNotification:
|
||||||
|
description: Sent to all players when the discard pile is reshuffled into the draw pile.
|
||||||
|
type: object
|
||||||
|
required: [type, id]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.notification.models.PilesShuffledNotification]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
|
||||||
|
PlayerWinNotification:
|
||||||
|
description: Sent to all players when a player wins the game.
|
||||||
|
type: object
|
||||||
|
required: [type, id, playerId]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [eventDemo.contexts.game.application.notification.models.PlayerWinNotification]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
playerId:
|
||||||
|
$ref: "#/components/schemas/PlayerId"
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package eventDemo.adapter.infrastructureLayer.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.GameEventStore
|
|
||||||
import eventDemo.business.event.event.GameEvent
|
|
||||||
import eventDemo.libs.event.EventStore
|
|
||||||
import eventDemo.libs.event.EventStoreInMemory
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stream to publish and read the played card event.
|
|
||||||
*/
|
|
||||||
class GameEventStoreInMemory :
|
|
||||||
GameEventStore,
|
|
||||||
EventStore<GameEvent, GameId> by EventStoreInMemory()
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
package eventDemo.adapter.infrastructureLayer.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.GameEventStore
|
|
||||||
import eventDemo.business.event.event.GameEvent
|
|
||||||
import eventDemo.libs.event.EventStore
|
|
||||||
import eventDemo.libs.event.EventStoreInPostgresql
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import javax.sql.DataSource
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stream to publish and read the played card event.
|
|
||||||
*/
|
|
||||||
class GameEventStoreInPostgresql(
|
|
||||||
dataSource: DataSource,
|
|
||||||
) : GameEventStore,
|
|
||||||
EventStore<GameEvent, GameId> by EventStoreInPostgresql(
|
|
||||||
dataSource,
|
|
||||||
{ Json.encodeToString(it) },
|
|
||||||
{ Json.decodeFromString(it) },
|
|
||||||
)
|
|
||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
package eventDemo.adapter.infrastructureLayer.event.projection
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.GameEventBus
|
|
||||||
import eventDemo.business.event.projection.GameList
|
|
||||||
import eventDemo.business.event.projection.GameListRepository
|
|
||||||
import eventDemo.business.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.business.event.projection.GameState
|
|
||||||
import eventDemo.business.event.projection.apply
|
|
||||||
import eventDemo.libs.event.projection.ProjectionRepositoryInMemory
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus].
|
|
||||||
*/
|
|
||||||
class GameListRepositoryInMemory : GameListRepository {
|
|
||||||
private val projectionsRepository =
|
|
||||||
ProjectionRepositoryInMemory(
|
|
||||||
applyToProjection = GameList::apply,
|
|
||||||
initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) },
|
|
||||||
)
|
|
||||||
|
|
||||||
fun subscribeToBus(
|
|
||||||
projectionBus: GameProjectionBus,
|
|
||||||
eventBus: GameEventBus,
|
|
||||||
) {
|
|
||||||
// On new event was received, build projection and publish it to the projection bus
|
|
||||||
eventBus.subscribe { event ->
|
|
||||||
withLoggingContext("event" to event.toString()) {
|
|
||||||
projectionsRepository
|
|
||||||
.applyAndSave(event)
|
|
||||||
.also { projectionBus.publish(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the last version of the [GameState] from the all eventStream.
|
|
||||||
*
|
|
||||||
* It fetches it from the local cache if possible, otherwise it builds it.
|
|
||||||
*/
|
|
||||||
override fun getList(): List<GameList> =
|
|
||||||
projectionsRepository.getList()
|
|
||||||
}
|
|
||||||
-51
@@ -1,51 +0,0 @@
|
|||||||
package eventDemo.adapter.infrastructureLayer.event.projection
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.GameEventBus
|
|
||||||
import eventDemo.business.event.projection.GameList
|
|
||||||
import eventDemo.business.event.projection.GameListRepository
|
|
||||||
import eventDemo.business.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.business.event.projection.GameState
|
|
||||||
import eventDemo.business.event.projection.apply
|
|
||||||
import eventDemo.libs.event.projection.ProjectionRepositoryInRedis
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import redis.clients.jedis.UnifiedJedis
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages [projections][GameList], their building and publication in the [bus][GameProjectionBus].
|
|
||||||
*/
|
|
||||||
class GameListRepositoryInRedis(
|
|
||||||
jedis: UnifiedJedis,
|
|
||||||
) : GameListRepository {
|
|
||||||
private val projectionsRepository =
|
|
||||||
ProjectionRepositoryInRedis(
|
|
||||||
initialStateBuilder = { aggregateId: GameId -> GameList(aggregateId) },
|
|
||||||
projectionClass = GameList::class,
|
|
||||||
projectionToJson = { Json.encodeToString(GameList.serializer(), it) },
|
|
||||||
jsonToProjection = { Json.decodeFromString(GameList.serializer(), it) },
|
|
||||||
applyToProjection = GameList::apply,
|
|
||||||
jedis = jedis,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun subscribeToBus(
|
|
||||||
projectionBus: GameProjectionBus,
|
|
||||||
eventBus: GameEventBus,
|
|
||||||
) {
|
|
||||||
eventBus.subscribe { event ->
|
|
||||||
withLoggingContext("event" to event.toString()) {
|
|
||||||
projectionsRepository
|
|
||||||
.applyAndSave(event)
|
|
||||||
.also { projectionBus.publish(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the last version of the [GameState] from the all eventStream.
|
|
||||||
*
|
|
||||||
* It fetches it from the local cache if possible, otherwise it builds it.
|
|
||||||
*/
|
|
||||||
override fun getList(): List<GameList> =
|
|
||||||
projectionsRepository.getList()
|
|
||||||
}
|
|
||||||
-41
@@ -1,41 +0,0 @@
|
|||||||
package eventDemo.adapter.infrastructureLayer.event.projection
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.GameEventBus
|
|
||||||
import eventDemo.business.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.business.event.projection.GameState
|
|
||||||
import eventDemo.business.event.projection.GameStateRepository
|
|
||||||
import eventDemo.business.event.projection.apply
|
|
||||||
import eventDemo.libs.event.projection.ProjectionRepositoryInMemory
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus].
|
|
||||||
*/
|
|
||||||
class GameStateRepositoryInMemory : GameStateRepository {
|
|
||||||
private val projectionsRepository =
|
|
||||||
ProjectionRepositoryInMemory(
|
|
||||||
applyToProjection = GameState::apply,
|
|
||||||
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
|
|
||||||
)
|
|
||||||
|
|
||||||
fun subscribeToBus(
|
|
||||||
projectionBus: GameProjectionBus,
|
|
||||||
eventBus: GameEventBus,
|
|
||||||
) {
|
|
||||||
// On new event was received, build projection and publish it to the projection bus
|
|
||||||
eventBus.subscribe { event ->
|
|
||||||
withLoggingContext("event" to event.toString()) {
|
|
||||||
projectionsRepository
|
|
||||||
.applyAndSave(event)
|
|
||||||
.also { projectionBus.publish(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the [GameState].
|
|
||||||
*/
|
|
||||||
override fun get(gameId: GameId): GameState =
|
|
||||||
projectionsRepository.get(gameId)
|
|
||||||
}
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
package eventDemo.adapter.infrastructureLayer.event.projection
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.GameEventBus
|
|
||||||
import eventDemo.business.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.business.event.projection.GameState
|
|
||||||
import eventDemo.business.event.projection.GameStateRepository
|
|
||||||
import eventDemo.business.event.projection.apply
|
|
||||||
import eventDemo.libs.event.projection.ProjectionRepositoryInRedis
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import redis.clients.jedis.UnifiedJedis
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages [projections][GameState], their building and publication in the [bus][GameProjectionBus].
|
|
||||||
*/
|
|
||||||
class GameStateRepositoryInRedis(
|
|
||||||
jedis: UnifiedJedis,
|
|
||||||
) : GameStateRepository {
|
|
||||||
private val projectionsRepository =
|
|
||||||
ProjectionRepositoryInRedis(
|
|
||||||
initialStateBuilder = { aggregateId: GameId -> GameState(aggregateId) },
|
|
||||||
projectionClass = GameState::class,
|
|
||||||
projectionToJson = { Json.encodeToString(GameState.serializer(), it) },
|
|
||||||
jsonToProjection = { Json.decodeFromString(GameState.serializer(), it) },
|
|
||||||
applyToProjection = GameState::apply,
|
|
||||||
jedis = jedis,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun subscribeToBus(
|
|
||||||
projectionBus: GameProjectionBus,
|
|
||||||
eventBus: GameEventBus,
|
|
||||||
) {
|
|
||||||
// On new event was received, build projection and publish it to the projection bus
|
|
||||||
eventBus.subscribe { event ->
|
|
||||||
withLoggingContext("event" to event.toString()) {
|
|
||||||
projectionsRepository
|
|
||||||
.applyAndSave(event)
|
|
||||||
.also { projectionBus.publish(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the [GameState].
|
|
||||||
*/
|
|
||||||
override fun get(gameId: GameId): GameState =
|
|
||||||
projectionsRepository.get(gameId)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package eventDemo.adapter.interfaceLayer.query
|
|
||||||
|
|
||||||
import eventDemo.business.command.GameCommandHandler
|
|
||||||
import eventDemo.business.command.command.GameCommand
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.projection.projectionListener.PlayerNotificationListener
|
|
||||||
import eventDemo.business.notification.Notification
|
|
||||||
import eventDemo.libs.fromFrameChannel
|
|
||||||
import eventDemo.libs.toObjectChannel
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
import io.ktor.server.auth.authenticate
|
|
||||||
import io.ktor.server.routing.Route
|
|
||||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
|
||||||
import io.ktor.server.websocket.webSocket
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@DelicateCoroutinesApi
|
|
||||||
fun Route.gameWebSocket(
|
|
||||||
playerNotificationListener: PlayerNotificationListener,
|
|
||||||
commandHandler: GameCommandHandler,
|
|
||||||
) {
|
|
||||||
authenticate {
|
|
||||||
webSocket("/games/new") {
|
|
||||||
runWebSocket(GameId(), commandHandler, playerNotificationListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
webSocket("/games/{id}") {
|
|
||||||
val gameId = GameId(UUID.fromString(call.parameters["id"]!!))
|
|
||||||
runWebSocket(gameId, commandHandler, playerNotificationListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@DelicateCoroutinesApi
|
|
||||||
private fun DefaultWebSocketServerSession.runWebSocket(
|
|
||||||
gameId: GameId,
|
|
||||||
commandHandler: GameCommandHandler,
|
|
||||||
playerNotificationListener: PlayerNotificationListener,
|
|
||||||
) {
|
|
||||||
val currentPlayer = call.getPlayerCredentials()
|
|
||||||
val incomingFrameChannel: ReceiveChannel<GameCommand> = toObjectChannel(incoming)
|
|
||||||
val outgoingFrameChannel: SendChannel<Notification> = fromFrameChannel(outgoing)
|
|
||||||
withLoggingContext("currentPlayer" to currentPlayer.toString()) {
|
|
||||||
val notificationListener =
|
|
||||||
playerNotificationListener.startListening(
|
|
||||||
currentPlayer,
|
|
||||||
gameId,
|
|
||||||
) { outgoingFrameChannel.trySendBlocking(it) }
|
|
||||||
|
|
||||||
// TODO change GlobalScope
|
|
||||||
GlobalScope.launch {
|
|
||||||
commandHandler.handleIncomingPlayerCommands(
|
|
||||||
currentPlayer,
|
|
||||||
gameId,
|
|
||||||
incomingFrameChannel,
|
|
||||||
outgoingFrameChannel,
|
|
||||||
)
|
|
||||||
notificationListener.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package eventDemo.adapter.interfaceLayer.query
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import io.ktor.server.application.ApplicationCall
|
|
||||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
|
||||||
import io.ktor.server.auth.principal
|
|
||||||
|
|
||||||
internal fun ApplicationCall.getPlayerCredentials() =
|
|
||||||
principal<JWTPrincipal>()!!.run {
|
|
||||||
Player(
|
|
||||||
id = payload.getClaim("playerid").asString(),
|
|
||||||
name = payload.getClaim("username").asString(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package eventDemo.adapter.interfaceLayer.query
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.projection.GameStateRepository
|
|
||||||
import eventDemo.configuration.serializer.GameIdSerializer
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.resources.Resource
|
|
||||||
import io.ktor.server.auth.authenticate
|
|
||||||
import io.ktor.server.resources.get
|
|
||||||
import io.ktor.server.response.respond
|
|
||||||
import io.ktor.server.routing.Route
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@Resource("/games/{id}")
|
|
||||||
class Game(
|
|
||||||
@Serializable(with = GameIdSerializer::class)
|
|
||||||
val id: GameId,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
@Resource("card/last")
|
|
||||||
class Card(
|
|
||||||
val game: Game,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@Resource("state")
|
|
||||||
class State(
|
|
||||||
val game: Game,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API routes to read the game state.
|
|
||||||
*/
|
|
||||||
fun Route.readTheGameState(gameStateRepository: GameStateRepository) {
|
|
||||||
authenticate {
|
|
||||||
// Read the last played card on the game.
|
|
||||||
get<Game.Card> { body ->
|
|
||||||
gameStateRepository
|
|
||||||
.get(body.game.id)
|
|
||||||
.cardOnCurrentStack
|
|
||||||
?.let { call.respond(it) }
|
|
||||||
?: call.response.status(HttpStatusCode.BadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the last played card on the game.
|
|
||||||
get<Game.State> { body ->
|
|
||||||
val state = gameStateRepository.get(body.game.id)
|
|
||||||
call.respond(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package eventDemo.business.command
|
|
||||||
|
|
||||||
import eventDemo.business.command.action.ICantPlay
|
|
||||||
import eventDemo.business.command.action.IWantToJoinTheGame
|
|
||||||
import eventDemo.business.command.action.IWantToPlayCard
|
|
||||||
import eventDemo.business.command.action.IamReadyToPlay
|
|
||||||
import eventDemo.business.command.command.GameCommand
|
|
||||||
import eventDemo.business.command.command.ICantPlayCommand
|
|
||||||
import eventDemo.business.command.command.IWantToJoinTheGameCommand
|
|
||||||
import eventDemo.business.command.command.IWantToPlayCardCommand
|
|
||||||
import eventDemo.business.command.command.IamReadyToPlayCommand
|
|
||||||
import eventDemo.business.event.event.GameEvent
|
|
||||||
|
|
||||||
class GameCommandActionRunner(
|
|
||||||
private val iWantToPlayCard: IWantToPlayCard,
|
|
||||||
private val iamReadyToPlay: IamReadyToPlay,
|
|
||||||
private val iWantToJoinTheGame: IWantToJoinTheGame,
|
|
||||||
private val iCantPlay: ICantPlay,
|
|
||||||
) {
|
|
||||||
fun run(command: GameCommand): (version: Int) -> GameEvent =
|
|
||||||
when (command) {
|
|
||||||
is IWantToPlayCardCommand -> iWantToPlayCard.run(command)
|
|
||||||
is IamReadyToPlayCommand -> iamReadyToPlay.run(command)
|
|
||||||
is IWantToJoinTheGameCommand -> iWantToJoinTheGame.run(command)
|
|
||||||
is ICantPlayCommand -> iCantPlay.run(command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package eventDemo.business.command
|
|
||||||
|
|
||||||
import eventDemo.business.command.command.GameCommand
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.business.event.GameEventBus
|
|
||||||
import eventDemo.business.event.GameEventStore
|
|
||||||
import eventDemo.business.event.event.GameEvent
|
|
||||||
import eventDemo.business.notification.CommandErrorNotification
|
|
||||||
import eventDemo.business.notification.CommandSuccessNotification
|
|
||||||
import eventDemo.business.notification.Notification
|
|
||||||
import eventDemo.libs.command.CommandHandler
|
|
||||||
import eventDemo.libs.command.CommandRunnerController
|
|
||||||
import eventDemo.libs.event.EventHandlerImpl
|
|
||||||
import eventDemo.libs.event.VersionBuilder
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listen [GameCommand] on [GameEventBus], check the validity and execute an action.
|
|
||||||
*
|
|
||||||
* This action can be executing an action and produce a new [GameEvent] after verification.
|
|
||||||
*/
|
|
||||||
class GameCommandHandler(
|
|
||||||
eventBus: GameEventBus,
|
|
||||||
eventStore: GameEventStore,
|
|
||||||
versionBuilder: VersionBuilder,
|
|
||||||
runner: GameCommandActionRunner,
|
|
||||||
) {
|
|
||||||
private val logger = KotlinLogging.logger { }
|
|
||||||
|
|
||||||
private val eventHandler =
|
|
||||||
EventHandlerImpl(
|
|
||||||
eventBus,
|
|
||||||
eventStore,
|
|
||||||
versionBuilder,
|
|
||||||
)
|
|
||||||
private val commandHandler =
|
|
||||||
CommandHandler(
|
|
||||||
CommandRunnerController<GameCommand>(),
|
|
||||||
eventHandler,
|
|
||||||
) {
|
|
||||||
runner.run(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to the [event bus][GameEventBus]
|
|
||||||
* to send success [notification][Notification] after save the [event][GameEvent].
|
|
||||||
*/
|
|
||||||
fun subscribeToBus(eventBus: GameEventBus) =
|
|
||||||
commandHandler.subscribeToBus(eventBus)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lisent incoming [command][GameCommand] from the [channel][ReceiveChannel],
|
|
||||||
* run the command and publish the generated [event][GameEvent] to the bus.
|
|
||||||
*
|
|
||||||
* It restricts to run only once a command.
|
|
||||||
*
|
|
||||||
* If the command fail, send an [error notification][CommandErrorNotification],
|
|
||||||
* if success, send a [success notification][CommandSuccessNotification]
|
|
||||||
*/
|
|
||||||
suspend fun handleIncomingPlayerCommands(
|
|
||||||
player: Player,
|
|
||||||
gameId: GameId,
|
|
||||||
incomingCommandChannel: ReceiveChannel<GameCommand>,
|
|
||||||
channelNotification: SendChannel<Notification>,
|
|
||||||
) {
|
|
||||||
for (command in incomingCommandChannel) {
|
|
||||||
handle(
|
|
||||||
player,
|
|
||||||
gameId,
|
|
||||||
command,
|
|
||||||
channelNotification.sendSuccess(command),
|
|
||||||
channelNotification.sendError(command),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the [command] and publish the generated [event][GameEvent] to the bus.
|
|
||||||
*
|
|
||||||
* It restricts to run only once a command.
|
|
||||||
*
|
|
||||||
* If the command fail, send an [error notification][CommandErrorNotification],
|
|
||||||
* if success, send a [success notification][CommandSuccessNotification]
|
|
||||||
*/
|
|
||||||
fun handle(
|
|
||||||
player: Player,
|
|
||||||
gameId: GameId,
|
|
||||||
command: GameCommand,
|
|
||||||
sendSuccess: () -> Unit,
|
|
||||||
sendError: (message: String) -> Unit,
|
|
||||||
) {
|
|
||||||
if (command.payload.aggregateId.id != gameId.id) {
|
|
||||||
logger.warn { "Handle command Refuse, the gameId of the command is not the same" }
|
|
||||||
sendError("The gameId in the command does not match with your game")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (command.payload.player.id != player.id) {
|
|
||||||
logger.warn { "Handle command Refuse, the player of the command is not the same" }
|
|
||||||
sendError("You are not the author of this command")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
commandHandler.handle(gameId, command) { _, error ->
|
|
||||||
if (error != null) {
|
|
||||||
sendError(error.message) // Business
|
|
||||||
} else {
|
|
||||||
sendSuccess()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SendChannel<Notification>.sendSuccess(command: GameCommand): () -> Unit =
|
|
||||||
{
|
|
||||||
val logger = KotlinLogging.logger { }
|
|
||||||
CommandSuccessNotification(commandId = command.id)
|
|
||||||
.also { notification ->
|
|
||||||
withLoggingContext("notification" to notification.toString(), "commandId" to command.id.toString()) {
|
|
||||||
logger.debug { "Notification SUCCESS sent" }
|
|
||||||
trySend(notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SendChannel<Notification>.sendError(command: GameCommand): (message: String) -> Unit =
|
|
||||||
{
|
|
||||||
val logger = KotlinLogging.logger { }
|
|
||||||
CommandErrorNotification(message = it, command = command)
|
|
||||||
.also { notification ->
|
|
||||||
withLoggingContext("notification" to notification.toString(), "command" to command.toString()) {
|
|
||||||
logger.warn { "Notification ERROR sent: ${notification.message}" }
|
|
||||||
trySend(notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package eventDemo.business.command.action
|
|
||||||
|
|
||||||
import eventDemo.libs.command.Command
|
|
||||||
import eventDemo.libs.event.Event
|
|
||||||
|
|
||||||
sealed interface CommandAction<C : Command, E : Event<*>> {
|
|
||||||
fun run(command: C): (version: Int) -> E
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package eventDemo.business.command.action
|
|
||||||
|
|
||||||
import eventDemo.business.command.CommandException
|
|
||||||
import eventDemo.business.command.command.ICantPlayCommand
|
|
||||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
|
||||||
import eventDemo.business.event.projection.GameStateRepository
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to perform an action to play a new card
|
|
||||||
*/
|
|
||||||
data class ICantPlay(
|
|
||||||
private val gameStateRepository: GameStateRepository,
|
|
||||||
) : CommandAction<ICantPlayCommand, PlayerHavePassEvent> {
|
|
||||||
override fun run(command: ICantPlayCommand): (version: Int) -> PlayerHavePassEvent {
|
|
||||||
val state = gameStateRepository.get(command.payload.aggregateId)
|
|
||||||
|
|
||||||
if (state.currentPlayerTurn != command.payload.player) {
|
|
||||||
throw CommandException("Its not your turn!")
|
|
||||||
}
|
|
||||||
|
|
||||||
val playableCards = state.playableCards(command.payload.player)
|
|
||||||
if (playableCards.isNotEmpty()) {
|
|
||||||
throw CommandException("You can and must play one card, like ${playableCards.first()::class.simpleName}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val takenCard = state.deck.stack.first()
|
|
||||||
return { version ->
|
|
||||||
PlayerHavePassEvent(
|
|
||||||
aggregateId = command.payload.aggregateId,
|
|
||||||
player = command.payload.player,
|
|
||||||
takenCard = takenCard,
|
|
||||||
version = version,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package eventDemo.business.command.action
|
|
||||||
|
|
||||||
import eventDemo.business.command.CommandException
|
|
||||||
import eventDemo.business.command.command.IWantToJoinTheGameCommand
|
|
||||||
import eventDemo.business.event.event.NewPlayerEvent
|
|
||||||
import eventDemo.business.event.projection.GameStateRepository
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to perform an action to play a new card
|
|
||||||
*/
|
|
||||||
data class IWantToJoinTheGame(
|
|
||||||
private val gameStateRepository: GameStateRepository,
|
|
||||||
) : CommandAction<IWantToJoinTheGameCommand, NewPlayerEvent> {
|
|
||||||
override fun run(command: IWantToJoinTheGameCommand): (version: Int) -> NewPlayerEvent {
|
|
||||||
val state = gameStateRepository.get(command.payload.aggregateId)
|
|
||||||
if (!state.isStarted) {
|
|
||||||
return {
|
|
||||||
NewPlayerEvent(
|
|
||||||
aggregateId = command.payload.aggregateId,
|
|
||||||
player = command.payload.player,
|
|
||||||
version = it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw CommandException("The game is already started")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package eventDemo.business.command.action
|
|
||||||
|
|
||||||
import eventDemo.business.command.CommandException
|
|
||||||
import eventDemo.business.command.command.IWantToPlayCardCommand
|
|
||||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
|
||||||
import eventDemo.business.event.projection.GameStateRepository
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to perform an action to play a new card
|
|
||||||
*/
|
|
||||||
data class IWantToPlayCard(
|
|
||||||
private val gameStateRepository: GameStateRepository,
|
|
||||||
) : CommandAction<IWantToPlayCardCommand, CardIsPlayedEvent> {
|
|
||||||
override fun run(command: IWantToPlayCardCommand): (version: Int) -> CardIsPlayedEvent {
|
|
||||||
val state = gameStateRepository.get(command.payload.aggregateId)
|
|
||||||
|
|
||||||
if (!state.isStarted) {
|
|
||||||
throw CommandException("The game is Not started")
|
|
||||||
}
|
|
||||||
if (state.currentPlayerTurn != command.payload.player) {
|
|
||||||
throw CommandException("Its not your turn!")
|
|
||||||
}
|
|
||||||
if (!state.canBePlayThisCard(command.payload.player, command.payload.card)) {
|
|
||||||
throw CommandException("You cannot play this card")
|
|
||||||
}
|
|
||||||
|
|
||||||
return { version ->
|
|
||||||
CardIsPlayedEvent(
|
|
||||||
aggregateId = command.payload.aggregateId,
|
|
||||||
card = command.payload.card,
|
|
||||||
player = command.payload.player,
|
|
||||||
version = version,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package eventDemo.business.command.action
|
|
||||||
|
|
||||||
import eventDemo.business.command.CommandException
|
|
||||||
import eventDemo.business.command.command.IamReadyToPlayCommand
|
|
||||||
import eventDemo.business.event.event.PlayerReadyEvent
|
|
||||||
import eventDemo.business.event.projection.GameStateRepository
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to set as ready to play
|
|
||||||
*/
|
|
||||||
class IamReadyToPlay(
|
|
||||||
private val gameStateRepository: GameStateRepository,
|
|
||||||
) : CommandAction<IamReadyToPlayCommand, PlayerReadyEvent> {
|
|
||||||
@Throws(CommandException::class)
|
|
||||||
override fun run(command: IamReadyToPlayCommand): (version: Int) -> PlayerReadyEvent {
|
|
||||||
val state = gameStateRepository.get(command.payload.aggregateId)
|
|
||||||
val playerExist: Boolean = state.players.contains(command.payload.player)
|
|
||||||
val playerIsAlreadyReady: Boolean = state.readyPlayers.contains(command.payload.player)
|
|
||||||
|
|
||||||
if (state.isStarted) {
|
|
||||||
throw CommandException("The game is already started")
|
|
||||||
} else if (!playerExist) {
|
|
||||||
throw CommandException("You are not in the game")
|
|
||||||
} else if (playerIsAlreadyReady) {
|
|
||||||
throw CommandException("You are already ready")
|
|
||||||
} else {
|
|
||||||
return { version: Int ->
|
|
||||||
PlayerReadyEvent(
|
|
||||||
aggregateId = command.payload.aggregateId,
|
|
||||||
player = command.payload.player,
|
|
||||||
version = version,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package eventDemo.business.command.command
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.libs.command.Command
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
sealed interface GameCommand : Command {
|
|
||||||
val payload: Payload
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
sealed interface Payload {
|
|
||||||
val aggregateId: GameId
|
|
||||||
val player: Player
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package eventDemo.business.command.command
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.libs.command.CommandId
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to perform an action to play a new card
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class IWantToJoinTheGameCommand(
|
|
||||||
override val payload: Payload,
|
|
||||||
) : GameCommand {
|
|
||||||
override val id: CommandId = CommandId()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Payload(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
override val player: Player,
|
|
||||||
) : GameCommand.Payload
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package eventDemo.business.command.command
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Card
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.libs.command.CommandId
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to perform an action to play a new card
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class IWantToPlayCardCommand(
|
|
||||||
override val payload: Payload,
|
|
||||||
) : GameCommand {
|
|
||||||
override val id: CommandId = CommandId()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Payload(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
override val player: Player,
|
|
||||||
val card: Card,
|
|
||||||
) : GameCommand.Payload
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package eventDemo.business.command.command
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.libs.command.CommandId
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to set as ready to play
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class IamReadyToPlayCommand(
|
|
||||||
override val payload: Payload,
|
|
||||||
) : GameCommand {
|
|
||||||
override val id: CommandId = CommandId()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Payload(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
override val player: Player,
|
|
||||||
) : GameCommand.Payload
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package eventDemo.business.entity
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Deck(
|
|
||||||
val stack: Stack = Stack(),
|
|
||||||
val discard: Discard = Discard(),
|
|
||||||
val playersHands: PlayersHands = PlayersHands(),
|
|
||||||
) {
|
|
||||||
constructor(players: Set<Player>) :
|
|
||||||
this(playersHands = PlayersHands(players))
|
|
||||||
|
|
||||||
fun shuffle(): Deck =
|
|
||||||
copy(stack = stack.shuffle())
|
|
||||||
|
|
||||||
fun placeFirstCardOnDiscard(): Deck {
|
|
||||||
val takenCard = stack.first()
|
|
||||||
return copy(
|
|
||||||
stack = stack - takenCard,
|
|
||||||
discard = discard + takenCard,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun takeOneCardFromStackTo(player: Player): Deck =
|
|
||||||
takeOne().let { (deck, newPlayerCard) ->
|
|
||||||
deck.copy(
|
|
||||||
playersHands = deck.playersHands.addCard(player, newPlayerCard),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun putOneCardFromHand(
|
|
||||||
player: Player,
|
|
||||||
card: Card,
|
|
||||||
): Deck =
|
|
||||||
run {
|
|
||||||
// Validate parameters
|
|
||||||
val playerHand =
|
|
||||||
playersHands.getHand(player)
|
|
||||||
?: error("No player on this game")
|
|
||||||
if (playerHand.none { it == card }) {
|
|
||||||
error("No card exist on the player hand")
|
|
||||||
}
|
|
||||||
}.let {
|
|
||||||
copy(
|
|
||||||
discard = discard + card,
|
|
||||||
playersHands = playersHands.removeCard(player, card),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playerHasNoCardLeft(): List<Player.PlayerId> =
|
|
||||||
playersHands
|
|
||||||
.filter { (playerId, hand) -> hand.isEmpty() }
|
|
||||||
.map { (playerId, hand) -> playerId }
|
|
||||||
|
|
||||||
private fun take(n: Int): Pair<Deck, List<Card>> {
|
|
||||||
val takenCards = stack.take(n)
|
|
||||||
val newStack = stack.filterNot { takenCards.contains(it) }.toStack()
|
|
||||||
return Pair(copy(stack = newStack), takenCards)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun takeOne(): Pair<Deck, Card> =
|
|
||||||
take(1).let { (deck, cards) -> Pair(deck, cards.first()) }
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun newWithoutPlayers(): Deck =
|
|
||||||
listOf(Card.Color.Red, Card.Color.Blue, Card.Color.Yellow, Card.Color.Green)
|
|
||||||
.flatMap { color ->
|
|
||||||
((0..9) + (1..9)).map { Card.NumericCard(it, color) } +
|
|
||||||
(1..2).map { Card.Plus2Card(color) } +
|
|
||||||
(1..2).map { Card.ReverseCard(color) } +
|
|
||||||
(1..2).map { Card.PassCard(color) }
|
|
||||||
}.let {
|
|
||||||
it + (1..4).map { Card.Plus4Card() }
|
|
||||||
}.toStack()
|
|
||||||
.let { Deck(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Deck.initHands(
|
|
||||||
players: Set<Player>,
|
|
||||||
handSize: Int = 7,
|
|
||||||
): Deck {
|
|
||||||
// Copy cards from stack to the player hands
|
|
||||||
val deckWithEmptyHands = copy(playersHands = PlayersHands(players))
|
|
||||||
return players.fold(deckWithEmptyHands) { acc: Deck, player: Player ->
|
|
||||||
val hand = acc.stack.take(handSize)
|
|
||||||
val newStack = acc.stack.filterNot { card: Card -> hand.contains(card) }.toStack()
|
|
||||||
copy(
|
|
||||||
stack = newStack,
|
|
||||||
playersHands = acc.playersHands.addCards(player, hand),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmInline
|
|
||||||
@Serializable
|
|
||||||
value class Stack(
|
|
||||||
private val cards: Set<Card> = emptySet(),
|
|
||||||
) : Set<Card> by cards {
|
|
||||||
operator fun plus(card: Card): Stack =
|
|
||||||
cards.plus(card).toStack()
|
|
||||||
|
|
||||||
operator fun minus(card: Card): Stack =
|
|
||||||
cards.minus(card).toStack()
|
|
||||||
|
|
||||||
fun shuffle(): Stack =
|
|
||||||
shuffled().toStack()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun List<Card>.toStack(): Stack =
|
|
||||||
Stack(this.toSet())
|
|
||||||
|
|
||||||
fun Set<Card>.toStack(): Stack =
|
|
||||||
Stack(this)
|
|
||||||
|
|
||||||
@JvmInline
|
|
||||||
@Serializable
|
|
||||||
value class Discard(
|
|
||||||
private val cards: Set<Card> = emptySet(),
|
|
||||||
) : Set<Card> by cards {
|
|
||||||
operator fun plus(card: Card): Discard =
|
|
||||||
cards.plus(card).toDiscard()
|
|
||||||
|
|
||||||
operator fun minus(card: Card): Discard =
|
|
||||||
cards.minus(card).toDiscard()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun List<Card>.toDiscard(): Discard =
|
|
||||||
Discard(this.toSet())
|
|
||||||
|
|
||||||
fun Set<Card>.toDiscard(): Discard =
|
|
||||||
Discard(this)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package eventDemo.business.entity
|
|
||||||
|
|
||||||
import eventDemo.configuration.serializer.PlayerIdSerializer
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import eventDemo.libs.event.AggregateId
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Player(
|
|
||||||
val name: String,
|
|
||||||
@Serializable(with = PlayerIdSerializer::class)
|
|
||||||
val id: PlayerId = PlayerId(UUID.randomUUID()),
|
|
||||||
) {
|
|
||||||
constructor(id: String, name: String) : this(
|
|
||||||
name,
|
|
||||||
PlayerId(UUID.fromString(id)),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@JvmInline
|
|
||||||
value class PlayerId(
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val id: UUID = UUID.randomUUID(),
|
|
||||||
) : AggregateId {
|
|
||||||
override fun toString(): String =
|
|
||||||
id.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package eventDemo.business.entity
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@JvmInline
|
|
||||||
value class PlayersHands(
|
|
||||||
private val map: Map<Player.PlayerId, List<Card>> = emptyMap(),
|
|
||||||
) : Map<Player.PlayerId, List<Card>> by map {
|
|
||||||
constructor(players: Set<Player>) :
|
|
||||||
this(players.map { it.id }.associateWith { emptyList<Card>() }.toPlayersHands())
|
|
||||||
|
|
||||||
fun getHand(player: Player): List<Card>? =
|
|
||||||
this[player.id]
|
|
||||||
|
|
||||||
fun removeCard(
|
|
||||||
player: Player,
|
|
||||||
card: Card,
|
|
||||||
): PlayersHands =
|
|
||||||
mapValues { (playerId, cards) ->
|
|
||||||
if (playerId == player.id) {
|
|
||||||
if (!cards.contains(card)) error("The hand no contain the card")
|
|
||||||
cards - card
|
|
||||||
} else {
|
|
||||||
cards
|
|
||||||
}
|
|
||||||
}.toPlayersHands()
|
|
||||||
|
|
||||||
fun addCard(
|
|
||||||
player: Player,
|
|
||||||
newCard: Card,
|
|
||||||
): PlayersHands =
|
|
||||||
addCards(player, listOf(newCard))
|
|
||||||
|
|
||||||
fun addCards(
|
|
||||||
player: Player,
|
|
||||||
newCards: List<Card>,
|
|
||||||
): PlayersHands =
|
|
||||||
mapValues { (p, cards) ->
|
|
||||||
if (p == player.id) {
|
|
||||||
if (cards.intersect(newCards).isNotEmpty()) error("The hand already contain the card")
|
|
||||||
cards + newCards
|
|
||||||
} else {
|
|
||||||
cards
|
|
||||||
}
|
|
||||||
}.toPlayersHands()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Map<Player.PlayerId, List<Card>>.toPlayersHands(): PlayersHands =
|
|
||||||
PlayersHands(this)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package eventDemo.business.event
|
|
||||||
|
|
||||||
import eventDemo.business.event.event.GameEvent
|
|
||||||
import eventDemo.libs.bus.Bus
|
|
||||||
|
|
||||||
interface GameEventBus : Bus<GameEvent>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package eventDemo.business.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.event.GameEvent
|
|
||||||
import eventDemo.libs.event.EventHandler
|
|
||||||
import eventDemo.libs.event.EventHandlerImpl
|
|
||||||
import eventDemo.libs.event.VersionBuilder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the event to dispatch it to store, bus and projections builders
|
|
||||||
*/
|
|
||||||
class GameEventHandler(
|
|
||||||
private val eventBus: GameEventBus,
|
|
||||||
private val eventStore: GameEventStore,
|
|
||||||
private val versionBuilder: VersionBuilder,
|
|
||||||
) : EventHandler<GameEvent, GameId> by EventHandlerImpl(eventBus, eventStore, versionBuilder)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package eventDemo.business.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.event.GameEvent
|
|
||||||
import eventDemo.libs.event.EventStore
|
|
||||||
|
|
||||||
interface GameEventStore : EventStore<GameEvent, GameId>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package eventDemo.business.event.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Card
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An [GameEvent] to represent a played card.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class CardIsPlayedEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
val card: Card,
|
|
||||||
override val player: Player,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent,
|
|
||||||
PlayerActionEvent {
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val eventId: UUID = UUID.randomUUID()
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package eventDemo.business.event.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.libs.event.Event
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An [Event] of a Game.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
sealed interface GameEvent : Event<GameId> {
|
|
||||||
override val eventId: UUID
|
|
||||||
override val aggregateId: GameId
|
|
||||||
override val version: Int
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package eventDemo.business.event.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Deck
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.business.entity.initHands
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [GameEvent] is sent when all players are ready.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class GameStartedEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
val firstPlayer: Player,
|
|
||||||
val deck: Deck,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent {
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val eventId: UUID = UUID.randomUUID()
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun new(
|
|
||||||
id: GameId,
|
|
||||||
players: Set<Player>,
|
|
||||||
version: Int,
|
|
||||||
shuffleIsDisabled: Boolean = isDisabled,
|
|
||||||
): GameStartedEvent =
|
|
||||||
GameStartedEvent(
|
|
||||||
aggregateId = id,
|
|
||||||
firstPlayer = if (shuffleIsDisabled) players.first() else players.random(),
|
|
||||||
deck =
|
|
||||||
Deck
|
|
||||||
.newWithoutPlayers()
|
|
||||||
.let { if (shuffleIsDisabled) it else it.shuffle() }
|
|
||||||
.initHands(players)
|
|
||||||
.placeFirstCardOnDiscard(),
|
|
||||||
version = version,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isDisabled = false
|
|
||||||
|
|
||||||
internal fun disableShuffleDeck() {
|
|
||||||
isDisabled = true
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package eventDemo.business.event.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An [GameEvent] to represent a new player joining the game.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class NewPlayerEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
val player: Player,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent {
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val eventId: UUID = UUID.randomUUID()
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package eventDemo.business.event.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
sealed interface PlayerActionEvent : GameEvent {
|
|
||||||
val player: Player
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package eventDemo.business.event.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Card
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [GameEvent] is sent when a player chose a color.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlayerChoseColorEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
override val player: Player,
|
|
||||||
val color: Card.Color,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent,
|
|
||||||
PlayerActionEvent {
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val eventId: UUID = UUID.randomUUID()
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package eventDemo.business.event.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Card
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [GameEvent] is sent when a player can play.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlayerHavePassEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
override val player: Player,
|
|
||||||
val takenCard: Card,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent,
|
|
||||||
PlayerActionEvent {
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val eventId: UUID = UUID.randomUUID()
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package eventDemo.business.event.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [GameEvent] is sent when a player is ready.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlayerReadyEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
val player: Player,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent {
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val eventId: UUID = UUID.randomUUID()
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package eventDemo.business.event.event
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [GameEvent] is sent when a player is ready.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlayerWinEvent(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
val player: Player,
|
|
||||||
override val version: Int,
|
|
||||||
) : GameEvent {
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val eventId: UUID = UUID.randomUUID()
|
|
||||||
override val createdAt: Instant = Clock.System.now()
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package eventDemo.business.event.projection
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.libs.event.projection.Projection
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
sealed interface GameProjection : Projection<GameId>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package eventDemo.business.event.projection
|
|
||||||
|
|
||||||
import eventDemo.libs.bus.Bus
|
|
||||||
|
|
||||||
interface GameProjectionBus : Bus<GameProjection>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package eventDemo.business.event.projection
|
|
||||||
|
|
||||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
|
||||||
import eventDemo.business.event.event.GameEvent
|
|
||||||
import eventDemo.business.event.event.GameStartedEvent
|
|
||||||
import eventDemo.business.event.event.NewPlayerEvent
|
|
||||||
import eventDemo.business.event.event.PlayerChoseColorEvent
|
|
||||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
|
||||||
import eventDemo.business.event.event.PlayerReadyEvent
|
|
||||||
import eventDemo.business.event.event.PlayerWinEvent
|
|
||||||
|
|
||||||
fun GameList.apply(event: GameEvent): GameList =
|
|
||||||
when (event) {
|
|
||||||
is NewPlayerEvent -> {
|
|
||||||
copy(
|
|
||||||
players = players + event.player,
|
|
||||||
status = GameList.Status.OPENING,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is GameStartedEvent -> {
|
|
||||||
copy(
|
|
||||||
status = GameList.Status.IS_STARTED,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerWinEvent -> {
|
|
||||||
copy(
|
|
||||||
winners = winners + event.player,
|
|
||||||
status = GameList.Status.FINISH,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is CardIsPlayedEvent -> {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerChoseColorEvent -> {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerHavePassEvent -> {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerReadyEvent -> {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}.copy(
|
|
||||||
lastEventVersion = event.version,
|
|
||||||
)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package eventDemo.business.event.projection
|
|
||||||
|
|
||||||
interface GameListRepository {
|
|
||||||
fun getList(): List<GameList>
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
package eventDemo.business.event.projection
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Card
|
|
||||||
import eventDemo.business.entity.Deck
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.business.event.event.GameEvent
|
|
||||||
import eventDemo.libs.event.projection.Projection
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This [projection][Projection] is used for manage a game and theirs [card][Card]
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class GameState(
|
|
||||||
override val aggregateId: GameId,
|
|
||||||
override val lastEventVersion: Int = 0,
|
|
||||||
val players: Set<Player> = emptySet(),
|
|
||||||
val currentPlayerTurn: Player? = null,
|
|
||||||
val lastCardPlayer: Player? = null,
|
|
||||||
val colorOnCurrentStack: Card.Color? = null,
|
|
||||||
val direction: Direction = Direction.CLOCKWISE,
|
|
||||||
val readyPlayers: Set<Player> = emptySet(),
|
|
||||||
val deck: Deck = Deck(players),
|
|
||||||
val isStarted: Boolean = false,
|
|
||||||
val playerWins: Set<Player> = emptySet(),
|
|
||||||
val lastEvent: GameEvent? = null,
|
|
||||||
) : GameProjection {
|
|
||||||
enum class Direction {
|
|
||||||
CLOCKWISE,
|
|
||||||
COUNTER_CLOCKWISE,
|
|
||||||
;
|
|
||||||
|
|
||||||
fun revert(): Direction =
|
|
||||||
if (this === CLOCKWISE) {
|
|
||||||
COUNTER_CLOCKWISE
|
|
||||||
} else {
|
|
||||||
CLOCKWISE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val cardOnCurrentStack: Card? = deck.discard.lastOrNull()
|
|
||||||
|
|
||||||
val isReady: Boolean get() {
|
|
||||||
return players.size == readyPlayers.size && players.all { readyPlayers.contains(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private val currentPlayerIndex: Int? get() {
|
|
||||||
val i = players.indexOf(currentPlayerTurn)
|
|
||||||
return if (i == -1) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nextPlayerIndex(direction: Direction): Int {
|
|
||||||
if (players.isEmpty()) return 0
|
|
||||||
|
|
||||||
return if (direction == Direction.CLOCKWISE) {
|
|
||||||
sidePlayerIndexClockwise
|
|
||||||
} else {
|
|
||||||
sidePlayerIndexCounterClockwise
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun nextPlayer(direction: Direction): Player =
|
|
||||||
players.elementAt(nextPlayerIndex(direction))
|
|
||||||
|
|
||||||
private val sidePlayerIndexClockwise: Int by lazy {
|
|
||||||
if (players.isEmpty()) {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
((currentPlayerIndex ?: 0) + 1) % players.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private val sidePlayerIndexCounterClockwise: Int by lazy {
|
|
||||||
if (players.isEmpty()) {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
((currentPlayerIndex ?: 0) - 1) % players.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val nextPlayerTurn: Player? by lazy {
|
|
||||||
if (players.isEmpty()) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
nextPlayer(direction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val Player.currentIndex: Int get() = players.indexOf(this)
|
|
||||||
|
|
||||||
fun Player.playerDiffIndex(nextPlayer: Player): Int =
|
|
||||||
if (direction == Direction.CLOCKWISE) {
|
|
||||||
nextPlayer.currentIndex + this.currentIndex
|
|
||||||
} else {
|
|
||||||
nextPlayer.currentIndex - this.currentIndex
|
|
||||||
}.let { it % players.size }
|
|
||||||
|
|
||||||
val Player.cardOnBoardIsForYou: Boolean get() {
|
|
||||||
if (lastCardPlayer == null) error("No card")
|
|
||||||
return this.playerDiffIndex(lastCardPlayer) == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playableCards(player: Player): List<Card> =
|
|
||||||
deck
|
|
||||||
.playersHands
|
|
||||||
.getHand(player)
|
|
||||||
?.filter { canBePlayThisCard(player, it) }
|
|
||||||
?: emptyList()
|
|
||||||
|
|
||||||
fun playerHasNoCardLeft(): List<Player> =
|
|
||||||
deck.playerHasNoCardLeft().map { playerId ->
|
|
||||||
players.find { it.id == playerId } ?: error("inconsistency detected between players")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun canBePlayThisCard(
|
|
||||||
player: Player,
|
|
||||||
card: Card,
|
|
||||||
): Boolean {
|
|
||||||
val cardOnBoard = cardOnCurrentStack ?: return false
|
|
||||||
return when (cardOnBoard) {
|
|
||||||
is Card.NumericCard -> {
|
|
||||||
when (card) {
|
|
||||||
is Card.AllColorCard -> true
|
|
||||||
is Card.NumericCard -> card.number == cardOnBoard.number || card.color == cardOnBoard.color
|
|
||||||
is Card.ColorCard -> card.color == cardOnBoard.color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Card.ReverseCard -> {
|
|
||||||
when (card) {
|
|
||||||
is Card.ReverseCard -> true
|
|
||||||
is Card.AllColorCard -> true
|
|
||||||
is Card.ColorCard -> card.color == cardOnBoard.color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Card.PassCard -> {
|
|
||||||
if (player.cardOnBoardIsForYou) {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
when (card) {
|
|
||||||
is Card.AllColorCard -> true
|
|
||||||
is Card.ColorCard -> card.color == cardOnBoard.color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Card.ChangeColorCard -> {
|
|
||||||
when (card) {
|
|
||||||
is Card.AllColorCard -> true
|
|
||||||
is Card.ColorCard -> card.color == colorOnCurrentStack
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Card.Plus2Card -> {
|
|
||||||
if (player.cardOnBoardIsForYou && card is Card.Plus2Card) {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
when (card) {
|
|
||||||
is Card.Plus2Card -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Card.Plus4Card -> {
|
|
||||||
if (player.cardOnBoardIsForYou && card is Card.Plus4Card) {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
when (card) {
|
|
||||||
is Card.AllColorCard -> true
|
|
||||||
is Card.ColorCard -> card.color == colorOnCurrentStack
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package eventDemo.business.event.projection
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Card
|
|
||||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
|
||||||
import eventDemo.business.event.event.GameEvent
|
|
||||||
import eventDemo.business.event.event.GameStartedEvent
|
|
||||||
import eventDemo.business.event.event.NewPlayerEvent
|
|
||||||
import eventDemo.business.event.event.PlayerActionEvent
|
|
||||||
import eventDemo.business.event.event.PlayerChoseColorEvent
|
|
||||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
|
||||||
import eventDemo.business.event.event.PlayerReadyEvent
|
|
||||||
import eventDemo.business.event.event.PlayerWinEvent
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
|
|
||||||
fun GameState.apply(event: GameEvent): GameState =
|
|
||||||
this.let { state ->
|
|
||||||
val logger = KotlinLogging.logger { }
|
|
||||||
if (event is PlayerActionEvent) {
|
|
||||||
if (state.currentPlayerTurn != event.player) {
|
|
||||||
logger.atError {
|
|
||||||
message = "Inconsistent player turn"
|
|
||||||
payload =
|
|
||||||
mapOf(
|
|
||||||
"CurrentPlayerTurn" to (state.currentPlayerTurn ?: "No currentPlayerTurn"),
|
|
||||||
"Player" to event.player,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when (event) {
|
|
||||||
is CardIsPlayedEvent -> {
|
|
||||||
val nextDirectionAfterPlay =
|
|
||||||
when (event.card) {
|
|
||||||
is Card.ReverseCard -> state.direction.revert()
|
|
||||||
else -> state.direction
|
|
||||||
}
|
|
||||||
|
|
||||||
val color =
|
|
||||||
when (event.card) {
|
|
||||||
is Card.ColorCard -> event.card.color
|
|
||||||
is Card.AllColorCard -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentPlayerAfterThePlay =
|
|
||||||
if (event.card is Card.AllColorCard) {
|
|
||||||
state.currentPlayerTurn
|
|
||||||
} else {
|
|
||||||
state.nextPlayer(nextDirectionAfterPlay)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.copy(
|
|
||||||
currentPlayerTurn = currentPlayerAfterThePlay,
|
|
||||||
direction = nextDirectionAfterPlay,
|
|
||||||
colorOnCurrentStack = color,
|
|
||||||
lastCardPlayer = event.player,
|
|
||||||
deck = state.deck.putOneCardFromHand(event.player, event.card),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is NewPlayerEvent -> {
|
|
||||||
if (state.isStarted) {
|
|
||||||
logger.error { "The game is already started" }
|
|
||||||
}
|
|
||||||
|
|
||||||
state.copy(
|
|
||||||
players = state.players + event.player,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerReadyEvent -> {
|
|
||||||
if (state.isStarted) {
|
|
||||||
logger.error { "The game is already started" }
|
|
||||||
}
|
|
||||||
state.copy(
|
|
||||||
readyPlayers = state.readyPlayers + event.player,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerHavePassEvent -> {
|
|
||||||
if (event.takenCard != state.deck.stack.first()) {
|
|
||||||
logger.error { "taken card is not ot top of the stack: ${event.takenCard}" }
|
|
||||||
}
|
|
||||||
state.copy(
|
|
||||||
currentPlayerTurn = state.nextPlayerTurn,
|
|
||||||
deck = state.deck.takeOneCardFromStackTo(event.player),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerChoseColorEvent -> {
|
|
||||||
state.copy(
|
|
||||||
currentPlayerTurn = state.nextPlayerTurn,
|
|
||||||
colorOnCurrentStack = event.color,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is GameStartedEvent -> {
|
|
||||||
state.copy(
|
|
||||||
colorOnCurrentStack = (event.deck.discard.first() as? Card.ColorCard)?.color ?: state.colorOnCurrentStack,
|
|
||||||
lastCardPlayer = null,
|
|
||||||
currentPlayerTurn = event.firstPlayer,
|
|
||||||
deck = event.deck,
|
|
||||||
isStarted = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerWinEvent -> {
|
|
||||||
state.copy(
|
|
||||||
playerWins = state.playerWins + event.player,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.copy(
|
|
||||||
lastEventVersion = event.version,
|
|
||||||
lastEvent = event,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package eventDemo.business.event.projection
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
|
|
||||||
interface GameStateRepository {
|
|
||||||
fun get(gameId: GameId): GameState
|
|
||||||
}
|
|
||||||
-149
@@ -1,149 +0,0 @@
|
|||||||
package eventDemo.business.event.projection.projectionListener
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Card
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.business.event.event.CardIsPlayedEvent
|
|
||||||
import eventDemo.business.event.event.GameStartedEvent
|
|
||||||
import eventDemo.business.event.event.NewPlayerEvent
|
|
||||||
import eventDemo.business.event.event.PlayerChoseColorEvent
|
|
||||||
import eventDemo.business.event.event.PlayerHavePassEvent
|
|
||||||
import eventDemo.business.event.event.PlayerReadyEvent
|
|
||||||
import eventDemo.business.event.event.PlayerWinEvent
|
|
||||||
import eventDemo.business.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.business.event.projection.GameState
|
|
||||||
import eventDemo.business.notification.ItsTheTurnOfNotification
|
|
||||||
import eventDemo.business.notification.Notification
|
|
||||||
import eventDemo.business.notification.PlayerAsJoinTheGameNotification
|
|
||||||
import eventDemo.business.notification.PlayerAsPlayACardNotification
|
|
||||||
import eventDemo.business.notification.PlayerHavePassNotification
|
|
||||||
import eventDemo.business.notification.PlayerWasChoseTheCardColorNotification
|
|
||||||
import eventDemo.business.notification.PlayerWasReadyNotification
|
|
||||||
import eventDemo.business.notification.PlayerWinNotification
|
|
||||||
import eventDemo.business.notification.TheGameWasStartedNotification
|
|
||||||
import eventDemo.business.notification.WelcomeToTheGameNotification
|
|
||||||
import eventDemo.business.notification.YourNewCardNotification
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
|
|
||||||
class PlayerNotificationListener(
|
|
||||||
private val projectionBus: GameProjectionBus,
|
|
||||||
) {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forward projection from [bus][GameProjectionBus] to the player [notification][outgoingNotification]
|
|
||||||
*/
|
|
||||||
fun startListening(
|
|
||||||
currentPlayer: Player,
|
|
||||||
gameId: GameId,
|
|
||||||
outgoingNotification: (Notification) -> Unit,
|
|
||||||
): AutoCloseable {
|
|
||||||
return projectionBus.subscribe { currentState ->
|
|
||||||
if (currentState !is GameState) return@subscribe
|
|
||||||
if (currentState.aggregateId != gameId) return@subscribe
|
|
||||||
withLoggingContext("currentPlayer" to currentPlayer.toString(), "projection" to currentState.toString()) {
|
|
||||||
fun Notification.send() {
|
|
||||||
withLoggingContext("notification" to this.toString()) {
|
|
||||||
if (currentState.players.contains(currentPlayer)) {
|
|
||||||
// Only notify players who have already joined the game.
|
|
||||||
outgoingNotification(this)
|
|
||||||
logger.info { "Notification was SEND" }
|
|
||||||
} else {
|
|
||||||
// Rare use case, when a connexion is created with the channel,
|
|
||||||
// but the player was not already join in the game
|
|
||||||
logger.warn { "Notification was SKIP, no player on the game" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendNextTurnNotif() =
|
|
||||||
ItsTheTurnOfNotification(
|
|
||||||
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
|
|
||||||
).send()
|
|
||||||
|
|
||||||
val event =
|
|
||||||
currentState.lastEvent
|
|
||||||
?: error("No last event in the GameState projection")
|
|
||||||
|
|
||||||
when (event) {
|
|
||||||
is NewPlayerEvent -> {
|
|
||||||
if (currentPlayer != event.player) {
|
|
||||||
PlayerAsJoinTheGameNotification(
|
|
||||||
player = event.player,
|
|
||||||
).send()
|
|
||||||
} else {
|
|
||||||
WelcomeToTheGameNotification(
|
|
||||||
players = currentState.players,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is CardIsPlayedEvent -> {
|
|
||||||
if (currentPlayer != event.player) {
|
|
||||||
PlayerAsPlayACardNotification(
|
|
||||||
player = event.player,
|
|
||||||
card = event.card,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.card !is Card.AllColorCard) {
|
|
||||||
ItsTheTurnOfNotification(
|
|
||||||
player = currentState.currentPlayerTurn ?: error("No player turn defined"),
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is GameStartedEvent -> {
|
|
||||||
TheGameWasStartedNotification(
|
|
||||||
hand =
|
|
||||||
event.deck.playersHands.getHand(currentPlayer)
|
|
||||||
?: error("You are not in the game"),
|
|
||||||
).send()
|
|
||||||
|
|
||||||
sendNextTurnNotif()
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerChoseColorEvent -> {
|
|
||||||
if (currentPlayer != event.player) {
|
|
||||||
PlayerWasChoseTheCardColorNotification(
|
|
||||||
player = event.player,
|
|
||||||
color = event.color,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
|
|
||||||
sendNextTurnNotif()
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerHavePassEvent -> {
|
|
||||||
if (currentPlayer == event.player) {
|
|
||||||
YourNewCardNotification(
|
|
||||||
card = event.takenCard,
|
|
||||||
).send()
|
|
||||||
} else {
|
|
||||||
PlayerHavePassNotification(
|
|
||||||
player = event.player,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
|
|
||||||
sendNextTurnNotif()
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerReadyEvent -> {
|
|
||||||
if (currentPlayer != event.player) {
|
|
||||||
PlayerWasReadyNotification(
|
|
||||||
player = event.player,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is PlayerWinEvent -> {
|
|
||||||
PlayerWinNotification(
|
|
||||||
player = event.player,
|
|
||||||
).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-75
@@ -1,75 +0,0 @@
|
|||||||
package eventDemo.business.event.projection.projectionListener
|
|
||||||
|
|
||||||
import eventDemo.business.entity.GameId
|
|
||||||
import eventDemo.business.event.GameEventHandler
|
|
||||||
import eventDemo.business.event.event.GameStartedEvent
|
|
||||||
import eventDemo.business.event.event.PlayerWinEvent
|
|
||||||
import eventDemo.business.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.business.event.projection.GameState
|
|
||||||
import eventDemo.libs.event.projection.Projection
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
|
||||||
import java.util.concurrent.ConcurrentSkipListSet
|
|
||||||
|
|
||||||
class ReactionListener(
|
|
||||||
private val eventHandler: GameEventHandler,
|
|
||||||
) {
|
|
||||||
companion object Config {
|
|
||||||
val registeredListeners = ConcurrentSkipListSet<GameProjectionBus>()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger { }
|
|
||||||
|
|
||||||
fun subscribeToBus(projectionBus: GameProjectionBus) {
|
|
||||||
if (registeredListeners.add(projectionBus)) {
|
|
||||||
projectionBus.subscribe { projection: Projection<GameId> ->
|
|
||||||
if (projection !is GameState) return@subscribe
|
|
||||||
withLoggingContext("projection" to projection.toString()) {
|
|
||||||
sendStartGameEvent(projection)
|
|
||||||
sendWinnerEvent(projection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"${this::class.simpleName} is already init for this bus".let {
|
|
||||||
logger.error { it }
|
|
||||||
error(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendStartGameEvent(state: GameState) {
|
|
||||||
if (state.isReady && !state.isStarted) {
|
|
||||||
val reactionEvent =
|
|
||||||
eventHandler.handle(state.aggregateId) {
|
|
||||||
GameStartedEvent.new(
|
|
||||||
id = state.aggregateId,
|
|
||||||
players = state.players,
|
|
||||||
version = it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
logger.atInfo {
|
|
||||||
message = "Reaction event was Send"
|
|
||||||
payload = mapOf("reactionEvent" to reactionEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendWinnerEvent(state: GameState) {
|
|
||||||
val winner = state.playerHasNoCardLeft().firstOrNull()
|
|
||||||
if (winner != null) {
|
|
||||||
val reactionEvent =
|
|
||||||
eventHandler.handle(state.aggregateId) {
|
|
||||||
PlayerWinEvent(
|
|
||||||
aggregateId = state.aggregateId,
|
|
||||||
player = winner,
|
|
||||||
version = it,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.atInfo {
|
|
||||||
message = "Reaction event was Send"
|
|
||||||
payload = mapOf("reactionEvent" to reactionEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package eventDemo.business.notification
|
|
||||||
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import eventDemo.libs.command.Command
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class CommandErrorNotification(
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val id: UUID = UUID.randomUUID(),
|
|
||||||
val message: String,
|
|
||||||
val command: Command,
|
|
||||||
) : Notification,
|
|
||||||
CommandNotification
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package eventDemo.business.notification
|
|
||||||
|
|
||||||
sealed interface CommandNotification : Notification
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package eventDemo.business.notification
|
|
||||||
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import eventDemo.libs.command.CommandId
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class CommandSuccessNotification(
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val id: UUID = UUID.randomUUID(),
|
|
||||||
val commandId: CommandId,
|
|
||||||
) : Notification,
|
|
||||||
CommandNotification
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
package eventDemo.business.notification
|
|
||||||
|
|
||||||
import eventDemo.business.entity.Card
|
|
||||||
import eventDemo.business.entity.Player
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PlayerWasChoseTheCardColorNotification(
|
|
||||||
@Serializable(with = UUIDSerializer::class)
|
|
||||||
override val id: UUID = UUID.randomUUID(),
|
|
||||||
val player: Player,
|
|
||||||
val color: Card.Color,
|
|
||||||
) : Notification
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package eventDemo.configuration
|
||||||
|
|
||||||
|
import io.ktor.server.config.ApplicationConfig
|
||||||
|
|
||||||
|
data class Configuration(
|
||||||
|
val jwtSecret: String,
|
||||||
|
val postgresql: Postgresql,
|
||||||
|
val rabbitmq: RabbitMQ,
|
||||||
|
) {
|
||||||
|
data class Postgresql(
|
||||||
|
val url: String,
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RabbitMQ(
|
||||||
|
val url: String,
|
||||||
|
val port: Int,
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ApplicationConfig.configuration
|
||||||
|
get() =
|
||||||
|
Configuration(
|
||||||
|
jwtSecret = getProperty("jwt.secret"),
|
||||||
|
postgresql =
|
||||||
|
Configuration.Postgresql(
|
||||||
|
url = getProperty("postgresql.url"),
|
||||||
|
username = getProperty("postgresql.username"),
|
||||||
|
password = getProperty("postgresql.password"),
|
||||||
|
),
|
||||||
|
rabbitmq =
|
||||||
|
Configuration.RabbitMQ(
|
||||||
|
url = getProperty("rabbitmq.url"),
|
||||||
|
port = getProperty("rabbitmq.port").toInt(),
|
||||||
|
username = getProperty("rabbitmq.username"),
|
||||||
|
password = getProperty("rabbitmq.password"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ApplicationConfig.getProperty(path: String): String =
|
||||||
|
propertyOrNull(path)?.getString() ?: error("You must set the $path")
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package eventDemo.configuration
|
|
||||||
|
|
||||||
import eventDemo.configuration.business.configureGameListener
|
|
||||||
import eventDemo.configuration.ktor.configureHttpRouting
|
|
||||||
import eventDemo.configuration.ktor.configureKoin
|
|
||||||
import eventDemo.configuration.ktor.configureSecurity
|
|
||||||
import eventDemo.configuration.ktor.configureSerialization
|
|
||||||
import eventDemo.configuration.ktor.configureWebSockets
|
|
||||||
import eventDemo.configuration.route.declareHttpGameRoute
|
|
||||||
import eventDemo.configuration.route.declareWebSocketsGameRoute
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import org.koin.ktor.ext.get
|
|
||||||
import org.koin.ktor.ext.getKoin
|
|
||||||
|
|
||||||
fun Application.configure() {
|
|
||||||
configureKoin()
|
|
||||||
|
|
||||||
configureSecurity()
|
|
||||||
|
|
||||||
configureSerialization()
|
|
||||||
|
|
||||||
configureWebSockets()
|
|
||||||
declareWebSocketsGameRoute(get(), get())
|
|
||||||
|
|
||||||
configureHttpRouting()
|
|
||||||
declareHttpGameRoute()
|
|
||||||
|
|
||||||
getKoin().configureGameListener()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package eventDemo.configuration
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.infrastructure.configure.configureAuthDi
|
||||||
|
import eventDemo.contexts.game.infrastructure.configuration.injections.application.configureGameDIApplication
|
||||||
|
import eventDemo.contexts.game.infrastructure.configuration.injections.infrastructure.configureGameDIInfrastructure
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
fun appKoinModule(config: Configuration) =
|
||||||
|
module {
|
||||||
|
configureDIDataSource(config)
|
||||||
|
configureAuthDi()
|
||||||
|
configureGameDIInfrastructure()
|
||||||
|
configureGameDIApplication()
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package eventDemo.configuration
|
||||||
|
|
||||||
|
import com.rabbitmq.client.ConnectionFactory
|
||||||
|
import com.zaxxer.hikari.HikariConfig
|
||||||
|
import com.zaxxer.hikari.HikariDataSource
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.scope.Scope
|
||||||
|
import org.koin.core.scope.ScopeCallback
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
import javax.sql.DataSource
|
||||||
|
|
||||||
|
fun Module.configureDIDataSource(config: Configuration) {
|
||||||
|
// PostgreSQL (for EventStore)
|
||||||
|
single {
|
||||||
|
hikariDataSource(config)
|
||||||
|
.apply {
|
||||||
|
registerCallback(
|
||||||
|
object : ScopeCallback {
|
||||||
|
override fun onScopeClose(scope: Scope) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} bind DataSource::class
|
||||||
|
|
||||||
|
// RabbitMQ (for EventBus)
|
||||||
|
factory {
|
||||||
|
ConnectionFactory().apply {
|
||||||
|
host = config.rabbitmq.url
|
||||||
|
port = config.rabbitmq.port
|
||||||
|
username = config.rabbitmq.username
|
||||||
|
password = config.rabbitmq.password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hikariDataSource(config: Configuration): HikariDataSource =
|
||||||
|
HikariConfig()
|
||||||
|
.apply {
|
||||||
|
jdbcUrl = config.postgresql.url
|
||||||
|
username = config.postgresql.username
|
||||||
|
password = config.postgresql.password
|
||||||
|
maximumPoolSize = 10
|
||||||
|
minimumIdle = 10
|
||||||
|
}.let {
|
||||||
|
HikariDataSource(it)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package eventDemo.configuration
|
||||||
|
|
||||||
|
import io.ktor.server.application.Application
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import org.koin.ktor.plugin.Koin
|
||||||
|
import org.koin.logger.slf4jLogger
|
||||||
|
|
||||||
|
fun Application.configureKoin() {
|
||||||
|
install(Koin) {
|
||||||
|
slf4jLogger()
|
||||||
|
|
||||||
|
modules(
|
||||||
|
appKoinModule(environment.config.configuration),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package eventDemo.configuration
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.infrastructure.configure.configureAuth
|
||||||
|
import eventDemo.contexts.game.infrastructure.configuration.ktor.configureUno
|
||||||
|
import io.ktor.server.application.Application
|
||||||
|
|
||||||
|
fun Application.configure() {
|
||||||
|
configureKoin()
|
||||||
|
configureAuth()
|
||||||
|
configureUno()
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package eventDemo.configuration.business
|
|
||||||
|
|
||||||
import eventDemo.adapter.infrastructureLayer.event.projection.GameListRepositoryInRedis
|
|
||||||
import eventDemo.adapter.infrastructureLayer.event.projection.GameStateRepositoryInRedis
|
|
||||||
import eventDemo.business.command.GameCommandHandler
|
|
||||||
import eventDemo.business.event.projection.projectionListener.ReactionListener
|
|
||||||
import org.koin.core.Koin
|
|
||||||
|
|
||||||
fun Koin.configureGameListener() {
|
|
||||||
get<GameCommandHandler>()
|
|
||||||
.subscribeToBus(get())
|
|
||||||
|
|
||||||
get<GameStateRepositoryInRedis>()
|
|
||||||
.subscribeToBus(get(), get())
|
|
||||||
|
|
||||||
get<GameListRepositoryInRedis>()
|
|
||||||
.subscribeToBus(get(), get())
|
|
||||||
|
|
||||||
get<ReactionListener>()
|
|
||||||
.subscribeToBus(get())
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package eventDemo.configuration.injection
|
|
||||||
|
|
||||||
import org.koin.dsl.module
|
|
||||||
|
|
||||||
fun appKoinModule(config: Configuration) =
|
|
||||||
module {
|
|
||||||
configureDIBusiness()
|
|
||||||
configureDIInfrastructure(config)
|
|
||||||
configureDILibs()
|
|
||||||
configureDICommandActions()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Configuration(
|
|
||||||
val redisUrl: String,
|
|
||||||
val postgresql: Postgresql,
|
|
||||||
val rabbitmq: RabbitMQ,
|
|
||||||
) {
|
|
||||||
data class Postgresql(
|
|
||||||
val url: String,
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RabbitMQ(
|
|
||||||
val url: String,
|
|
||||||
val port: Int,
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package eventDemo.configuration.injection
|
|
||||||
|
|
||||||
import eventDemo.business.command.action.ICantPlay
|
|
||||||
import eventDemo.business.command.action.IWantToJoinTheGame
|
|
||||||
import eventDemo.business.command.action.IWantToPlayCard
|
|
||||||
import eventDemo.business.command.action.IamReadyToPlay
|
|
||||||
import org.koin.core.module.Module
|
|
||||||
import org.koin.core.module.dsl.singleOf
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure all actions
|
|
||||||
*/
|
|
||||||
fun Module.configureDICommandActions() {
|
|
||||||
singleOf(::IWantToPlayCard)
|
|
||||||
singleOf(::IamReadyToPlay)
|
|
||||||
singleOf(::IWantToJoinTheGame)
|
|
||||||
singleOf(::ICantPlay)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package eventDemo.configuration.injection
|
|
||||||
|
|
||||||
import eventDemo.business.command.GameCommandActionRunner
|
|
||||||
import eventDemo.business.command.GameCommandHandler
|
|
||||||
import eventDemo.business.event.GameEventHandler
|
|
||||||
import eventDemo.business.event.projection.projectionListener.PlayerNotificationListener
|
|
||||||
import eventDemo.business.event.projection.projectionListener.ReactionListener
|
|
||||||
import org.koin.core.module.Module
|
|
||||||
import org.koin.core.module.dsl.singleOf
|
|
||||||
|
|
||||||
fun Module.configureDIBusiness() {
|
|
||||||
single {
|
|
||||||
GameCommandHandler(get(), get(), get(), get())
|
|
||||||
}
|
|
||||||
singleOf(::GameEventHandler)
|
|
||||||
singleOf(::GameCommandActionRunner)
|
|
||||||
singleOf(::PlayerNotificationListener)
|
|
||||||
singleOf(::ReactionListener)
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package eventDemo.configuration.injection
|
|
||||||
|
|
||||||
import com.rabbitmq.client.ConnectionFactory
|
|
||||||
import com.zaxxer.hikari.HikariConfig
|
|
||||||
import com.zaxxer.hikari.HikariDataSource
|
|
||||||
import eventDemo.adapter.infrastructureLayer.event.GameEventBusInRabbinMQ
|
|
||||||
import eventDemo.adapter.infrastructureLayer.event.GameEventStoreInPostgresql
|
|
||||||
import eventDemo.adapter.infrastructureLayer.event.projection.GameListRepositoryInRedis
|
|
||||||
import eventDemo.adapter.infrastructureLayer.event.projection.GameProjectionBusInRabbitMQ
|
|
||||||
import eventDemo.adapter.infrastructureLayer.event.projection.GameStateRepositoryInRedis
|
|
||||||
import eventDemo.business.event.GameEventBus
|
|
||||||
import eventDemo.business.event.GameEventStore
|
|
||||||
import eventDemo.business.event.projection.GameListRepository
|
|
||||||
import eventDemo.business.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.business.event.projection.GameStateRepository
|
|
||||||
import org.koin.core.module.Module
|
|
||||||
import org.koin.core.module.dsl.singleOf
|
|
||||||
import org.koin.core.scope.Scope
|
|
||||||
import org.koin.core.scope.ScopeCallback
|
|
||||||
import org.koin.dsl.bind
|
|
||||||
import redis.clients.jedis.JedisPooled
|
|
||||||
import redis.clients.jedis.UnifiedJedis
|
|
||||||
import javax.sql.DataSource
|
|
||||||
|
|
||||||
fun Module.configureDIInfrastructure(config: Configuration) {
|
|
||||||
// Postgresql config
|
|
||||||
single {
|
|
||||||
JedisPooled(config.redisUrl)
|
|
||||||
} bind UnifiedJedis::class
|
|
||||||
|
|
||||||
single {
|
|
||||||
HikariConfig()
|
|
||||||
.apply {
|
|
||||||
jdbcUrl = config.postgresql.url
|
|
||||||
username = config.postgresql.username
|
|
||||||
password = config.postgresql.password
|
|
||||||
maximumPoolSize = 10
|
|
||||||
minimumIdle = 10
|
|
||||||
}.let {
|
|
||||||
HikariDataSource(it)
|
|
||||||
}.also { datasource ->
|
|
||||||
registerCallback(
|
|
||||||
object : ScopeCallback {
|
|
||||||
override fun onScopeClose(scope: Scope) {
|
|
||||||
datasource.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} bind DataSource::class
|
|
||||||
|
|
||||||
// RabbitMQ config
|
|
||||||
factory {
|
|
||||||
ConnectionFactory().apply {
|
|
||||||
host = config.rabbitmq.url
|
|
||||||
port = config.rabbitmq.port
|
|
||||||
username = config.rabbitmq.username
|
|
||||||
password = config.rabbitmq.password
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
singleOf(::GameEventBusInRabbinMQ) bind GameEventBus::class
|
|
||||||
singleOf(::GameEventStoreInPostgresql) bind GameEventStore::class
|
|
||||||
singleOf(::GameProjectionBusInRabbitMQ) bind GameProjectionBus::class
|
|
||||||
|
|
||||||
single {
|
|
||||||
GameStateRepositoryInRedis(get())
|
|
||||||
} bind GameStateRepository::class
|
|
||||||
|
|
||||||
single {
|
|
||||||
GameListRepositoryInRedis(get())
|
|
||||||
} bind GameListRepository::class
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package eventDemo.configuration.injection
|
|
||||||
|
|
||||||
import eventDemo.libs.event.VersionBuilder
|
|
||||||
import eventDemo.libs.event.VersionBuilderLocal
|
|
||||||
import org.koin.core.module.Module
|
|
||||||
import org.koin.core.module.dsl.singleOf
|
|
||||||
import org.koin.dsl.bind
|
|
||||||
|
|
||||||
fun Module.configureDILibs() {
|
|
||||||
singleOf(::VersionBuilderLocal) bind VersionBuilder::class
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package eventDemo.configuration.ktor
|
|
||||||
|
|
||||||
import eventDemo.configuration.injection.Configuration
|
|
||||||
import eventDemo.configuration.injection.appKoinModule
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import io.ktor.server.application.install
|
|
||||||
import io.ktor.server.config.ApplicationConfig
|
|
||||||
import org.koin.ktor.plugin.Koin
|
|
||||||
import org.koin.logger.slf4jLogger
|
|
||||||
|
|
||||||
fun Application.configureKoin() {
|
|
||||||
install(Koin) {
|
|
||||||
slf4jLogger()
|
|
||||||
|
|
||||||
modules(
|
|
||||||
appKoinModule(
|
|
||||||
environment.config.configuration(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ApplicationConfig.configuration() =
|
|
||||||
Configuration(
|
|
||||||
redisUrl = getProperty("redis.url"),
|
|
||||||
postgresql =
|
|
||||||
Configuration.Postgresql(
|
|
||||||
url = getProperty("postgresql.url"),
|
|
||||||
username = getProperty("postgresql.username"),
|
|
||||||
password = getProperty("postgresql.password"),
|
|
||||||
),
|
|
||||||
rabbitmq =
|
|
||||||
Configuration.RabbitMQ(
|
|
||||||
url = getProperty("rabbitmq.url"),
|
|
||||||
port = getProperty("rabbitmq.port").toInt(),
|
|
||||||
username = getProperty("rabbitmq.username"),
|
|
||||||
password = getProperty("rabbitmq.password"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun ApplicationConfig.getProperty(path: String): String =
|
|
||||||
propertyOrNull(path)?.getString() ?: error("You must set the $path")
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package eventDemo.configuration.route
|
|
||||||
|
|
||||||
import eventDemo.adapter.interfaceLayer.query.readGamesList
|
|
||||||
import eventDemo.adapter.interfaceLayer.query.readTheGameState
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import org.koin.ktor.ext.get
|
|
||||||
|
|
||||||
fun Application.declareHttpGameRoute() {
|
|
||||||
routing {
|
|
||||||
readTheGameState(this@declareHttpGameRoute.get())
|
|
||||||
readGamesList(this@declareHttpGameRoute.get())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package eventDemo.configuration.route
|
|
||||||
|
|
||||||
import eventDemo.adapter.interfaceLayer.query.gameWebSocket
|
|
||||||
import eventDemo.business.command.GameCommandHandler
|
|
||||||
import eventDemo.business.event.projection.projectionListener.PlayerNotificationListener
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
|
||||||
fun Application.declareWebSocketsGameRoute(
|
|
||||||
playerNotificationListener: PlayerNotificationListener,
|
|
||||||
commandHandler: GameCommandHandler,
|
|
||||||
) {
|
|
||||||
routing {
|
|
||||||
gameWebSocket(playerNotificationListener, commandHandler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package eventDemo.contexts.auth.application.eventStores
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.application.ports.UserEventStore
|
||||||
|
import eventDemo.contexts.auth.domain.User
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
|
||||||
|
class UserEventStoreRepository(
|
||||||
|
val eventStore: UserEventStore,
|
||||||
|
) : UserRepository {
|
||||||
|
override fun get(id: UserId): User? {
|
||||||
|
val events =
|
||||||
|
eventStore
|
||||||
|
.getStream(id)
|
||||||
|
.readAll()
|
||||||
|
if (events.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return events.let { User.loadFromHistory(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(user: User) {
|
||||||
|
eventStore.append(user.recordedEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user