Compare commits
14
Commits
4e4b307275
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
505cfe38f0
|
||
|
|
85bcdcadb8
|
||
|
|
e67c475c38
|
||
|
|
07983dc5b0
|
||
|
|
5e9587b93f
|
||
|
|
fd62f2574d
|
||
|
|
828bd0639e
|
||
|
|
da13b0d2b8
|
||
|
|
d632dc0f6b
|
||
|
|
b60e4aa457
|
||
|
|
e262f35b27
|
||
|
|
77e4cab8ad
|
||
|
|
7291419c9f
|
||
|
|
b2b8fcf92f
|
@@ -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
|
||||
+30
-18
@@ -80,36 +80,48 @@ jobs:
|
||||
continue-on-error: false
|
||||
|
||||
test:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GRADLE_CACHE_DIR: ${{ github.workspace }}/.gradle-docker-cache
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
- name: Install a pinned Docker Compose version
|
||||
run: |
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fSL https://github.com/docker/compose/releases/download/v5.1.4/docker-compose-linux-x86_64 \
|
||||
-o ~/.docker/cli-plugins/docker-compose
|
||||
chmod +x ~/.docker/cli-plugins/docker-compose
|
||||
docker compose version
|
||||
|
||||
- name: Restore Gradle cache
|
||||
- name: Prepare docker secrets
|
||||
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:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ needs.build.outputs.cache-key }}
|
||||
path: ${{ env.GRADLE_CACHE_DIR }}
|
||||
key: ${{ steps.cache-key-generator.outputs.key }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
gradle-docker-${{ runner.os }}-
|
||||
|
||||
- name: Grant execute permission to Gradle wrapper
|
||||
run: chmod +x gradlew
|
||||
- name: Prepare cache directory permissions
|
||||
run: |
|
||||
mkdir -p "$GRADLE_CACHE_DIR"
|
||||
chmod -R 777 "$GRADLE_CACHE_DIR"
|
||||
|
||||
- name: Start CI Docker Compose services
|
||||
run: ./gradlew composeUp -Pci
|
||||
- name: Run tests in Docker
|
||||
run: docker compose -f docker/docker-compose-test.yaml run tests
|
||||
|
||||
- name: Run tests
|
||||
run: ./gradlew test -x composeUp --no-daemon
|
||||
- name: Shut down Docker services
|
||||
if: always()
|
||||
run: docker compose -f docker/docker-compose-test.yaml down -v
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
|
||||
@@ -37,3 +37,4 @@ out/
|
||||
/docker/.env
|
||||
/docker/*.secret
|
||||
*.hprof
|
||||
/.gradle-docker-cache/
|
||||
|
||||
@@ -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)
|
||||
- [What's the demo for ?](#whats-the-demo-for-)
|
||||
- [What's in this demo](#whats-in-this-demo)
|
||||
- [The stack](#the-stack)
|
||||
- [Architecture](./doc/architecture.md)
|
||||
|
||||
@@ -18,31 +17,6 @@ of different patterns and architectures.
|
||||
- The CQRS pattern.
|
||||
- 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
|
||||
---------
|
||||
|
||||
@@ -51,11 +25,11 @@ Language
|
||||
|
||||
Framework
|
||||
- Ktor
|
||||
- with Koin for Dependency Injection
|
||||
|
||||
Database
|
||||
- Postgresql
|
||||
- with Flyway
|
||||
- Redis
|
||||
- RabbitMQ
|
||||
|
||||
Infra
|
||||
|
||||
+11
-59
@@ -14,7 +14,6 @@ plugins {
|
||||
id("io.ktor.plugin") version "3.5.1"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
|
||||
id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
|
||||
id("com.avast.gradle.docker-compose") version "0.17.12"
|
||||
}
|
||||
|
||||
group = "io.github.flecomte"
|
||||
@@ -47,63 +46,17 @@ java {
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dockerCompose {
|
||||
val composeFile =
|
||||
if (project.hasProperty("ci")) {
|
||||
// Use docker-compose-ci.yaml for the CI
|
||||
"docker/docker-compose-ci.yaml"
|
||||
} else {
|
||||
// Use docker-compose-test.yaml for local tests
|
||||
"docker/docker-compose-test.yaml"
|
||||
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")
|
||||
}
|
||||
useComposeFiles.set(listOf(composeFile))
|
||||
setProjectName("event-demo-test")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
dependsOn("composeUp")
|
||||
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
|
||||
dockerCompose.setProjectName("event-demo-test")
|
||||
}
|
||||
|
||||
tasks.named("run") {
|
||||
dependsOn("composeUp")
|
||||
dockerCompose.useComposeFiles.set(listOf("docker/docker-compose-test.yaml"))
|
||||
dockerCompose.setProjectName("event-demo-dev")
|
||||
}
|
||||
|
||||
tasks.register<Copy>("copyEnv") {
|
||||
group = "docker"
|
||||
description = "copy the default dotenv file"
|
||||
from("docker")
|
||||
into("docker")
|
||||
rename {
|
||||
it.removeSuffix(".template")
|
||||
}
|
||||
include(".env.template")
|
||||
eachFile {
|
||||
if (File("docker/$name").exists()) {
|
||||
exclude()
|
||||
}
|
||||
}
|
||||
val files =
|
||||
listOf(
|
||||
File("docker/pgadmin.secret"),
|
||||
File("docker/postgresql.secret"),
|
||||
)
|
||||
outputs.files(*files.toTypedArray())
|
||||
doLast {
|
||||
files.forEach {
|
||||
if (!it.exists()) {
|
||||
it.writeText("changeit")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.composeUp {
|
||||
dependsOn("copyEnv")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -128,7 +81,6 @@ dependencies {
|
||||
implementation("io.github.oshai:kotlin-logging-jvm:${kotlinLoggingVersion.get()}")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${kotlinSerializationVersion.get()}")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
|
||||
implementation("redis.clients:jedis:5.2.0")
|
||||
implementation("org.postgresql:postgresql:42.7.13")
|
||||
implementation("com.zaxxer:HikariCP:6.3.0")
|
||||
implementation("com.rabbitmq:amqp-client:5.25.0")
|
||||
@@ -141,6 +93,6 @@ dependencies {
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${kotlinVersion.get()}")
|
||||
testImplementation("io.ktor:ktor-server-test-host-jvm:${ktorVersion.get()}")
|
||||
testImplementation("io.kotest:kotest-runner-junit5:${kotestVersion.get()}")
|
||||
testImplementation("io.mockk:mockk:1.13.17")
|
||||
testImplementation("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
|
||||
---------------------
|
||||
|
||||

|
||||
@@ -1,67 +0,0 @@
|
||||
# Exemple de structure
|
||||
|
||||
Les couches, du plus interne au plus externe
|
||||
```
|
||||
Domain (le cœur, ne dépend de RIEN d'externe)
|
||||
↑
|
||||
Application (orchestre le Domain, ne connaît pas l'infra concrète)
|
||||
↑
|
||||
Infrastructure (WebSocket, DB, event store — dépend de tout le reste)
|
||||
```
|
||||
|
||||
```
|
||||
src/
|
||||
└── contexts/
|
||||
├── auth/
|
||||
└── ...
|
||||
└── game/
|
||||
├── domain/ ← Le cœur métier, zéro dépendance externe
|
||||
│ ├── game/
|
||||
│ │ ├── Game.ts ← Aggregate Root
|
||||
│ │ ├── Player.ts ← Entity interne
|
||||
│ │ ├── Card.ts ← Entity
|
||||
│ │ ├── Color.ts ← Value Object
|
||||
│ │ ├── Deck.ts ← VO ou petite structure
|
||||
│ │ └── errors/
|
||||
│ │ ├── InvalidMoveError.ts
|
||||
│ │ └── ColorChoiceRequiredError.ts
|
||||
│ └── events/ ← Events de DOMAINE (internes)
|
||||
│ ├── CardPlayed.ts
|
||||
│ ├── CardDrawn.ts
|
||||
│ ├── TurnPassed.ts
|
||||
│ └── DomainEvent.ts ← interface/type de base
|
||||
├── application/ ← Orchestration, cas d'usage
|
||||
│ ├── commands/ ← Les Commandes (intentions)
|
||||
│ │ ├── PlayCardCommand.ts
|
||||
│ │ └── DrawCardCommand.ts
|
||||
│ ├── handlers/ ← Un handler par commande
|
||||
│ │ ├── PlayCardHandler.ts ← charge l'aggregate, appelle game.playCard(), save
|
||||
│ │ └── DrawCardHandler.ts
|
||||
│ ├── projections/ ← LA LOGIQUE de construction des projections
|
||||
│ │ ├── GameSummaryProjector.kt ← écoute les events, met à jour la vue
|
||||
│ │ └── PlayerStatsProjector.kt
|
||||
│ └── ports/ ← INTERFACES seulement (le "hexagone")
|
||||
│ ├── GameRepository.ts ← interface, pas d'implémentation
|
||||
│ ├── EventPublisher.ts ← interface, pas d'implémentation
|
||||
│ └── ProjectionStore.kt ← interface, où lire/écrire la projection
|
||||
├── infrastructure/ ← Tout ce qui est technique/externe
|
||||
│ ├── persistence/
|
||||
│ │ ├── EventStoreGameRepository.ts ← implémente GameRepository
|
||||
│ │ ├── EventStore.ts
|
||||
│ │ ├── projections/
|
||||
│ │ │ ├── GameSummaryProjectionStore.kt ← implémentation concrète (DB, table dédiée)
|
||||
│ │ │ └── models/
|
||||
│ │ │ └── GameSummaryView.kt ← structure de la vue elle-même
|
||||
│ ├── websocket/
|
||||
│ │ ├── WebSocketServer.ts
|
||||
│ │ ├── connectionManager.ts ← Map<gameId, Map<playerId, WebSocket>>
|
||||
│ │ └── commandRouter.ts ← reçoit le message brut, dispatch vers le bon handler
|
||||
│ └── eventPublisher/
|
||||
│ └── WebSocketEventPublisher.ts ← implémente EventPublisher, fait le broadcast
|
||||
└── presentation/ ← Traduction vers/depuis le client (le fameux DTO layer)
|
||||
├── clientEvents/
|
||||
│ ├── ClientEvent.ts ← types des events envoyés au front
|
||||
│ └── toClientEvent.ts ← fonction de traduction domain event → client event
|
||||
└── clientCommands/
|
||||
└── parseIncomingCommand.ts ← valide/parse le message brut du client → Command
|
||||
```
|
||||
+15
-5
@@ -1,23 +1,33 @@
|
||||
Installation
|
||||
============
|
||||
|
||||
To run the stack:
|
||||
To run the stack in production:
|
||||
|
||||
```shell
|
||||
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:
|
||||
- [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/)
|
||||
- [Redis](http://localhost:6379/)
|
||||
- [RabbitMQ](http://localhost:15672/)
|
||||
|
||||
Admin service URL:
|
||||
- [Træfik dashboard](http://traefik.traefik.me/)
|
||||
- [Redis insight](http://insight.redis.traefik.me/)
|
||||
- [pgAdmin](http://pgadmin.postgresql.traefik.me/)
|
||||
- [RabbitMQ management](http://management.rabbitmq.traefik.me/)
|
||||
@@ -1,93 +0,0 @@
|
||||
@startuml
|
||||
'https://plantuml.com/class-diagram
|
||||
|
||||
left to right direction
|
||||
|
||||
class GameList <<Projection>> {
|
||||
+ status: Status
|
||||
}
|
||||
class GameState <<Projection>> {
|
||||
+ players: List<Player>
|
||||
+ currentPlayerTurn: Player
|
||||
+ lastCardPlayer: Player
|
||||
+ colorOnCurrentStack: Color
|
||||
+ direction: Direction
|
||||
+ readyPlayers: List<Player>
|
||||
+ deck: Deck
|
||||
+ isStarted: Boolean
|
||||
+ playerWins: List<Player>
|
||||
}
|
||||
interface Card {
|
||||
+ id: UUID
|
||||
}
|
||||
enum Color {
|
||||
+ Blue
|
||||
+ Red
|
||||
+ Yellow
|
||||
+ Green
|
||||
}
|
||||
class GameId {
|
||||
+ id: UUID
|
||||
}
|
||||
class Player {
|
||||
+ id: PlayerId
|
||||
+ name: String
|
||||
}
|
||||
class Deck {
|
||||
+ stack: Stack
|
||||
+ discard: Discard
|
||||
+ playersHands: PlayersHands
|
||||
}
|
||||
class Stack {
|
||||
+ cards: List<Card>
|
||||
+ shuffle()
|
||||
}
|
||||
class Discard {
|
||||
+ cards: List<Card>
|
||||
}
|
||||
class PlayerHands {
|
||||
+ map: Map<PlayerId, List<Card>>
|
||||
}
|
||||
|
||||
class NumericCard {
|
||||
+ number: Int
|
||||
+ color: Color
|
||||
}
|
||||
class ReverseCard {
|
||||
+ color: Color
|
||||
}
|
||||
class PassCard {
|
||||
+ color: Color
|
||||
}
|
||||
class Plus2Card {
|
||||
+ color: Color
|
||||
}
|
||||
class Plus4Card
|
||||
class ChangeColorCard
|
||||
|
||||
GameState *-- Deck
|
||||
GameState o-- "many" Player
|
||||
Deck *-- PlayerHands
|
||||
PlayerHands *-- "many" Card
|
||||
PlayerHands o-- "many" Player
|
||||
Stack *-- "many" Card
|
||||
Discard *-- "many" Card
|
||||
Deck *-- Stack
|
||||
Deck *-- Discard
|
||||
GameState *-- GameId
|
||||
Card <|--- NumericCard
|
||||
Card <|--- ReverseCard
|
||||
Card <|--- PassCard
|
||||
Card <|--- ChangeColorCard
|
||||
Card <|--- Plus2Card
|
||||
Card <|--- Plus4Card
|
||||
|
||||
ReverseCard o-- Color
|
||||
NumericCard o-- Color
|
||||
PassCard o-- Color
|
||||
Plus2Card o-- Color
|
||||
|
||||
GameList *-- GameId
|
||||
GameList o-- "many" Player
|
||||
|
||||
@enduml
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 48 KiB |
@@ -1,84 +0,0 @@
|
||||
@startuml
|
||||
'https://plantuml.com/use-case-diagram
|
||||
|
||||
package Legend {
|
||||
usecase (Queries) #7693C4
|
||||
usecase (Projections) #AB64C9
|
||||
usecase (Events) #5FAD56
|
||||
}
|
||||
|
||||
actor User
|
||||
entity Query #7693C4
|
||||
|
||||
entity Command #5FAD56
|
||||
entity Event #5FAD56
|
||||
entity Projection #AB64C9
|
||||
|
||||
database Postgresql
|
||||
database Redis
|
||||
queue RabbitMQ
|
||||
|
||||
usecase (Web socket adapter) #5FAD56
|
||||
usecase (Command handler) #5FAD56
|
||||
usecase/ (Action) #5FAD56
|
||||
usecase (Event handler) #5FAD56
|
||||
usecase (Version builder) #5FAD56
|
||||
usecase (Event store) #5FAD56
|
||||
usecase (Event stream) #5FAD56
|
||||
usecase (Event bus) #5FAD56
|
||||
usecase/ (Reaction listener) #5FAD56
|
||||
|
||||
usecase/ (Projection builder) #AB64C9
|
||||
usecase (Projection repository) #AB64C9
|
||||
usecase (Projection bus) #AB64C9
|
||||
|
||||
usecase (Controller) #7693C4
|
||||
|
||||
User -> Query : <<create>>
|
||||
Command <- User : <<create>>
|
||||
User ---> (Controller) : Get \nprojection
|
||||
User <-- (Controller) : Returns \nprojection
|
||||
|
||||
(Controller) --------> (Projection repository) : Get \nprojection
|
||||
|
||||
User -> (Web socket adapter) : Send \ncommand
|
||||
(Web socket adapter) --> (Command handler) : Send \ncommand
|
||||
(Web socket adapter) ...> User : Send notification \n(error or success)
|
||||
|
||||
(Command handler) ..> (Web socket adapter) : Send \nnotification
|
||||
(Command handler) -> (Action) : Execute action
|
||||
(Command handler) <- (Action) : Returns \nevent builder
|
||||
(Command handler) ---> (Event handler) : Dispatch \nevent \n(send an event builder)
|
||||
(Command handler) --> Event : <<Create>>
|
||||
|
||||
(Event handler) --> (Event store) : Publish \nevent
|
||||
(Event handler) <-- (Reaction listener) : Dispatch \n new event
|
||||
(Version builder) <- (Event handler) : build next version
|
||||
note "Acquire a lock, \nget the next event version, \nand then, build the event " as EventHandlerNote
|
||||
EventHandlerNote <-- (Event handler)
|
||||
|
||||
(Event store) -left-> (Event stream)
|
||||
(Event store) ---> (Event bus) : Publish \nevent
|
||||
|
||||
(Event stream) --> Postgresql : Persist \nevent
|
||||
(Event bus) -> RabbitMQ : Publish \nevent
|
||||
(Event bus) -> RabbitMQ : Subscribe \nto event
|
||||
(Event bus) <. RabbitMQ : Emit event
|
||||
|
||||
(Reaction listener) ---> (Projection bus) : Subscribe
|
||||
(Reaction listener) <.. (Projection bus) : Emit projection
|
||||
|
||||
(Projection bus) <- (Projection repository) : Publish \nprojection
|
||||
RabbitMQ <- (Projection bus) : Publish \nprojection
|
||||
RabbitMQ <- (Projection bus) : Subscribe \nto projection
|
||||
RabbitMQ .> (Projection bus) : Emit projection
|
||||
|
||||
(Event bus) <---- (Projection repository) : Subscribe
|
||||
(Event bus) ..> (Projection repository) : Emit event
|
||||
|
||||
(Projection repository) --> Redis : Persist \nprojection
|
||||
(Projection repository) <- Redis : Get \nprojection
|
||||
(Projection repository) -> (Projection builder) : Build \nprojection
|
||||
|
||||
(Projection builder) --> Projection : <<create projection>>
|
||||
@enduml
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,2 @@
|
||||
POSTGRESQL_URL=jdbc:postgresql://postgresql/event-demo
|
||||
RABBITMQ_URL=rabbitmq
|
||||
@@ -1 +0,0 @@
|
||||
PGADMIN_DEFAULT_EMAIL=
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Cache Gradle dependencies
|
||||
FROM gradle:latest AS cache
|
||||
FROM gradle:9.6.1-jdk21-alpine AS cache
|
||||
RUN mkdir -p /home/gradle/cache_home
|
||||
ENV GRADLE_USER_HOME=/home/gradle/cache_home
|
||||
COPY build.gradle.* gradle.properties /home/gradle/app/
|
||||
@@ -7,7 +7,7 @@ WORKDIR /home/gradle/app
|
||||
RUN gradle build -i -x check
|
||||
|
||||
# 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 --chown=gradle:gradle . /home/gradle/src
|
||||
WORKDIR /home/gradle/src
|
||||
@@ -16,8 +16,8 @@ WORKDIR /home/gradle/src
|
||||
RUN gradle buildFatJar --no-daemon
|
||||
|
||||
# Stage 3: Create the Runtime Image
|
||||
FROM amazoncorretto:21 AS runtime
|
||||
FROM eclipse-temurin:21-jre-alpine AS runtime
|
||||
EXPOSE 8080
|
||||
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"]
|
||||
@@ -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-tools.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:
|
||||
- path:
|
||||
- parts/docker-compose-databases.yaml
|
||||
- parts/docker-compose-databases-expose.yaml
|
||||
- parts/docker-compose-tools.yaml
|
||||
- parts/docker-compose-test.yaml
|
||||
- parts/docker-compose-traefik.yaml
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
environment:
|
||||
POSTGRES_PASSWORD: "changeit"
|
||||
@@ -10,8 +10,8 @@ services:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- ../.env.docker
|
||||
labels:
|
||||
- "traefik.http.routers.api.rule=Host(`api.traefik.me`)"
|
||||
- "traefik.http.services.api.loadbalancer.server.port=8080"
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
services:
|
||||
redis:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
postgresql:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
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:
|
||||
image: flyway/flyway
|
||||
command: migrate
|
||||
@@ -22,10 +14,7 @@ services:
|
||||
image: postgres:18.4
|
||||
command: postgres -c 'max_connections=500'
|
||||
environment:
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgresql_password
|
||||
POSTGRES_USER: event-demo
|
||||
secrets:
|
||||
- postgresql_password
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "sh -c 'pg_isready -U event-demo'"]
|
||||
interval: 1s
|
||||
@@ -47,12 +36,6 @@ services:
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq/
|
||||
|
||||
secrets:
|
||||
postgresql_password:
|
||||
file: ../postgresql.secret
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
redisinsight_data:
|
||||
postgresql_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,32 +2,16 @@ services:
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: $PGADMIN_DEFAULT_EMAIL
|
||||
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/pgadmin_password
|
||||
PGADMIN_CONFIG_SERVER_MODE: 'False'
|
||||
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
|
||||
secrets:
|
||||
- pgadmin_password
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@event-demo.dev}
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
configs:
|
||||
- source: pgpass
|
||||
target: /pgpass
|
||||
mode: 0600
|
||||
uid: "5050"
|
||||
gid: "5050"
|
||||
- source: servers_json
|
||||
target: /pgadmin4/servers.json
|
||||
labels:
|
||||
- "traefik.http.routers.pgadmin.rule=Host(`pgadmin.postgresql.traefik.me`)"
|
||||
- "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:
|
||||
labels:
|
||||
- "traefik.http.routers.rabbitmq-management.rule=Host(`management.rabbitmq.traefik.me`)"
|
||||
@@ -35,9 +19,6 @@ services:
|
||||
- "traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672"
|
||||
|
||||
configs:
|
||||
pgpass:
|
||||
content: |
|
||||
*:*:*:event-demo:changeit
|
||||
servers_json:
|
||||
content: |
|
||||
{
|
||||
@@ -55,9 +36,5 @@ configs:
|
||||
}
|
||||
}
|
||||
|
||||
secrets:
|
||||
pgadmin_password:
|
||||
file: ../pgadmin.secret
|
||||
|
||||
volumes:
|
||||
pgadmin_data:
|
||||
@@ -1,58 +1,264 @@
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: "event_demo API"
|
||||
description: "event_demo API"
|
||||
version: "1.0.0"
|
||||
description: |
|
||||
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:
|
||||
- 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:
|
||||
"/game/{id}/card/last":
|
||||
get:
|
||||
description: get the last card played
|
||||
"/login/{username}":
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Log in and obtain a JWT
|
||||
security: []
|
||||
parameters:
|
||||
- name: username
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: password
|
||||
in: query
|
||||
description: The user's plain-text password.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: The last card
|
||||
description: Successful login
|
||||
content:
|
||||
application/json:
|
||||
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:
|
||||
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:
|
||||
Card:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SimpleCard"
|
||||
- $ref: "#/components/schemas/ReverseCard"
|
||||
- $ref: "#/components/schemas/PassCard"
|
||||
- $ref: "#/components/schemas/Plus2Card"
|
||||
- $ref: "#/components/schemas/Plus4Card"
|
||||
- $ref: "#/components/schemas/ChangeColorCard"
|
||||
SimpleCard:
|
||||
LoginResponse:
|
||||
type: object
|
||||
required: [token]
|
||||
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
|
||||
color:
|
||||
$ref: "#/components/schemas/CardColor"
|
||||
ReverseCard:
|
||||
invalidParams:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InvalidParam"
|
||||
|
||||
InvalidParam:
|
||||
type: object
|
||||
required: [name, reason]
|
||||
properties:
|
||||
color:
|
||||
$ref: "#/components/schemas/CardColor"
|
||||
PassCard:
|
||||
name:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
|
||||
PlayerId:
|
||||
type: string
|
||||
format: uuid
|
||||
|
||||
Player:
|
||||
type: object
|
||||
required: [name, userId, hand, id]
|
||||
properties:
|
||||
color:
|
||||
$ref: "#/components/schemas/CardColor"
|
||||
Plus2Card:
|
||||
name:
|
||||
type: string
|
||||
userId:
|
||||
type: string
|
||||
format: uuid
|
||||
id:
|
||||
$ref: "#/components/schemas/PlayerId"
|
||||
hand:
|
||||
description: The set of cards currently held by the player.
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Card"
|
||||
|
||||
GameList:
|
||||
type: object
|
||||
required: [aggregateId, status, players, winners]
|
||||
properties:
|
||||
color:
|
||||
$ref: "#/components/schemas/CardColor"
|
||||
Plus4Card:
|
||||
properties:
|
||||
nextColor:
|
||||
$ref: "#/components/schemas/CardColor"
|
||||
ChangeColorCard:
|
||||
properties:
|
||||
nextColor:
|
||||
$ref: "#/components/schemas/CardColor"
|
||||
aggregateId:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
$ref: "#/components/schemas/GameStatus"
|
||||
players:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Player"
|
||||
winners:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PlayerId"
|
||||
|
||||
GameStatus:
|
||||
type: string
|
||||
enum:
|
||||
- OPENING
|
||||
- IS_STARTED
|
||||
- FINISH
|
||||
- CANCELED
|
||||
|
||||
CardColor:
|
||||
type: string
|
||||
enum:
|
||||
@@ -60,3 +266,401 @@ components:
|
||||
- Red
|
||||
- Yellow
|
||||
- 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"
|
||||
|
||||
@@ -3,7 +3,6 @@ package eventDemo.configuration
|
||||
import io.ktor.server.config.ApplicationConfig
|
||||
|
||||
data class Configuration(
|
||||
val redisUrl: String,
|
||||
val jwtSecret: String,
|
||||
val postgresql: Postgresql,
|
||||
val rabbitmq: RabbitMQ,
|
||||
@@ -25,7 +24,6 @@ data class Configuration(
|
||||
val ApplicationConfig.configuration
|
||||
get() =
|
||||
Configuration(
|
||||
redisUrl = getProperty("redis.url"),
|
||||
jwtSecret = getProperty("jwt.secret"),
|
||||
postgresql =
|
||||
Configuration.Postgresql(
|
||||
|
||||
@@ -7,8 +7,6 @@ import org.koin.core.module.Module
|
||||
import org.koin.core.scope.Scope
|
||||
import org.koin.core.scope.ScopeCallback
|
||||
import org.koin.dsl.bind
|
||||
import redis.clients.jedis.JedisPooled
|
||||
import redis.clients.jedis.UnifiedJedis
|
||||
import javax.sql.DataSource
|
||||
|
||||
fun Module.configureDIDataSource(config: Configuration) {
|
||||
@@ -26,11 +24,6 @@ fun Module.configureDIDataSource(config: Configuration) {
|
||||
}
|
||||
} bind DataSource::class
|
||||
|
||||
// Redis (for Projections)
|
||||
single {
|
||||
JedisPooled(config.redisUrl)
|
||||
} bind UnifiedJedis::class
|
||||
|
||||
// RabbitMQ (for EventBus)
|
||||
factory {
|
||||
ConnectionFactory().apply {
|
||||
|
||||
+2
-10
@@ -37,16 +37,8 @@ abstract class GameEventManager(
|
||||
return this
|
||||
}
|
||||
|
||||
protected fun <G : Game> Game.isStatusOrFail(
|
||||
kClass: KClass<G>,
|
||||
message: String,
|
||||
): G {
|
||||
if (kClass.isInstance(this)) {
|
||||
return this as G
|
||||
} else {
|
||||
throw CommandException(message)
|
||||
}
|
||||
}
|
||||
protected inline fun <reified G : Game> Game.isStatusOrFail(message: String): G =
|
||||
this as? G ?: throw CommandException(message)
|
||||
|
||||
protected fun <T> retry(
|
||||
mapAttempts: Int = 5,
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ class JoinTheGameHandler(
|
||||
retry {
|
||||
command
|
||||
.getGame()
|
||||
.isStatusOrFail(GameCreated::class, "The game is started")
|
||||
.isStatusOrFail<GameCreated>("The game is started")
|
||||
.userJoinTheGame(
|
||||
userId = command.userId,
|
||||
name = user.username,
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ class PlayCardHandler(
|
||||
override fun handle(command: PlayCardCommand) {
|
||||
command
|
||||
.getGame()
|
||||
.isStatusOrFail(GameStarted::class, "The game is not started")
|
||||
.isStatusOrFail<GameStarted>("The game is not started")
|
||||
.playTheCard(
|
||||
card = command.payload.card,
|
||||
playerId = command.payload.playerId,
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ class ReadyToPlayHandler(
|
||||
override fun handle(command: ReadyToPlayCommand) {
|
||||
command
|
||||
.getGame()
|
||||
.isStatusOrFail(GameCreated::class, "The game is started")
|
||||
.isStatusOrFail<GameCreated>("The game is started")
|
||||
.setReadyPlayer(command.payload.playerId)
|
||||
.saveEvents()
|
||||
.publishEvents()
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ class TakeCartFromDrawPileHandler(
|
||||
override fun handle(command: TakeCartFromDrawPileCommand) {
|
||||
command
|
||||
.getGame()
|
||||
.isStatusOrFail(GameStarted::class, "The game is not started")
|
||||
.isStatusOrFail<GameStarted>("The game is not started")
|
||||
.playerTakeCartFromDrawPile(command.payload.playerId, 1)
|
||||
.saveEvents()
|
||||
.publishEvents()
|
||||
|
||||
@@ -12,11 +12,6 @@ jwt {
|
||||
secret = ${?JWT_SECRET}
|
||||
}
|
||||
|
||||
redis {
|
||||
url = "redis://localhost:6379"
|
||||
url = ${?REDIS_URL}
|
||||
}
|
||||
|
||||
postgresql {
|
||||
url = "jdbc:postgresql://localhost:5432/event-demo"
|
||||
url = ${?POSTGRESQL_URL}
|
||||
|
||||
@@ -7,7 +7,5 @@ object Tag {
|
||||
|
||||
object RabbitMQ : Tag()
|
||||
|
||||
object Redis : Tag()
|
||||
|
||||
object Concurrence : Tag()
|
||||
}
|
||||
|
||||
@@ -6,12 +6,18 @@ import com.tngtech.archunit.library.Architectures.layeredArchitecture
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Vérifie le respect des frontières de l'architecture hexagonale (ports & adapters).
|
||||
* Vérifie le respect des frontières de l'architecture hexagonale (ports & adapters),
|
||||
* pour chaque bounded context sous `eventDemo.contexts`.
|
||||
*
|
||||
* Convention attendue :
|
||||
* eventDemo.contexts.uno.domain
|
||||
* eventDemo.contexts.uno.application
|
||||
* eventDemo.contexts.uno.infrastructure
|
||||
* Les contexts ne sont pas listés en dur : ils sont déduits des classes réellement
|
||||
* présentes sous `eventDemo.contexts.*`, de sorte que l'ajout d'un nouveau context
|
||||
* (nouveau dossier `eventDemo.contexts.<xxx>`) soit automatiquement couvert par ce test,
|
||||
* sans modification de ce fichier.
|
||||
*
|
||||
* Convention attendue, pour un contexte donné :
|
||||
* eventDemo.contexts.<context>.domain
|
||||
* eventDemo.contexts.<context>.application
|
||||
* eventDemo.contexts.<context>.infrastructure
|
||||
*
|
||||
* Règles imposées :
|
||||
* – domain → ne dépend d'aucune autre couche (ni application, ni infrastructure)
|
||||
@@ -19,15 +25,32 @@ import org.junit.jupiter.api.Test
|
||||
* – infrastructure → ne dépend que de domain et application
|
||||
*/
|
||||
class HexagonalArchitectureTest {
|
||||
private val basePackage = "eventDemo.contexts.uno"
|
||||
private val rootPackage = "eventDemo.contexts"
|
||||
|
||||
private val classes =
|
||||
ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.importPackages(basePackage)
|
||||
.importPackages(rootPackage)
|
||||
|
||||
// Premier segment de package après "eventDemo.contexts." (ex. "auth", "game", ...),
|
||||
// recalculé à chaque exécution à partir des classes importées.
|
||||
private val contexts: Set<String> =
|
||||
classes
|
||||
.map { it.packageName }
|
||||
.filter { it.startsWith("$rootPackage.") }
|
||||
.map { it.removePrefix("$rootPackage.").substringBefore('.') }
|
||||
.toSet()
|
||||
|
||||
@Test
|
||||
fun `respecte les couches de l'architecture hexagonale`() {
|
||||
check(contexts.isNotEmpty()) {
|
||||
"Aucun context trouvé sous `$rootPackage` : le test ne vérifie rien, " +
|
||||
"vérifiez que le package racine est correct."
|
||||
}
|
||||
|
||||
contexts.forEach { context ->
|
||||
val basePackage = "$rootPackage.$context"
|
||||
|
||||
@Suppress("ktlint:standard:chain-method-continuation")
|
||||
layeredArchitecture()
|
||||
.consideringAllDependencies()
|
||||
@@ -39,4 +62,5 @@ class HexagonalArchitectureTest {
|
||||
.whereLayer("Infrastructure").mayOnlyAccessLayers("Domain", "Application")
|
||||
.check(classes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,13 @@ import eventDemo.testHelpers.createNewUser
|
||||
import eventDemo.testHelpers.testKoinApplicationWithConfig
|
||||
import io.kotest.assertions.nondeterministic.eventually
|
||||
import io.kotest.assertions.nondeterministic.until
|
||||
import io.kotest.assertions.retry
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.equals.shouldBeEqual
|
||||
import io.kotest.matchers.equals.shouldEqual
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.should
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -49,7 +51,8 @@ class GameSimulationTest :
|
||||
tags(Tag.Postgresql)
|
||||
|
||||
test("Simulation of a game") {
|
||||
withTimeout(10.seconds) {
|
||||
should {
|
||||
retry(maxRetry = 3, timeout = 20.seconds) {
|
||||
disableRandomForTest()
|
||||
val gameId = GameId()
|
||||
val user1 = createNewUser("user1")
|
||||
@@ -269,12 +272,13 @@ class GameSimulationTest :
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
context(user: User)
|
||||
private suspend inline fun <reified T : Notification> MutableList<Notification>.waitNotification(crossinline block: T.() -> Boolean): T {
|
||||
println("NOTIFICATION WAITING: ${T::class.simpleName} for user: ${user.username}")
|
||||
return eventually(3.seconds) {
|
||||
return eventually(5.seconds) {
|
||||
filterIsInstance<T>()
|
||||
.first { block(it) }
|
||||
.also { remove(it) }
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
package eventDemo.contexts.game.intrastructure.persistence.connectors
|
||||
|
||||
import eventDemo.Tag
|
||||
import eventDemo.testHelpers.testKoinApplicationWithConfig
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.equals.shouldBeEqual
|
||||
import redis.clients.jedis.UnifiedJedis
|
||||
|
||||
class RedisTest :
|
||||
FunSpec({
|
||||
tags(Tag.Redis)
|
||||
|
||||
test("test connection with jedis") {
|
||||
testKoinApplicationWithConfig {
|
||||
get<UnifiedJedis>().also {
|
||||
it.set("test", "test")
|
||||
it.get("test") shouldBeEqual "test"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@ package eventDemo.libs.bus
|
||||
|
||||
import com.rabbitmq.client.ConnectionFactory
|
||||
import eventDemo.testHelpers.spyPing
|
||||
import eventDemo.testHelpers.testKoinApplicationWithConfig
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.datatest.withData
|
||||
import io.kotest.matchers.string.shouldStartWith
|
||||
@@ -15,19 +16,13 @@ private data class ObjTest(
|
||||
class BusTest :
|
||||
FunSpec({
|
||||
context("Pub/sub") {
|
||||
val factory =
|
||||
ConnectionFactory().apply {
|
||||
host = "localhost"
|
||||
port = 5672
|
||||
username = "event-demo"
|
||||
password = "changeit"
|
||||
}
|
||||
testKoinApplicationWithConfig {
|
||||
val list: Map<String, Bus<ObjTest>> =
|
||||
mapOf(
|
||||
BusInMemory::class.java.simpleName to BusInMemory(),
|
||||
BusInRabbitMQ::class.java.simpleName to
|
||||
BusInRabbitMQ(
|
||||
factory,
|
||||
get<ConnectionFactory>(),
|
||||
"testExchange",
|
||||
{ it.value },
|
||||
{ ObjTest(it) },
|
||||
@@ -45,4 +40,5 @@ class BusTest :
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,10 +15,13 @@ import org.koin.core.module.KoinApplicationDslMarker
|
||||
import org.koin.dsl.koinApplication
|
||||
import org.koin.ktor.ext.getKoin
|
||||
|
||||
const val CONFIG_FILE_NAME = "application.conf"
|
||||
|
||||
@KoinApplicationDslMarker
|
||||
suspend fun <T> testKoinApplicationWithConfig(block: suspend Koin.() -> T): T =
|
||||
koinApplication { modules(appKoinModule(ApplicationConfig("application.conf").configuration)) }
|
||||
.koin
|
||||
koinApplication {
|
||||
modules(appKoinModule(ApplicationConfig(CONFIG_FILE_NAME).configuration))
|
||||
}.koin
|
||||
.run {
|
||||
cleanDataTest()
|
||||
configureProjectionListener()
|
||||
@@ -34,7 +37,7 @@ fun testApplicationWithConfig(
|
||||
) {
|
||||
val logger = KotlinLogging.logger {}
|
||||
testApplication {
|
||||
val conf = ApplicationConfig("application.conf")
|
||||
val conf = ApplicationConfig(CONFIG_FILE_NAME)
|
||||
environment {
|
||||
config = conf
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package eventDemo.testHelpers
|
||||
|
||||
import org.koin.core.Koin
|
||||
import redis.clients.jedis.UnifiedJedis
|
||||
import javax.sql.DataSource
|
||||
|
||||
fun DataSource.cleanEventSource() {
|
||||
@@ -17,11 +16,6 @@ fun DataSource.cleanEventSource() {
|
||||
}
|
||||
}
|
||||
|
||||
fun UnifiedJedis.cleanProjections() {
|
||||
flushAll()
|
||||
}
|
||||
|
||||
fun Koin.cleanDataTest() {
|
||||
get<DataSource>().cleanEventSource()
|
||||
get<UnifiedJedis>().cleanProjections()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user