Compare commits
18
Commits
v0.1
..
505cfe38f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
505cfe38f0
|
||
|
|
85bcdcadb8
|
||
|
|
e67c475c38
|
||
|
|
07983dc5b0
|
||
|
|
5e9587b93f
|
||
|
|
fd62f2574d
|
||
|
|
828bd0639e
|
||
|
|
da13b0d2b8
|
||
|
|
d632dc0f6b
|
||
|
|
b60e4aa457
|
||
|
|
e262f35b27
|
||
|
|
77e4cab8ad
|
||
|
|
7291419c9f
|
||
|
|
b2b8fcf92f
|
||
|
|
4e4b307275
|
||
|
|
e87a36caa5
|
||
|
|
e2d7942c7e
|
||
|
|
b313b39cf4
|
No files matched your search
@@ -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
|
||||||
|
|||||||
+32
-80
@@ -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.21"
|
kotlin("jvm") version "2.1.21"
|
||||||
id("io.ktor.plugin") version "3.5.1"
|
id("io.ktor.plugin") version "3.5.1"
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.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
|
||||||
|
// SIGQUIT-triggered AttachListener handshake never completes there. Loading the byte-buddy
|
||||||
|
// agent jar statically via -javaagent avoids the attach handshake entirely: MockK detects the
|
||||||
|
// pre-installed Instrumentation instance and skips dynamic attach.
|
||||||
|
doFirst {
|
||||||
|
val agentJar =
|
||||||
|
classpath.files.firstOrNull { it.name.startsWith("byte-buddy-agent") }
|
||||||
|
?: error("byte-buddy-agent jar not found on test classpath")
|
||||||
|
jvmArgs("-javaagent:$agentJar")
|
||||||
}
|
}
|
||||||
|
|
||||||
dockerCompose {
|
|
||||||
val composeFile =
|
|
||||||
if (project.hasProperty("ci")) {
|
|
||||||
// Use docker-compose-ci.yaml for the CI
|
|
||||||
"docker/docker-compose-ci.yaml"
|
|
||||||
} else {
|
|
||||||
// Use docker-compose-test.yaml for local tests
|
|
||||||
"docker/docker-compose-test.yaml"
|
|
||||||
}
|
|
||||||
useComposeFiles.set(listOf(composeFile))
|
|
||||||
setProjectName("event-demo-test")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.test {
|
|
||||||
dependsOn("composeUp")
|
|
||||||
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
|
|
||||||
dockerCompose.setProjectName("event-demo-test")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.named("run") {
|
|
||||||
dependsOn("composeUp")
|
|
||||||
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
|
|
||||||
dockerCompose.setProjectName("event-demo-dev")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register<Copy>("copyEnv") {
|
|
||||||
group = "docker"
|
|
||||||
description = "copy the default dotenv file"
|
|
||||||
from("docker")
|
|
||||||
into("docker")
|
|
||||||
rename {
|
|
||||||
it.removeSuffix(".template")
|
|
||||||
}
|
|
||||||
include(".env.template")
|
|
||||||
eachFile {
|
|
||||||
if (File("docker/$name").exists()) {
|
|
||||||
exclude()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" height="668px" preserveAspectRatio="none" style="width:1329px;height:668px;" version="1.1" viewBox="0 0 1329 668" width="1329px" zoomAndPan="magnify"><defs/><g><!--class GameList--><g id="elem_GameList"><rect codeLine="5" fill="#F1F1F1" height="79.2031" id="GameList" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="110.8203" x="50.5" y="7"/><ellipse cx="72.7501" cy="29.291" fill="#ADD1B2" rx="11" ry="11" style="stroke:#181818;stroke-width:1.0;"/><path d="M75.5157,25.166 Q75.672,24.9473 75.8595,24.8379 Q76.047,24.7285 76.2657,24.7285 Q76.6407,24.7285 76.8751,24.9941 Q77.1095,25.2441 77.1095,25.8535 L77.1095,27.3066 Q77.1095,27.916 76.8751,28.1816 Q76.6407,28.4473 76.2657,28.4473 Q75.922,28.4473 75.7188,28.2441 Q75.5157,28.0566 75.4063,27.541 Q75.3595,27.1816 75.172,26.9941 Q74.8438,26.6191 74.2345,26.4004 Q73.6251,26.1816 73.0001,26.1816 Q72.2345,26.1816 71.5938,26.5098 Q70.9688,26.8379 70.4688,27.5879 Q69.9845,28.3379 69.9845,29.3691 L69.9845,30.4629 Q69.9845,31.6973 70.8751,32.5254 Q71.7657,33.3379 73.3595,33.3379 Q74.297,33.3379 74.9532,33.0879 Q75.3438,32.9316 75.7657,32.4941 Q76.0313,32.2285 76.172,32.1504 Q76.3282,32.0723 76.5313,32.0723 Q76.8595,32.0723 77.1095,32.3379 Q77.3751,32.5879 77.3751,32.9316 Q77.3751,33.2754 77.0313,33.6816 Q76.5313,34.2598 75.7345,34.5879 Q74.6563,35.041 73.3595,35.041 Q71.8438,35.041 70.6407,34.416 Q69.6563,33.916 68.9688,32.8535 Q68.2813,31.7754 68.2813,30.4941 L68.2813,29.3379 Q68.2813,28.0098 68.8907,26.8691 Q69.5157,25.7129 70.6095,25.1035 Q71.7032,24.4785 72.9376,24.4785 Q73.672,24.4785 74.3126,24.6504 Q74.9688,24.8066 75.5157,25.166 Z " fill="#000000"/><text fill="#000000" font-family="sans-serif" font-size="12" font-style="italic" lengthAdjust="spacing" textLength="66.709" x="86.3612" y="24.9492">«Projection»</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="59.9102" x="89.7606" y="43.0684">GameList</text><line style="stroke:#181818;stroke-width:0.5;" x1="51.5" x2="160.3203" y1="51.582" y2="51.582"/><ellipse cx="61.5" cy="66.3926" fill="none" rx="3" ry="3" style="stroke:#038048;stroke-width:1.0;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="84.8203" x="70.5" y="70.6895">status: Status</text><line style="stroke:#181818;stroke-width:0.5;" x1="51.5" x2="160.3203" y1="78.2031" y2="78.2031"/></g><!--class GameState--><g id="elem_GameState"><rect codeLine="8" fill="#F1F1F1" height="228.1719" id="GameState" rx="2.5" ry="2.5" style="stroke:#181818;stroke-width:0.5;" width="197.9512" x="7" y="121.5"/><ellipse cx="66.3188" cy="143.791" fill="#ADD1B2" rx="11" ry="11" style="stroke:#181818;stroke-width:1.0;"/><path d="M69.0845,139.666 Q69.2407,139.4473 69.4282,139.3379 Q69.6157,139.2285 69.8345,139.2285 Q70.2095,139.2285 70.4438,139.4941 Q70.6782,139.7441 70.6782,140.3535 L70.6782,141.8066 Q70.6782,142.416 70.4438,142.6816 Q70.2095,142.9473 69.8345,142.9473 Q69.4907,142.9473 69.2876,142.7441 Q69.0845,142.5566 68.9751,142.041 Q68.9282,141.6816 68.7407,141.4941 Q68.4126,141.1191 67.8032,140.9004 Q67.1938,140.6816 66.5688,140.6816 Q65.8032,140.6816 65.1626,141.0098 Q64.5376,141.3379 64.0376,142.0879 Q63.5532,142.8379 63.5532,143.8691 L63.5532,144.9629 Q63.5532,146.1973 64.4438,147.0254 Q65.3345,147.8379 66.9282,147.8379 Q67.8657,147.8379 68.522,147.5879 Q68.9126,147.4316 69.3345,146.9941 Q69.6001,146.7285 69.7407,146.6504 Q69.897,146.5723 70.1001,146.5723 Q70.4282,146.5723 70.6782,146.8379 Q70.9438,147.0879 70.9438,147.4316 Q70.9438,147.7754 70.6001,148.1816 Q70.1001,148.7598 69.3032,149.0879 Q68.2251,149.541 66.9282,149.541 Q65.4126,149.541 64.2095,148.916 Q63.2251,148.416 62.5376,147.3535 Q61.8501,146.2754 61.8501,144.9941 L61.8501,143.8379 Q61.8501,142.5098 62.4595,141.3691 Q63.0845,140.2129 64.1782,139.6035 Q65.272,138.9785 66.5063,138.9785 Q67.2407,138.9785 67.8813,139.1504 Q68.5376,139.3066 69.0845,139.666 Z " fill="#000000"/><text fill="#000000" font-family="sans-serif" font-size="12" font-style="italic" lengthAdjust="spacing" textLength="66.709" x="88.8711" y="139.4492">«Projection»</text><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="70.8135" x="86.8188" y="157.5684">GameState</text><line style="stroke:#181818;stroke-width:0.5;" x1="8" x2="203.9512" y1="166.082" y2="166.082"/><ellipse cx="18" cy="180.8926" fill="none" rx="3" ry="3" style="stroke:#038048;stroke-width:1.0;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="130.7305" x="27" y="185.1895">players: List<Player></text><ellipse cx="18" cy="199.5137" fill="none" rx="3" ry="3" style="stroke:#038048;stroke-width:1.0;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="159.5029" x="27" y="203.8105">currentPlayerTurn: PlayerLine truncated
|
|
||||||
|
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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" contentStyleType="text/css" height="1460px" preserveAspectRatio="none" style="width:1185px;height:1460px;" version="1.1" viewBox="0 0 1185 1460" width="1185px" zoomAndPan="magnify"><defs/><g><!--cluster Legend--><g id="cluster_Legend"><path d="M897,6.0137 L947.7793,6.0137 A3.75,3.75 0 0 1 950.2793,8.5137 L957.2793,30.6348 L1176,30.6348 A2.5,2.5 0 0 1 1178.5,33.1348 L1178.5,207.5137 A2.5,2.5 0 0 1 1176,210.0137 L897,210.0137 A2.5,2.5 0 0 1 894.5,207.5137 L894.5,8.5137 A2.5,2.5 0 0 1 897,6.0137 " fill="none" style="stroke:#000000;stroke-width:1.5;"/><line style="stroke:#000000;stroke-width:1.5;" x1="894.5" x2="957.2793" y1="30.6348" y2="30.6348"/><text fill="#000000" font-family="sans-serif" font-size="14" font-weight="bold" lengthAdjust="spacing" textLength="49.7793" x="898.5" y="23.1211">Legend</text></g><g id="elem_Queries"><ellipse cx="957.5879" cy="59.315" fill="#7693C4" rx="46.5879" ry="16.3013" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="49.0205" x="930.0776" y="64.1187">Queries</text></g><g id="elem_Projections"><ellipse cx="1100.6745" cy="59.4601" fill="#AB64C9" rx="61.6745" ry="16.4464" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="69.2549" x="1063.0471" y="64.2638">Projections</text></g><g id="elem_Events"><ellipse cx="957.4528" cy="178.2499" fill="#5FAD56" rx="41.9528" ry="16.2362" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="42.7998" x="933.2413" y="183.0022">Events</text></g><!--entity User--><g id="elem_User"><ellipse cx="605.2793" cy="28.5137" fill="#F1F1F1" rx="8" ry="8" style="stroke:#181818;stroke-width:0.5;"/><path d="M605.2793,36.5137 L605.2793,63.5137 M592.2793,44.5137 L618.2793,44.5137 M605.2793,63.5137 L592.2793,78.5137 M605.2793,63.5137 L618.2793,78.5137 " fill="none" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="29.5586" x="590.5" y="95.1211">User</text></g><!--entity Query--><g id="elem_Query"><ellipse cx="726.562" cy="50.0137" fill="#7693C4" rx="12" ry="12" style="stroke:#181818;stroke-width:0.5;"/><line style="stroke:#181818;stroke-width:0.5;" x1="714.562" x2="738.562" y1="64.0137" y2="64.0137"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="38.124" x="707.5" y="81.1211">Query</text></g><!--entity Command--><g id="elem_Command"><ellipse cx="471.2896" cy="50.0137" fill="#5FAD56" rx="12" ry="12" style="stroke:#181818;stroke-width:0.5;"/><line style="stroke:#181818;stroke-width:0.5;" x1="459.2896" x2="483.2896" y1="64.0137" y2="64.0137"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="64.5791" x="439" y="81.1211">Command</text></g><!--entity Event--><g id="elem_Event"><ellipse cx="243.3999" cy="530.5137" fill="#5FAD56" rx="12" ry="12" style="stroke:#181818;stroke-width:0.5;"/><line style="stroke:#181818;stroke-width:0.5;" x1="231.3999" x2="255.3999" y1="544.5137" y2="544.5137"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="35.7998" x="225.5" y="561.6211">Event</text></g><!--entity Projection--><g id="elem_Projection"><ellipse cx="750.6274" cy="1410.0137" fill="#AB64C9" rx="12" ry="12" style="stroke:#181818;stroke-width:0.5;"/><line style="stroke:#181818;stroke-width:0.5;" x1="738.6274" x2="762.6274" y1="1424.0137" y2="1424.0137"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="62.2549" x="719.5" y="1441.1211">Projection</text></g><!--entity Postgresql--><g id="elem_Postgresql"><path d="M83.5,905.0137 C83.5,895.0137 126.5723,895.0137 126.5723,895.0137 C126.5723,895.0137 169.6445,895.0137 169.6445,905.0137 L169.6445,932.6348 C169.6445,942.6348 126.5723,942.6348 126.5723,942.6348 C126.5723,942.6348 83.5,942.6348 83.5,932.6348 L83.5,905.0137 " fill="#F1F1F1" style="stroke:#181818;stroke-width:0.5;"/><path d="M83.5,905.0137 C83.5,915.0137 126.5723,915.0137 126.5723,915.0137 C126.5723,915.0137 169.6445,915.0137 169.6445,905.0137 " fill="none" style="stroke:#181818;stroke-width:0.5;"/><text fill="#000000" font-family="sans-serif" font-size="14" lengthAdjust="spacing" textLength="66.1445" x="93.5" y="934.1211">Postgresql</text></g><!--entity Redis--><g id="elem_Redis"><path d="M462.5,1405.5137 C462.5,1395.5137 490.3965,1395.5137 490.3965,1395.5137 C490.3965,1395.5137 518.293,1395.5137 518.293,1405.5137 L518.293,1433.1348 C518.293,1443.1348 490.3965,1443.1348 490.3965,1443.1348 C490.3965,1443.1348 462.5,1443.1348 462.5,1433.1348 L462.5,1405.5137 " fill="#F1F1F1" style="stroke:#181818;stroke-width:0.5;"/><path d="M462.5,1405.5137 C462.5,1415.5137 490.3965,Line truncated
|
|
||||||
|
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-v8
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
- redisinsight_data:/redisinsight
|
|
||||||
|
|
||||||
flyway:
|
flyway:
|
||||||
image: flyway/flyway
|
image: flyway/flyway
|
||||||
command: migrate
|
command: migrate
|
||||||
@@ -22,10 +14,7 @@ services:
|
|||||||
image: postgres:18.4
|
image: postgres:18.4
|
||||||
command: postgres -c 'max_connections=500'
|
command: postgres -c 'max_connections=500'
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
|
|
||||||
POSTGRES_USER: event-demo
|
POSTGRES_USER: event-demo
|
||||||
secrets:
|
|
||||||
- postgresql_password
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"]
|
test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"]
|
||||||
interval: 1s
|
interval: 1s
|
||||||
@@ -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"
|
||||||
|
|||||||
+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.infrastructure.event
|
|
||||||
|
|
||||||
import eventDemo.domain.entity.GameId
|
|
||||||
import eventDemo.domain.event.GameEventStore
|
|
||||||
import eventDemo.domain.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()
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package eventDemo.adapter.infrastructure.event
|
|
||||||
|
|
||||||
import eventDemo.domain.entity.GameId
|
|
||||||
import eventDemo.domain.event.GameEventStore
|
|
||||||
import eventDemo.domain.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.infrastructure.event.projection
|
|
||||||
|
|
||||||
import eventDemo.domain.entity.GameId
|
|
||||||
import eventDemo.domain.event.GameEventBus
|
|
||||||
import eventDemo.domain.event.projection.GameList
|
|
||||||
import eventDemo.domain.event.projection.GameListRepository
|
|
||||||
import eventDemo.domain.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.domain.event.projection.GameState
|
|
||||||
import eventDemo.domain.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.infrastructure.event.projection
|
|
||||||
|
|
||||||
import eventDemo.domain.entity.GameId
|
|
||||||
import eventDemo.domain.event.GameEventBus
|
|
||||||
import eventDemo.domain.event.projection.GameList
|
|
||||||
import eventDemo.domain.event.projection.GameListRepository
|
|
||||||
import eventDemo.domain.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.domain.event.projection.GameState
|
|
||||||
import eventDemo.domain.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.infrastructure.event.projection
|
|
||||||
|
|
||||||
import eventDemo.domain.entity.GameId
|
|
||||||
import eventDemo.domain.event.GameEventBus
|
|
||||||
import eventDemo.domain.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.domain.event.projection.GameState
|
|
||||||
import eventDemo.domain.event.projection.GameStateRepository
|
|
||||||
import eventDemo.domain.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.infrastructure.event.projection
|
|
||||||
|
|
||||||
import eventDemo.domain.entity.GameId
|
|
||||||
import eventDemo.domain.event.GameEventBus
|
|
||||||
import eventDemo.domain.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.domain.event.projection.GameState
|
|
||||||
import eventDemo.domain.event.projection.GameStateRepository
|
|
||||||
import eventDemo.domain.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.presenter.query
|
|
||||||
|
|
||||||
import eventDemo.domain.command.GameCommandHandler
|
|
||||||
import eventDemo.domain.command.command.GameCommand
|
|
||||||
import eventDemo.domain.entity.GameId
|
|
||||||
import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener
|
|
||||||
import eventDemo.domain.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.presenter.query
|
|
||||||
|
|
||||||
import eventDemo.domain.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.presenter.query
|
|
||||||
|
|
||||||
import eventDemo.domain.entity.GameId
|
|
||||||
import eventDemo.domain.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.domain.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.domain
|
|
||||||
|
|
||||||
import eventDemo.adapter.infrastructure.event.projection.GameListRepositoryInRedis
|
|
||||||
import eventDemo.adapter.infrastructure.event.projection.GameStateRepositoryInRedis
|
|
||||||
import eventDemo.domain.command.GameCommandHandler
|
|
||||||
import eventDemo.domain.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.domain.command.action.ICantPlay
|
|
||||||
import eventDemo.domain.command.action.IWantToJoinTheGame
|
|
||||||
import eventDemo.domain.command.action.IWantToPlayCard
|
|
||||||
import eventDemo.domain.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.domain.command.GameCommandActionRunner
|
|
||||||
import eventDemo.domain.command.GameCommandHandler
|
|
||||||
import eventDemo.domain.event.GameEventHandler
|
|
||||||
import eventDemo.domain.event.projection.projectionListener.PlayerNotificationListener
|
|
||||||
import eventDemo.domain.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.infrastructure.event.GameEventBusInRabbinMQ
|
|
||||||
import eventDemo.adapter.infrastructure.event.GameEventStoreInPostgresql
|
|
||||||
import eventDemo.adapter.infrastructure.event.projection.GameListRepositoryInRedis
|
|
||||||
import eventDemo.adapter.infrastructure.event.projection.GameProjectionBusInRabbitMQ
|
|
||||||
import eventDemo.adapter.infrastructure.event.projection.GameStateRepositoryInRedis
|
|
||||||
import eventDemo.domain.event.GameEventBus
|
|
||||||
import eventDemo.domain.event.GameEventStore
|
|
||||||
import eventDemo.domain.event.projection.GameListRepository
|
|
||||||
import eventDemo.domain.event.projection.GameProjectionBus
|
|
||||||
import eventDemo.domain.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.presenter.query.readGamesList
|
|
||||||
import eventDemo.adapter.presenter.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.presenter.query.gameWebSocket
|
|
||||||
import eventDemo.domain.command.GameCommandHandler
|
|
||||||
import eventDemo.domain.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package eventDemo.contexts.auth.application.eventStores
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.domain.User
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
|
||||||
|
interface UserRepository {
|
||||||
|
fun get(id: UserId): User?
|
||||||
|
|
||||||
|
fun save(user: User)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package eventDemo.contexts.auth.application.ports
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.domain.events.UserEvent
|
||||||
|
import eventDemo.libs.eventSource.eventStore.EventStore
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
|
||||||
|
interface UserEventStore : EventStore<UserEvent, UserId>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package eventDemo.contexts.auth.application.ports
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection
|
||||||
|
|
||||||
|
interface UserProjectionRepository {
|
||||||
|
fun getByUsername(username: String): UserProjection?
|
||||||
|
|
||||||
|
fun save(user: UserProjection)
|
||||||
|
|
||||||
|
fun getUserIfPasswordIsValid(
|
||||||
|
username: String,
|
||||||
|
rawPassword: String,
|
||||||
|
): UserProjection?
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package eventDemo.contexts.auth.domain
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.domain.events.NewUserCreatedEvent
|
||||||
|
import eventDemo.contexts.auth.domain.events.UserEvent
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class User(
|
||||||
|
val id: UserId,
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
val version: Int,
|
||||||
|
val recordedEvents: Set<UserEvent>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun createNewUser(
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
): User =
|
||||||
|
apply(NewUserCreatedEvent(username, password, version = 1))
|
||||||
|
|
||||||
|
fun apply(event: NewUserCreatedEvent): User =
|
||||||
|
User(
|
||||||
|
id = event.aggregateId,
|
||||||
|
username = event.username,
|
||||||
|
password = event.password,
|
||||||
|
version = event.version,
|
||||||
|
recordedEvents = setOf(event),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun loadFromHistory(events: Set<UserEvent>): User? =
|
||||||
|
events.fold(null as User?) { acc, event ->
|
||||||
|
when (event) {
|
||||||
|
is NewUserCreatedEvent -> apply(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package eventDemo.contexts.auth.domain.events
|
||||||
|
|
||||||
|
import eventDemo.libs.eventSource.EventId
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class NewUserCreatedEvent(
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
override val version: Int,
|
||||||
|
override val createdAt: Instant = Clock.System.now(),
|
||||||
|
override val aggregateId: UserId = UserId(),
|
||||||
|
) : UserEvent {
|
||||||
|
override val eventId: EventId = EventId()
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package eventDemo.contexts.auth.domain.events
|
||||||
|
|
||||||
|
import eventDemo.libs.eventSource.Event
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface UserEvent : Event<UserId>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package eventDemo.contexts.auth.infrastructure
|
||||||
|
|
||||||
|
import com.password4j.Hash
|
||||||
|
import com.password4j.Password
|
||||||
|
|
||||||
|
internal fun hashPassword(password: String): Hash =
|
||||||
|
Password.hash(password).addRandomSalt().withArgon2()
|
||||||
|
|
||||||
|
internal fun checkPassword(
|
||||||
|
password: String,
|
||||||
|
hash: Hash,
|
||||||
|
): Boolean =
|
||||||
|
Password.check(password, hash)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package eventDemo.contexts.auth.infrastructure.configure
|
||||||
|
|
||||||
|
import io.ktor.server.application.Application
|
||||||
|
|
||||||
|
fun Application.configureAuth() {
|
||||||
|
configureKtorAuth()
|
||||||
|
configureAuthRoutes()
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package eventDemo.contexts.auth.infrastructure.configure
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository
|
||||||
|
import eventDemo.contexts.auth.application.eventStores.UserRepository
|
||||||
|
import eventDemo.contexts.auth.application.ports.UserEventStore
|
||||||
|
import eventDemo.contexts.auth.application.ports.UserProjectionRepository
|
||||||
|
import eventDemo.contexts.auth.infrastructure.persistence.eventStore.UserEventStoreInPostgresql
|
||||||
|
import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjectionRepositoryInPostgresql
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
|
||||||
|
fun Module.configureAuthDi() {
|
||||||
|
singleOf(::UserEventStoreRepository) bind UserRepository::class
|
||||||
|
singleOf(::UserEventStoreInPostgresql) bind UserEventStore::class
|
||||||
|
singleOf(::UserProjectionRepositoryInPostgresql) bind UserProjectionRepository::class
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package eventDemo.contexts.auth.infrastructure.configure
|
||||||
|
|
||||||
|
import eventDemo.configuration.configuration
|
||||||
|
import eventDemo.contexts.auth.application.eventStores.UserEventStoreRepository
|
||||||
|
import eventDemo.contexts.auth.application.ports.UserProjectionRepository
|
||||||
|
import eventDemo.contexts.auth.infrastructure.rest.createUserRoute
|
||||||
|
import eventDemo.contexts.auth.infrastructure.rest.loginRoute
|
||||||
|
import io.ktor.server.application.Application
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import org.koin.ktor.ext.get
|
||||||
|
|
||||||
|
fun Application.configureAuthRoutes() {
|
||||||
|
val userRepository = get<UserEventStoreRepository>()
|
||||||
|
val userProjectionRepository = get<UserProjectionRepository>()
|
||||||
|
routing {
|
||||||
|
createUserRoute(userRepository)
|
||||||
|
loginRoute(environment.config.configuration.jwtSecret, userProjectionRepository)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-23
@@ -1,24 +1,21 @@
|
|||||||
package eventDemo.configuration.ktor
|
package eventDemo.contexts.auth.infrastructure.configure
|
||||||
|
|
||||||
import com.auth0.jwt.JWT
|
import com.auth0.jwt.JWT
|
||||||
import com.auth0.jwt.algorithms.Algorithm
|
import com.auth0.jwt.algorithms.Algorithm
|
||||||
import eventDemo.domain.entity.Player
|
import eventDemo.configuration.configuration
|
||||||
|
import eventDemo.contexts.auth.domain.User
|
||||||
|
import eventDemo.contexts.auth.infrastructure.persistence.projection.UserProjection
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.auth.authentication
|
import io.ktor.server.auth.authentication
|
||||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||||
import io.ktor.server.auth.jwt.jwt
|
import io.ktor.server.auth.jwt.jwt
|
||||||
import io.ktor.server.response.respond
|
import io.ktor.server.response.respond
|
||||||
import io.ktor.server.routing.post
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
private const val JWT_ISSUER = "PlayCardGame"
|
fun Application.configureKtorAuth() {
|
||||||
|
val jwtSecret = environment.config.configuration.jwtSecret
|
||||||
fun Application.configureSecurity() {
|
|
||||||
val jwtSecret = environment.config.propertyOrNull("jwt.secret")?.getString() ?: error("You must set a jwt secret")
|
|
||||||
|
|
||||||
authentication {
|
authentication {
|
||||||
jwt {
|
jwt {
|
||||||
realm = "Play card game"
|
realm = "Play card game"
|
||||||
@@ -29,7 +26,11 @@ fun Application.configureSecurity() {
|
|||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
validate { credential ->
|
validate { credential ->
|
||||||
if (credential.payload.getClaim("username").asString() != "") {
|
if (credential.payload
|
||||||
|
.getClaim("username")
|
||||||
|
.asString()
|
||||||
|
.isNotEmpty()
|
||||||
|
) {
|
||||||
JWTPrincipal(credential.payload)
|
JWTPrincipal(credential.payload)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -40,22 +41,25 @@ fun Application.configureSecurity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
routing {
|
|
||||||
post("login/{username}") {
|
|
||||||
val username = call.parameters["username"]!!
|
|
||||||
val player = Player(name = username)
|
|
||||||
|
|
||||||
call.respond(hashMapOf("token" to player.makeJwt(jwtSecret)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Player.makeJwt(jwtSecret: String): String =
|
private const val JWT_ISSUER = "PlayCardGame"
|
||||||
|
|
||||||
|
fun UserProjection.makeJwt(jwtSecret: String): String =
|
||||||
|
makeJwt(jwtSecret, id, username)
|
||||||
|
|
||||||
|
fun User.makeJwt(jwtSecret: String): String =
|
||||||
|
makeJwt(jwtSecret, id, username)
|
||||||
|
|
||||||
|
fun makeJwt(
|
||||||
|
jwtSecret: String,
|
||||||
|
id: UserId,
|
||||||
|
username: String,
|
||||||
|
): String =
|
||||||
JWT
|
JWT
|
||||||
.create()
|
.create()
|
||||||
.withIssuer(JWT_ISSUER)
|
.withIssuer(JWT_ISSUER)
|
||||||
.withClaim("username", name)
|
.withClaim("username", username)
|
||||||
.withPayload(Json.encodeToString(this))
|
.withClaim("userid", id.toString())
|
||||||
.withExpiresAt(Date(System.currentTimeMillis() + 60000))
|
.withExpiresAt(Date(System.currentTimeMillis() + 60000))
|
||||||
.sign(Algorithm.HMAC256(jwtSecret))
|
.sign(Algorithm.HMAC256(jwtSecret))
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package eventDemo.contexts.auth.infrastructure.persistence.eventStore
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.application.ports.UserEventStore
|
||||||
|
import eventDemo.contexts.auth.domain.events.UserEvent
|
||||||
|
import eventDemo.libs.eventSource.eventStore.EventStore
|
||||||
|
import eventDemo.libs.eventSource.eventStore.EventStoreInMemory
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stream to publish and read the user events.
|
||||||
|
*/
|
||||||
|
class UserEventStoreInMemory :
|
||||||
|
UserEventStore,
|
||||||
|
EventStore<UserEvent, UserId> by EventStoreInMemory()
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package eventDemo.contexts.auth.infrastructure.persistence.eventStore
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.application.ports.UserEventStore
|
||||||
|
import eventDemo.contexts.auth.domain.events.UserEvent
|
||||||
|
import eventDemo.libs.eventSource.eventStore.EventStore
|
||||||
|
import eventDemo.libs.eventSource.eventStore.EventStoreInPostgresql
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import javax.sql.DataSource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stream to publish and read the user events.
|
||||||
|
*/
|
||||||
|
class UserEventStoreInPostgresql(
|
||||||
|
dataSource: DataSource,
|
||||||
|
) : UserEventStore,
|
||||||
|
EventStore<UserEvent, UserId> by EventStoreInPostgresql(
|
||||||
|
dataSource,
|
||||||
|
{ Json.encodeToString(it) },
|
||||||
|
{ Json.decodeFromString(it) },
|
||||||
|
"auth.user_event_stream",
|
||||||
|
)
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package eventDemo.contexts.auth.infrastructure.persistence.projection
|
||||||
|
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
|
||||||
|
data class UserProjection(
|
||||||
|
val id: UserId,
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package eventDemo.contexts.auth.infrastructure.persistence.projection
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.application.ports.UserProjectionRepository
|
||||||
|
import eventDemo.contexts.auth.infrastructure.checkPassword
|
||||||
|
import eventDemo.contexts.auth.infrastructure.hashPassword
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.sql.DataSource
|
||||||
|
|
||||||
|
class UserProjectionRepositoryInPostgresql(
|
||||||
|
val dataSource: DataSource,
|
||||||
|
) : UserProjectionRepository {
|
||||||
|
override fun getByUsername(username: String): UserProjection? =
|
||||||
|
dataSource.connection
|
||||||
|
.prepareStatement(
|
||||||
|
"""
|
||||||
|
select id, username
|
||||||
|
from auth."user"
|
||||||
|
where id = ?;
|
||||||
|
""".trimIndent(),
|
||||||
|
).use {
|
||||||
|
it.setObject(1, username)
|
||||||
|
it.executeQuery()
|
||||||
|
}.use { resultSet ->
|
||||||
|
if (resultSet.next()) {
|
||||||
|
UserProjection(
|
||||||
|
id = UserId(UUID.fromString(resultSet.getString("id"))),
|
||||||
|
username = resultSet.getString("username"),
|
||||||
|
password = resultSet.getString("password"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(user: UserProjection) {
|
||||||
|
dataSource.connection.use { connection ->
|
||||||
|
connection
|
||||||
|
.prepareStatement(
|
||||||
|
"""
|
||||||
|
insert into auth.user (id, username)
|
||||||
|
values (?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
).use {
|
||||||
|
it.setObject(1, user.id)
|
||||||
|
it.setString(2, user.username)
|
||||||
|
it.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getUserIfPasswordIsValid(
|
||||||
|
username: String,
|
||||||
|
rawPassword: String,
|
||||||
|
): UserProjection? {
|
||||||
|
val user = getByUsername(username) ?: return null
|
||||||
|
val isValid = checkPassword(rawPassword, hashPassword(user.password))
|
||||||
|
if (!isValid) return null
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package eventDemo.contexts.auth.infrastructure.rest
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.application.ports.UserProjectionRepository
|
||||||
|
import eventDemo.contexts.auth.infrastructure.configure.makeJwt
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.post
|
||||||
|
|
||||||
|
fun Route.loginRoute(
|
||||||
|
jwtSecret: String,
|
||||||
|
userProjectionRepository: UserProjectionRepository,
|
||||||
|
) {
|
||||||
|
post("login/{username}") {
|
||||||
|
val username = call.parameters["username"]!!
|
||||||
|
val rawPassword = call.parameters["password"]!!
|
||||||
|
|
||||||
|
val userProjection =
|
||||||
|
userProjectionRepository.getUserIfPasswordIsValid(username, rawPassword)
|
||||||
|
?: return@post call.respond(HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
call.respond(hashMapOf("token" to userProjection.makeJwt(jwtSecret)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package eventDemo.contexts.auth.infrastructure.rest
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.application.eventStores.UserRepository
|
||||||
|
import eventDemo.contexts.auth.domain.User
|
||||||
|
import eventDemo.contexts.auth.infrastructure.hashPassword
|
||||||
|
import io.ktor.resources.Resource
|
||||||
|
import io.ktor.server.auth.authenticate
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.post
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@Resource("/users")
|
||||||
|
class Users {
|
||||||
|
@Serializable
|
||||||
|
@Resource("/create")
|
||||||
|
class Create(
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API routes to show all games.
|
||||||
|
*/
|
||||||
|
fun Route.createUserRoute(userRepository: UserRepository) {
|
||||||
|
authenticate {
|
||||||
|
// Create a new User, and return there ID
|
||||||
|
post<Users.Create> {
|
||||||
|
val passwordHash = hashPassword(it.password)
|
||||||
|
val user = User.createNewUser(it.username, passwordHash.result)
|
||||||
|
userRepository.save(user)
|
||||||
|
call.respond(
|
||||||
|
object {
|
||||||
|
val id = user.id.toString()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package eventDemo.contexts.game.application.channels
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.application.command.models.GameCommand
|
||||||
|
import eventDemo.contexts.game.application.notification.CommandSubscriber
|
||||||
|
import eventDemo.contexts.game.application.notification.EventToNotificationSubscriber
|
||||||
|
import eventDemo.contexts.game.application.notification.models.Notification
|
||||||
|
import eventDemo.contexts.game.domain.game.GameId
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.channels.SendChannel
|
||||||
|
|
||||||
|
class GameChannelsSubscriber(
|
||||||
|
private val eventToNotificationSubscriber: EventToNotificationSubscriber,
|
||||||
|
private val commandSubscriber: CommandSubscriber,
|
||||||
|
) {
|
||||||
|
@DelicateCoroutinesApi
|
||||||
|
fun subscribePlayerToGameChannels(
|
||||||
|
gameId: GameId,
|
||||||
|
userId: UserId,
|
||||||
|
incomingCommandChannel: ReceiveChannel<GameCommand>,
|
||||||
|
sendNotificationChannel: SendChannel<Notification>,
|
||||||
|
) {
|
||||||
|
val sub =
|
||||||
|
eventToNotificationSubscriber.subscribeToEventsAndSendNotification(
|
||||||
|
gameId = gameId,
|
||||||
|
currentUserId = userId,
|
||||||
|
outgoingFrameChannel = sendNotificationChannel,
|
||||||
|
)
|
||||||
|
|
||||||
|
commandSubscriber
|
||||||
|
.subscribe(
|
||||||
|
currentUserId = userId,
|
||||||
|
incomingFrameChannel = incomingCommandChannel,
|
||||||
|
).invokeOnCompletion { sub.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package eventDemo.domain.command
|
package eventDemo.contexts.game.application.command.handlers
|
||||||
|
|
||||||
class CommandException(
|
class CommandException(
|
||||||
override val message: String,
|
override val message: String,
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package eventDemo.contexts.game.application.command.handlers
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.application.command.models.GameCommand
|
||||||
|
import eventDemo.contexts.game.application.command.models.JoinTheGameCommand
|
||||||
|
import eventDemo.contexts.game.application.command.models.PlayCardCommand
|
||||||
|
import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand
|
||||||
|
import eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand
|
||||||
|
import eventDemo.contexts.game.domain.game.GameId
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
class GameCommandHandlerDispatcher(
|
||||||
|
private val playCardHandler: PlayCardHandler,
|
||||||
|
private val readyToPlayHandler: ReadyToPlayHandler,
|
||||||
|
private val joinTheGameHandler: JoinTheGameHandler,
|
||||||
|
private val takeCartFromDrawPileHandler: TakeCartFromDrawPileHandler,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val lock: MutableMap<GameId, String> = Collections.synchronizedMap(mutableMapOf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispatch(command: GameCommand) {
|
||||||
|
synchronized(lock.getOrPut(command.payload.aggregateId) { command.payload.aggregateId.toString() }) {
|
||||||
|
when (command) {
|
||||||
|
is JoinTheGameCommand -> joinTheGameHandler.handle(command)
|
||||||
|
is ReadyToPlayCommand -> readyToPlayHandler.handle(command)
|
||||||
|
is PlayCardCommand -> playCardHandler.handle(command)
|
||||||
|
is TakeCartFromDrawPileCommand -> takeCartFromDrawPileHandler.handle(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package eventDemo.contexts.game.application.command.handlers
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.application.command.models.GameCommand
|
||||||
|
import eventDemo.contexts.game.application.eventStores.GameRepository
|
||||||
|
import eventDemo.contexts.game.application.ports.GameEventBus
|
||||||
|
import eventDemo.contexts.game.domain.events.GameEvent
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.Game
|
||||||
|
import eventDemo.libs.command.Command
|
||||||
|
import eventDemo.libs.eventSource.eventStore.VersionConflictException
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
sealed interface CommandHandler<C : Command> {
|
||||||
|
fun handle(command: C)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class GameEventManager(
|
||||||
|
private val gameRepository: GameRepository,
|
||||||
|
private val gameEventBus: GameEventBus,
|
||||||
|
) {
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
fun GameCommand.getGame(): Game =
|
||||||
|
gameRepository.get(payload.aggregateId) ?: error("Game not found")
|
||||||
|
|
||||||
|
fun GameEvent.getGame(): Game =
|
||||||
|
gameRepository.get(aggregateId) ?: error("Game not found")
|
||||||
|
|
||||||
|
@Throws(VersionConflictException::class)
|
||||||
|
protected fun Game.saveEvents(): Game {
|
||||||
|
gameRepository.save(this)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun Game.publishEvents(): Game {
|
||||||
|
gameEventBus.publish(recordedEvents)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected inline fun <reified G : Game> Game.isStatusOrFail(message: String): G =
|
||||||
|
this as? G ?: throw CommandException(message)
|
||||||
|
|
||||||
|
protected fun <T> retry(
|
||||||
|
mapAttempts: Int = 5,
|
||||||
|
block: () -> T,
|
||||||
|
): T =
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (e: VersionConflictException) {
|
||||||
|
if (mapAttempts > 0) {
|
||||||
|
logger.warn { "retry after version conflict (attempts left: $mapAttempts)" }
|
||||||
|
retry(mapAttempts - 1, block)
|
||||||
|
} else {
|
||||||
|
logger.error { "Version conflict retry failed" }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package eventDemo.contexts.game.application.command.handlers
|
||||||
|
|
||||||
|
import eventDemo.contexts.auth.application.eventStores.UserRepository
|
||||||
|
import eventDemo.contexts.game.application.command.models.JoinTheGameCommand
|
||||||
|
import eventDemo.contexts.game.application.eventStores.GameRepository
|
||||||
|
import eventDemo.contexts.game.application.ports.GameEventBus
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.GameCreated
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to perform an action to play a new card
|
||||||
|
*/
|
||||||
|
class JoinTheGameHandler(
|
||||||
|
gameRepository: GameRepository,
|
||||||
|
gameEventBus: GameEventBus,
|
||||||
|
private val userRepository: UserRepository,
|
||||||
|
) : GameEventManager(gameRepository, gameEventBus),
|
||||||
|
CommandHandler<JoinTheGameCommand> {
|
||||||
|
override fun handle(command: JoinTheGameCommand) {
|
||||||
|
val user = userRepository.get(command.userId) ?: error("User with id ${command.userId} doesn't exist")
|
||||||
|
retry {
|
||||||
|
command
|
||||||
|
.getGame()
|
||||||
|
.isStatusOrFail<GameCreated>("The game is started")
|
||||||
|
.userJoinTheGame(
|
||||||
|
userId = command.userId,
|
||||||
|
name = user.username,
|
||||||
|
).saveEvents()
|
||||||
|
.publishEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package eventDemo.contexts.game.application.command.handlers
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.application.command.models.PlayCardCommand
|
||||||
|
import eventDemo.contexts.game.application.eventStores.GameRepository
|
||||||
|
import eventDemo.contexts.game.application.ports.GameEventBus
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.GameStarted
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to perform an action to play a new card
|
||||||
|
*/
|
||||||
|
class PlayCardHandler(
|
||||||
|
gameRepository: GameRepository,
|
||||||
|
gameEventBus: GameEventBus,
|
||||||
|
) : GameEventManager(gameRepository, gameEventBus),
|
||||||
|
CommandHandler<PlayCardCommand> {
|
||||||
|
override fun handle(command: PlayCardCommand) {
|
||||||
|
command
|
||||||
|
.getGame()
|
||||||
|
.isStatusOrFail<GameStarted>("The game is not started")
|
||||||
|
.playTheCard(
|
||||||
|
card = command.payload.card,
|
||||||
|
playerId = command.payload.playerId,
|
||||||
|
chosenColor = command.payload.chosenColor,
|
||||||
|
).saveEvents()
|
||||||
|
.publishEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package eventDemo.contexts.game.application.command.handlers
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.application.command.models.ReadyToPlayCommand
|
||||||
|
import eventDemo.contexts.game.application.eventStores.GameRepository
|
||||||
|
import eventDemo.contexts.game.application.ports.GameEventBus
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.GameCreated
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to set as ready to play
|
||||||
|
*/
|
||||||
|
class ReadyToPlayHandler(
|
||||||
|
gameRepository: GameRepository,
|
||||||
|
gameEventBus: GameEventBus,
|
||||||
|
) : GameEventManager(gameRepository, gameEventBus),
|
||||||
|
CommandHandler<ReadyToPlayCommand> {
|
||||||
|
override fun handle(command: ReadyToPlayCommand) {
|
||||||
|
command
|
||||||
|
.getGame()
|
||||||
|
.isStatusOrFail<GameCreated>("The game is started")
|
||||||
|
.setReadyPlayer(command.payload.playerId)
|
||||||
|
.saveEvents()
|
||||||
|
.publishEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package eventDemo.contexts.game.application.command.handlers
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.application.command.models.TakeCartFromDrawPileCommand
|
||||||
|
import eventDemo.contexts.game.application.eventStores.GameRepository
|
||||||
|
import eventDemo.contexts.game.application.ports.GameEventBus
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.GameStarted
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to draw card on draw pile.
|
||||||
|
*
|
||||||
|
* Is can be triggered when you cannot play any card in your hand.
|
||||||
|
*/
|
||||||
|
class TakeCartFromDrawPileHandler(
|
||||||
|
gameRepository: GameRepository,
|
||||||
|
gameEventBus: GameEventBus,
|
||||||
|
) : GameEventManager(gameRepository, gameEventBus),
|
||||||
|
CommandHandler<TakeCartFromDrawPileCommand> {
|
||||||
|
override fun handle(command: TakeCartFromDrawPileCommand) {
|
||||||
|
command
|
||||||
|
.getGame()
|
||||||
|
.isStatusOrFail<GameStarted>("The game is not started")
|
||||||
|
.playerTakeCartFromDrawPile(command.payload.playerId, 1)
|
||||||
|
.saveEvents()
|
||||||
|
.publishEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package eventDemo.contexts.game.application.command.models
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.domain.game.GameId
|
||||||
|
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
|
||||||
|
import eventDemo.libs.command.Command
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface GameCommand : Command {
|
||||||
|
val userId: UserId
|
||||||
|
val payload: Payload
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface Payload {
|
||||||
|
@Serializable(with = GameIdSerializer::class)
|
||||||
|
val aggregateId: GameId
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-5
@@ -1,22 +1,24 @@
|
|||||||
package eventDemo.domain.command.command
|
package eventDemo.contexts.game.application.command.models
|
||||||
|
|
||||||
import eventDemo.domain.entity.GameId
|
import eventDemo.contexts.game.domain.game.GameId
|
||||||
import eventDemo.domain.entity.Player
|
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
|
||||||
import eventDemo.libs.command.CommandId
|
import eventDemo.libs.command.CommandId
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A command to perform an action to play a new card
|
* A command to perform an action to play a new card
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ICantPlayCommand(
|
data class JoinTheGameCommand(
|
||||||
|
override val userId: UserId,
|
||||||
override val payload: Payload,
|
override val payload: Payload,
|
||||||
) : GameCommand {
|
) : GameCommand {
|
||||||
override val id: CommandId = CommandId()
|
override val id: CommandId = CommandId()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Payload(
|
data class Payload(
|
||||||
|
@Serializable(with = GameIdSerializer::class)
|
||||||
override val aggregateId: GameId,
|
override val aggregateId: GameId,
|
||||||
override val player: Player,
|
|
||||||
) : GameCommand.Payload
|
) : GameCommand.Payload
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package eventDemo.contexts.game.application.command.models
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.domain.game.Card
|
||||||
|
import eventDemo.contexts.game.domain.game.GameId
|
||||||
|
import eventDemo.contexts.game.domain.game.Player
|
||||||
|
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
|
||||||
|
import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
|
||||||
|
import eventDemo.libs.command.CommandId
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to perform an action to play a new card
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlayCardCommand(
|
||||||
|
override val userId: UserId,
|
||||||
|
override val payload: Payload,
|
||||||
|
) : GameCommand {
|
||||||
|
override val id: CommandId = CommandId()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Payload(
|
||||||
|
@Serializable(with = GameIdSerializer::class)
|
||||||
|
override val aggregateId: GameId,
|
||||||
|
@Serializable(with = PlayerIdSerializer::class)
|
||||||
|
val playerId: Player.PlayerId,
|
||||||
|
val card: Card,
|
||||||
|
val chosenColor: Card.Color?,
|
||||||
|
) : GameCommand.Payload
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package eventDemo.contexts.game.application.command.models
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.domain.game.GameId
|
||||||
|
import eventDemo.contexts.game.domain.game.Player
|
||||||
|
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
|
||||||
|
import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
|
||||||
|
import eventDemo.libs.command.CommandId
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to set as ready to play
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ReadyToPlayCommand(
|
||||||
|
override val userId: UserId,
|
||||||
|
override val payload: Payload,
|
||||||
|
) : GameCommand {
|
||||||
|
override val id: CommandId = CommandId()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Payload(
|
||||||
|
@Serializable(with = GameIdSerializer::class)
|
||||||
|
override val aggregateId: GameId,
|
||||||
|
@Serializable(with = PlayerIdSerializer::class)
|
||||||
|
val playerId: Player.PlayerId,
|
||||||
|
) : GameCommand.Payload
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package eventDemo.contexts.game.application.command.models
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.domain.game.GameId
|
||||||
|
import eventDemo.contexts.game.domain.game.Player
|
||||||
|
import eventDemo.contexts.game.infrastructure.persistence.serializers.GameIdSerializer
|
||||||
|
import eventDemo.contexts.game.infrastructure.persistence.serializers.PlayerIdSerializer
|
||||||
|
import eventDemo.libs.command.CommandId
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to perform an action to play a new card
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TakeCartFromDrawPileCommand(
|
||||||
|
override val userId: UserId,
|
||||||
|
override val payload: Payload,
|
||||||
|
) : GameCommand {
|
||||||
|
override val id: CommandId = CommandId()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Payload(
|
||||||
|
@Serializable(with = GameIdSerializer::class)
|
||||||
|
override val aggregateId: GameId,
|
||||||
|
@Serializable(with = PlayerIdSerializer::class)
|
||||||
|
val playerId: Player.PlayerId,
|
||||||
|
) : GameCommand.Payload
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package eventDemo.contexts.game.application.eventStores
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.application.ports.GameEventStore
|
||||||
|
import eventDemo.contexts.game.domain.game.GameId
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.Game
|
||||||
|
import eventDemo.libs.eventSource.eventStore.VersionConflictException
|
||||||
|
|
||||||
|
class GameEventStoreRepository(
|
||||||
|
val eventStore: GameEventStore,
|
||||||
|
) : GameRepository {
|
||||||
|
override fun get(id: GameId): Game? {
|
||||||
|
val events =
|
||||||
|
eventStore
|
||||||
|
.getStream(id)
|
||||||
|
.readAll()
|
||||||
|
if (events.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return events.let { Game.loadFromHistory(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(VersionConflictException::class)
|
||||||
|
override fun save(game: Game) {
|
||||||
|
eventStore.append(game.recordedEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package eventDemo.contexts.game.application.eventStores
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.domain.game.GameId
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.Game
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.GameCreated
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.GameInit
|
||||||
|
import eventDemo.libs.eventSource.eventStore.VersionConflictException
|
||||||
|
|
||||||
|
interface GameRepository {
|
||||||
|
fun get(id: GameId): Game?
|
||||||
|
|
||||||
|
@Throws(VersionConflictException::class)
|
||||||
|
fun save(game: Game)
|
||||||
|
|
||||||
|
fun getOrCreate(gameId: GameId): Game =
|
||||||
|
get(gameId) ?: create(gameId)
|
||||||
|
|
||||||
|
fun create(gameId: GameId = GameId()): GameCreated =
|
||||||
|
GameInit.createNewGame(gameId).also { save(it) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package eventDemo.contexts.game.application.logging
|
||||||
|
|
||||||
|
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||||
|
|
||||||
|
inline fun <T> withLoggingContext(
|
||||||
|
vararg pair: Pair<LoggingContextKeys, *>,
|
||||||
|
body: () -> T,
|
||||||
|
): T =
|
||||||
|
withLoggingContext(
|
||||||
|
*pair
|
||||||
|
.map {
|
||||||
|
it.first.name to it.second.toString()
|
||||||
|
}.toTypedArray(),
|
||||||
|
restorePrevious = true,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
|
||||||
|
// inline fun withLoggingContext(
|
||||||
|
// vararg pair: Pair<LoggingContextKeys, *>,
|
||||||
|
// body: () -> Unit,
|
||||||
|
// ) =
|
||||||
|
// withLoggingContext(
|
||||||
|
// *pair
|
||||||
|
// .map {
|
||||||
|
// it.first.name to it.second.toString()
|
||||||
|
// }.toTypedArray(),
|
||||||
|
// restorePrevious = true,
|
||||||
|
// body = body,
|
||||||
|
// )
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package eventDemo.contexts.game.application.logging
|
||||||
|
|
||||||
|
enum class LoggingContextKeys {
|
||||||
|
CurrentUserId,
|
||||||
|
Notification,
|
||||||
|
Game,
|
||||||
|
Event,
|
||||||
|
Command,
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
package eventDemo.contexts.game.application.notification
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.application.notification.models.ItsTheTurnOfNotification
|
||||||
|
import eventDemo.contexts.game.application.notification.models.Notification
|
||||||
|
import eventDemo.contexts.game.application.notification.models.PilesShuffledNotification
|
||||||
|
import eventDemo.contexts.game.application.notification.models.PlayerAsJoinTheGameNotification
|
||||||
|
import eventDemo.contexts.game.application.notification.models.PlayerAsPlayACardNotification
|
||||||
|
import eventDemo.contexts.game.application.notification.models.PlayerHavePassNotification
|
||||||
|
import eventDemo.contexts.game.application.notification.models.PlayerWasReadyNotification
|
||||||
|
import eventDemo.contexts.game.application.notification.models.PlayerWinNotification
|
||||||
|
import eventDemo.contexts.game.application.notification.models.TheGameWasStartedNotification
|
||||||
|
import eventDemo.contexts.game.application.notification.models.WelcomeToTheGameNotification
|
||||||
|
import eventDemo.contexts.game.application.notification.models.YourNewCardNotification
|
||||||
|
import eventDemo.contexts.game.domain.events.CardIsPlayedEvent
|
||||||
|
import eventDemo.contexts.game.domain.events.DrawFilledWithDiscardEvent
|
||||||
|
import eventDemo.contexts.game.domain.events.GameCreatedEvent
|
||||||
|
import eventDemo.contexts.game.domain.events.GameEvent
|
||||||
|
import eventDemo.contexts.game.domain.events.GameStartedEvent
|
||||||
|
import eventDemo.contexts.game.domain.events.NewPlayerEvent
|
||||||
|
import eventDemo.contexts.game.domain.events.PlayerActionEvent
|
||||||
|
import eventDemo.contexts.game.domain.events.PlayerHaveDrawCardEvent
|
||||||
|
import eventDemo.contexts.game.domain.events.PlayerReadyEvent
|
||||||
|
import eventDemo.contexts.game.domain.events.PlayerWinEvent
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.Game
|
||||||
|
import eventDemo.contexts.game.domain.game.gameState.GameStarted
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
fun GameEvent.toNotification(
|
||||||
|
game: Game,
|
||||||
|
currentUserId: UserId,
|
||||||
|
): Iterable<Notification> =
|
||||||
|
Iterable {
|
||||||
|
iterator {
|
||||||
|
context(iterator: SequenceScope<Notification>)
|
||||||
|
suspend fun Notification.send() {
|
||||||
|
withLoggingContext("notification" to (this).toString()) {
|
||||||
|
logger.info { "Notification sent" }
|
||||||
|
iterator.yield(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PlayerActionEvent.isFromCurrentUser(): Boolean =
|
||||||
|
game.players.get(currentUserId).id == playerId
|
||||||
|
|
||||||
|
when (this@toNotification) {
|
||||||
|
is GameCreatedEvent -> {
|
||||||
|
// Nothing to send
|
||||||
|
}
|
||||||
|
|
||||||
|
is DrawFilledWithDiscardEvent -> {
|
||||||
|
PilesShuffledNotification().send()
|
||||||
|
}
|
||||||
|
|
||||||
|
is NewPlayerEvent -> {
|
||||||
|
if (this@toNotification.player.userId != currentUserId) {
|
||||||
|
PlayerAsJoinTheGameNotification(
|
||||||
|
player = this@toNotification.player,
|
||||||
|
).send()
|
||||||
|
} else {
|
||||||
|
WelcomeToTheGameNotification(
|
||||||
|
players = game.players,
|
||||||
|
).send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is CardIsPlayedEvent -> {
|
||||||
|
PlayerAsPlayACardNotification(
|
||||||
|
playerId = this@toNotification.playerId,
|
||||||
|
card = this@toNotification.card,
|
||||||
|
).send()
|
||||||
|
|
||||||
|
if (game is GameStarted) {
|
||||||
|
ItsTheTurnOfNotification(
|
||||||
|
player = game.nextPlayer,
|
||||||
|
).send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is GameStartedEvent -> {
|
||||||
|
TheGameWasStartedNotification(
|
||||||
|
hand =
|
||||||
|
game.players
|
||||||
|
.get(currentUserId)
|
||||||
|
.hand.cards,
|
||||||
|
).send()
|
||||||
|
|
||||||
|
if (game is GameStarted) {
|
||||||
|
ItsTheTurnOfNotification(player = game.nextPlayer)
|
||||||
|
.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlayerHaveDrawCardEvent -> {
|
||||||
|
if (this@toNotification.isFromCurrentUser()) {
|
||||||
|
YourNewCardNotification(
|
||||||
|
cards = this@toNotification.takenCards,
|
||||||
|
).send()
|
||||||
|
} else {
|
||||||
|
PlayerHavePassNotification(
|
||||||
|
playerId = this@toNotification.playerId,
|
||||||
|
).send()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game is GameStarted) {
|
||||||
|
ItsTheTurnOfNotification(player = game.nextPlayer)
|
||||||
|
.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlayerReadyEvent -> {
|
||||||
|
PlayerWasReadyNotification(
|
||||||
|
playerId = this@toNotification.playerId,
|
||||||
|
).send()
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlayerWinEvent -> {
|
||||||
|
PlayerWinNotification(
|
||||||
|
playerId = this@toNotification.playerId,
|
||||||
|
).send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package eventDemo.contexts.game.application.notification
|
||||||
|
|
||||||
|
import eventDemo.contexts.game.application.command.handlers.GameCommandHandlerDispatcher
|
||||||
|
import eventDemo.contexts.game.application.command.models.GameCommand
|
||||||
|
import eventDemo.contexts.game.application.eventStores.GameRepository
|
||||||
|
import eventDemo.contexts.game.application.logging.LoggingContextKeys.Command
|
||||||
|
import eventDemo.contexts.game.application.logging.LoggingContextKeys.CurrentUserId
|
||||||
|
import eventDemo.contexts.game.application.logging.LoggingContextKeys.Event
|
||||||
|
import eventDemo.contexts.game.application.logging.LoggingContextKeys.Game
|
||||||
|
import eventDemo.contexts.game.application.logging.LoggingContextKeys.Notification
|
||||||
|
import eventDemo.contexts.game.application.logging.withLoggingContext
|
||||||
|
import eventDemo.contexts.game.application.notification.models.Notification
|
||||||
|
import eventDemo.contexts.game.application.ports.GameEventBus
|
||||||
|
import eventDemo.contexts.game.domain.game.GameId
|
||||||
|
import eventDemo.libs.bus.Bus
|
||||||
|
import eventDemo.libs.command.CommandUnicityChecker
|
||||||
|
import eventDemo.sharedKernel.UserId
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.channels.SendChannel
|
||||||
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class EventToNotificationSubscriber(
|
||||||
|
private val gameEventBus: GameEventBus,
|
||||||
|
private val gameRepository: GameRepository,
|
||||||
|
) {
|
||||||
|
fun subscribeToEventsAndSendNotification(
|
||||||
|
gameId: GameId,
|
||||||
|
currentUserId: UserId,
|
||||||
|
outgoingFrameChannel: SendChannel<Notification>,
|
||||||
|
): Bus.Subscription =
|
||||||
|
withLoggingContext(CurrentUserId to currentUserId) {
|
||||||
|
gameEventBus.subscribe { event ->
|
||||||
|
val game = gameRepository.get(gameId) ?: error("Game not found")
|
||||||
|
withLoggingContext(Event to event, Game to game) {
|
||||||
|
event
|
||||||
|
.toNotification(
|
||||||
|
game = game,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
).forEach { notification ->
|
||||||
|
withLoggingContext(Notification to notification) {
|
||||||
|
outgoingFrameChannel.trySendBlocking(notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommandSubscriber(
|
||||||
|
private val gameCommandHandlerDispatcher: GameCommandHandlerDispatcher,
|
||||||
|
) {
|
||||||
|
private val controller = CommandUnicityChecker<GameCommand>()
|
||||||
|
|
||||||
|
@DelicateCoroutinesApi
|
||||||
|
fun subscribe(
|
||||||
|
currentUserId: UserId,
|
||||||
|
incomingFrameChannel: ReceiveChannel<GameCommand>,
|
||||||
|
): Job =
|
||||||
|
GlobalScope.launch {
|
||||||
|
for (command in incomingFrameChannel) {
|
||||||
|
withLoggingContext(CurrentUserId to currentUserId, Command to command) {
|
||||||
|
controller.runOnlyOnce(command) {
|
||||||
|
gameCommandHandlerDispatcher.dispatch(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
package eventDemo.domain.notification
|
package eventDemo.contexts.game.application.notification.models
|
||||||
|
|
||||||
import eventDemo.domain.entity.Player
|
import eventDemo.contexts.game.domain.game.Player
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
import eventDemo.libs.serializer.UUIDSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package eventDemo.domain.notification
|
package eventDemo.contexts.game.application.notification.models
|
||||||
|
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
import eventDemo.libs.serializer.UUIDSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package eventDemo.contexts.game.application.notification.models
|
||||||
|
|
||||||
|
import eventDemo.libs.serializer.UUIDSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PilesShuffledNotification(
|
||||||
|
@Serializable(with = UUIDSerializer::class)
|
||||||
|
override val id: UUID = UUID.randomUUID(),
|
||||||
|
) : Notification
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
package eventDemo.domain.notification
|
package eventDemo.contexts.game.application.notification.models
|
||||||
|
|
||||||
import eventDemo.domain.entity.Player
|
import eventDemo.contexts.game.domain.game.Player
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
import eventDemo.libs.serializer.UUIDSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
+5
-5
@@ -1,8 +1,8 @@
|
|||||||
package eventDemo.domain.notification
|
package eventDemo.contexts.game.application.notification.models
|
||||||
|
|
||||||
import eventDemo.domain.entity.Card
|
import eventDemo.contexts.game.domain.game.Card
|
||||||
import eventDemo.domain.entity.Player
|
import eventDemo.contexts.game.domain.game.Player
|
||||||
import eventDemo.configuration.serializer.UUIDSerializer
|
import eventDemo.libs.serializer.UUIDSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -10,6 +10,6 @@ import java.util.UUID
|
|||||||
data class PlayerAsPlayACardNotification(
|
data class PlayerAsPlayACardNotification(
|
||||||
@Serializable(with = UUIDSerializer::class)
|
@Serializable(with = UUIDSerializer::class)
|
||||||
override val id: UUID = UUID.randomUUID(),
|
override val id: UUID = UUID.randomUUID(),
|
||||||
val player: Player,
|
val playerId: Player.PlayerId,
|
||||||
val card: Card,
|
val card: Card,
|
||||||
) : Notification
|
) : Notification
|
||||||
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user