diff --git a/.run/ApplicationKt.run.xml b/.run/ApplicationKt.run.xml
new file mode 100644
index 0000000..f7e71b5
--- /dev/null
+++ b/.run/ApplicationKt.run.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/ktlintFormat.run.xml b/.run/ktlintFormat.run.xml
new file mode 100644
index 0000000..b58cecb
--- /dev/null
+++ b/.run/ktlintFormat.run.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+ false
+
+
+
\ No newline at end of file
diff --git a/.run/test.run.xml b/.run/test.run.xml
new file mode 100644
index 0000000..6904dab
--- /dev/null
+++ b/.run/test.run.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+ false
+
+
+
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index ee66b1a..77559a2 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,7 +1,12 @@
+@file:Suppress("PropertyName")
+
@Suppress("ktlint:standard:property-naming")
val ktor_version: String by project
val kotlin_version: String by project
val logback_version: String by project
+val koin_version: String by project
+val kotlin_logging_version: String by project
+val kotest_version: String by project
plugins {
kotlin("jvm") version "1.9.22"
@@ -14,7 +19,7 @@ group = "io.github.flecomte"
version = "0.0.1"
application {
- mainClass.set("io.github.flecomte.ApplicationKt")
+ mainClass.set("eventDemo.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
@@ -24,6 +29,10 @@ repositories {
mavenCentral()
}
+tasks.withType().configureEach {
+ useJUnitPlatform()
+}
+
dependencies {
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-auth-jvm")
@@ -37,7 +46,14 @@ dependencies {
implementation("io.ktor:ktor-server-host-common-jvm")
implementation("io.ktor:ktor-server-status-pages-jvm")
implementation("io.ktor:ktor-server-netty-jvm")
+ implementation("io.ktor:ktor-server-data-conversion")
+ implementation("io.ktor:ktor-client-content-negotiation")
implementation("ch.qos.logback:logback-classic:$logback_version")
+ implementation("io.insert-koin:koin-ktor:$koin_version")
+ implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
+ implementation("io.github.oshai:kotlin-logging-jvm:$kotlin_logging_version")
testImplementation("io.ktor:ktor-server-tests-jvm")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
+ testImplementation("io.ktor:ktor-server-test-host-jvm:2.3.8")
+ testImplementation("io.kotest:kotest-runner-junit5:$kotest_version")
}
diff --git a/gradle.properties b/gradle.properties
index 2919844..e722697 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,4 +1,8 @@
ktor_version=2.3.8
kotlin_version=1.9.22
logback_version=1.4.14
+koin_version=3.5.3
+kotlin_logging_version=5.1.0
+kotest_version=5.8.0
+
kotlin.code.style=official
diff --git a/src/main/kotlin/io/github/flecomte/Application.kt b/src/main/kotlin/eventDemo/Application.kt
similarity index 52%
rename from src/main/kotlin/io/github/flecomte/Application.kt
rename to src/main/kotlin/eventDemo/Application.kt
index 5d89149..1529de2 100644
--- a/src/main/kotlin/io/github/flecomte/Application.kt
+++ b/src/main/kotlin/eventDemo/Application.kt
@@ -1,16 +1,17 @@
-package io.github.flecomte
+package eventDemo
-import io.github.flecomte.plugins.configureHTTP
-import io.github.flecomte.plugins.configureRouting
-import io.github.flecomte.plugins.configureSecurity
-import io.github.flecomte.plugins.configureSerialization
-import io.github.flecomte.plugins.configureSockets
+import eventDemo.plugins.configureHTTP
+import eventDemo.plugins.configureKoin
+import eventDemo.plugins.configureRouting
+import eventDemo.plugins.configureSecurity
+import eventDemo.plugins.configureSerialization
+import eventDemo.plugins.configureSockets
import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
fun main() {
- embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
+ embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module, watchPaths = listOf("classes"))
.start(wait = true)
}
@@ -20,4 +21,5 @@ fun Application.module() {
configureSockets()
configureHTTP()
configureRouting()
+ configureKoin()
}
diff --git a/src/main/kotlin/eventDemo/app/AggregateId.kt b/src/main/kotlin/eventDemo/app/AggregateId.kt
new file mode 100644
index 0000000..db61976
--- /dev/null
+++ b/src/main/kotlin/eventDemo/app/AggregateId.kt
@@ -0,0 +1,14 @@
+package eventDemo.app
+
+import java.util.UUID
+
+sealed interface AggregateId {
+ val id: UUID
+}
+
+@JvmInline
+value class GameId(override val id: UUID = UUID.randomUUID()) : AggregateId {
+ constructor(id: String) : this(UUID.fromString(id))
+
+ override fun toString(): String = id.toString()
+}
diff --git a/src/main/kotlin/eventDemo/app/Card.kt b/src/main/kotlin/eventDemo/app/Card.kt
new file mode 100644
index 0000000..a703cf8
--- /dev/null
+++ b/src/main/kotlin/eventDemo/app/Card.kt
@@ -0,0 +1,30 @@
+package eventDemo.app
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface Card {
+ @Serializable
+ enum class Color {
+ Blue,
+ Red,
+ Yellow,
+ Green,
+ }
+
+ @Serializable
+ @SerialName("Simple")
+ data class Simple(
+ val number: Int,
+ val color: Color,
+ ) : Card
+
+ sealed interface Special : Card
+
+ @Serializable
+ @SerialName("Reverse")
+ data class ReverseCard(
+ val color: Color,
+ ) : Special
+}
diff --git a/src/main/kotlin/eventDemo/app/EventStream.kt b/src/main/kotlin/eventDemo/app/EventStream.kt
new file mode 100644
index 0000000..e71670b
--- /dev/null
+++ b/src/main/kotlin/eventDemo/app/EventStream.kt
@@ -0,0 +1,27 @@
+package eventDemo.app
+
+import io.github.oshai.kotlinlogging.KotlinLogging
+
+class EventStream {
+ private val logger = KotlinLogging.logger {}
+ private val eventBus: MutableMap>> = mutableMapOf()
+
+ fun publish(event: Event) {
+ eventBus.getOrPut(event.aggregateId) { mutableListOf() }.add(event)
+ logger.atInfo {
+ message = "Event published"
+ payload = mapOf("event" to event)
+ }
+ }
+
+ fun > read(
+ aggregateId: ID,
+ eventClass: Class,
+ ): U? {
+ return eventBus.get(aggregateId)?.filterIsInstance(eventClass)?.firstOrNull()
+ }
+}
+
+inline fun , ID : AggregateId> EventStream.read(aggregateId: ID): U? {
+ return this.read(aggregateId, U::class.java)
+}
diff --git a/src/main/kotlin/eventDemo/app/Events.kt b/src/main/kotlin/eventDemo/app/Events.kt
new file mode 100644
index 0000000..57c09c4
--- /dev/null
+++ b/src/main/kotlin/eventDemo/app/Events.kt
@@ -0,0 +1,10 @@
+package eventDemo.app
+
+sealed interface Event {
+ val aggregateId: ID
+}
+
+data class PlayCardEvent(
+ override val aggregateId: GameId,
+ val card: Card,
+) : Event
diff --git a/src/main/kotlin/eventDemo/app/actions/PutCard.kt b/src/main/kotlin/eventDemo/app/actions/PutCard.kt
new file mode 100644
index 0000000..9f307c3
--- /dev/null
+++ b/src/main/kotlin/eventDemo/app/actions/PutCard.kt
@@ -0,0 +1,54 @@
+package eventDemo.app.actions
+
+import eventDemo.app.Card
+import eventDemo.app.EventStream
+import eventDemo.app.GameId
+import eventDemo.app.PlayCardEvent
+import eventDemo.app.read
+import eventDemo.plugins.GameIdSerializer
+import io.ktor.http.HttpStatusCode
+import io.ktor.resources.Resource
+import io.ktor.server.application.call
+import io.ktor.server.request.receive
+import io.ktor.server.resources.get
+import io.ktor.server.resources.post
+import io.ktor.server.response.respond
+import io.ktor.server.response.respondNullable
+import io.ktor.server.routing.Routing
+import kotlinx.serialization.Serializable
+import org.koin.ktor.ext.inject
+
+@Serializable
+@Resource("/game/{id}")
+class Game(
+ @Serializable(with = GameIdSerializer::class)
+ val id: GameId,
+) {
+ @Serializable
+ @Resource("card")
+ class Card(val game: Game) {
+ @Serializable
+ @Resource("")
+ class PutCard(val card: Card)
+
+ @Serializable
+ @Resource("last")
+ class LastCard(val card: Card)
+ }
+}
+
+fun Routing.putCard() {
+ val eventStream by inject>()
+
+ post {
+ val card = call.receive()
+ eventStream.publish(PlayCardEvent(it.card.game.id, card))
+ call.respondNullable(HttpStatusCode.OK, null)
+ }
+
+ get {
+ eventStream.read(it.card.game.id)
+ ?.let { it1 -> call.respond(it1.card) }
+ ?: call.response.status(HttpStatusCode.BadRequest)
+ }
+}
diff --git a/src/main/kotlin/io/github/flecomte/plugins/HTTP.kt b/src/main/kotlin/eventDemo/plugins/HTTP.kt
similarity index 55%
rename from src/main/kotlin/io/github/flecomte/plugins/HTTP.kt
rename to src/main/kotlin/eventDemo/plugins/HTTP.kt
index 7c1d89e..8cd3bd9 100644
--- a/src/main/kotlin/io/github/flecomte/plugins/HTTP.kt
+++ b/src/main/kotlin/eventDemo/plugins/HTTP.kt
@@ -1,7 +1,8 @@
-package io.github.flecomte.plugins
+package eventDemo.plugins
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.cors.routing.CORS
@@ -10,6 +11,7 @@ fun Application.configureHTTP() {
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
+ allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization)
@@ -17,3 +19,18 @@ fun Application.configureHTTP() {
anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
}
}
+
+class BadRequestException(val httpError: HttpErrorBadRequest) : Exception()
+
+class HttpErrorBadRequest(
+ statusCode: HttpStatusCode,
+ val title: String = statusCode.description,
+ val invalidParams: List,
+) {
+ val statusCode: Int = statusCode.value
+
+ data class InvalidParam(
+ val name: String,
+ val reason: String,
+ )
+}
diff --git a/src/main/kotlin/eventDemo/plugins/Koin.kt b/src/main/kotlin/eventDemo/plugins/Koin.kt
new file mode 100644
index 0000000..2d946bc
--- /dev/null
+++ b/src/main/kotlin/eventDemo/plugins/Koin.kt
@@ -0,0 +1,22 @@
+package eventDemo.plugins
+
+import eventDemo.app.EventStream
+import eventDemo.app.GameId
+import io.ktor.server.application.Application
+import io.ktor.server.application.install
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+import org.koin.ktor.plugin.Koin
+import org.koin.logger.slf4jLogger
+
+fun Application.configureKoin() {
+ install(Koin) {
+ slf4jLogger()
+ modules(appModule)
+ }
+}
+
+val appModule =
+ module {
+ singleOf>(::EventStream)
+ }
diff --git a/src/main/kotlin/io/github/flecomte/plugins/Routing.kt b/src/main/kotlin/eventDemo/plugins/Routing.kt
similarity index 54%
rename from src/main/kotlin/io/github/flecomte/plugins/Routing.kt
rename to src/main/kotlin/eventDemo/plugins/Routing.kt
index 179939e..3c3be37 100644
--- a/src/main/kotlin/io/github/flecomte/plugins/Routing.kt
+++ b/src/main/kotlin/eventDemo/plugins/Routing.kt
@@ -1,39 +1,31 @@
-package io.github.flecomte.plugins
+package eventDemo.plugins
+import eventDemo.app.actions.putCard
import io.ktor.http.HttpStatusCode
-import io.ktor.resources.Resource
import io.ktor.server.application.Application
-import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.plugins.autohead.AutoHeadResponse
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.resources.Resources
-import io.ktor.server.resources.get
-import io.ktor.server.response.respond
import io.ktor.server.response.respondText
-import io.ktor.server.routing.get
import io.ktor.server.routing.routing
-import kotlinx.serialization.Serializable
+import io.ktor.util.converters.DataConversion.Configuration
fun Application.configureRouting() {
install(AutoHeadResponse)
install(Resources)
install(StatusPages) {
+ exception { call, cause ->
+ call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest)
+ }
exception { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
}
+
routing {
- get("/") {
- call.respondText("Hello World!")
- }
- get { article ->
- // Get all articles ...
- call.respond("List of articles sorted starting from ${article.sort}")
- }
+ putCard()
}
}
-@Serializable
-@Resource("/articles")
-class Articles(val sort: String? = "new")
+private typealias ConverterDeclaration = Configuration.() -> Unit
diff --git a/src/main/kotlin/io/github/flecomte/plugins/Security.kt b/src/main/kotlin/eventDemo/plugins/Security.kt
similarity index 98%
rename from src/main/kotlin/io/github/flecomte/plugins/Security.kt
rename to src/main/kotlin/eventDemo/plugins/Security.kt
index 405b5b1..75c754e 100644
--- a/src/main/kotlin/io/github/flecomte/plugins/Security.kt
+++ b/src/main/kotlin/eventDemo/plugins/Security.kt
@@ -1,4 +1,4 @@
-package io.github.flecomte.plugins
+package eventDemo.plugins
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
diff --git a/src/main/kotlin/eventDemo/plugins/Serialization.kt b/src/main/kotlin/eventDemo/plugins/Serialization.kt
new file mode 100644
index 0000000..d946bb4
--- /dev/null
+++ b/src/main/kotlin/eventDemo/plugins/Serialization.kt
@@ -0,0 +1,55 @@
+package eventDemo.plugins
+
+import eventDemo.app.GameId
+import io.ktor.serialization.kotlinx.json.json
+import io.ktor.server.application.Application
+import io.ktor.server.application.install
+import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.modules.SerializersModule
+import java.util.UUID
+
+fun Application.configureSerialization() {
+ install(ContentNegotiation) {
+ json(
+ Json {
+ serializersModule =
+ SerializersModule {
+ contextual(UUID::class) { UUIDSerializer }
+ }
+ },
+ )
+ }
+}
+
+object GameIdSerializer : KSerializer {
+ override fun deserialize(decoder: Decoder): GameId = GameId(UUID.fromString(decoder.decodeString()))
+
+ override fun serialize(
+ encoder: Encoder,
+ value: GameId,
+ ) {
+ encoder.encodeString(value.id.toString())
+ }
+
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("GameId", PrimitiveKind.STRING)
+}
+
+object UUIDSerializer : KSerializer {
+ override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
+
+ override fun serialize(
+ encoder: Encoder,
+ value: UUID,
+ ) {
+ encoder.encodeString(value.toString())
+ }
+
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
+}
diff --git a/src/main/kotlin/io/github/flecomte/plugins/Sockets.kt b/src/main/kotlin/eventDemo/plugins/Sockets.kt
similarity index 97%
rename from src/main/kotlin/io/github/flecomte/plugins/Sockets.kt
rename to src/main/kotlin/eventDemo/plugins/Sockets.kt
index d8e77ab..bea64a0 100644
--- a/src/main/kotlin/io/github/flecomte/plugins/Sockets.kt
+++ b/src/main/kotlin/eventDemo/plugins/Sockets.kt
@@ -1,4 +1,4 @@
-package io.github.flecomte.plugins
+package eventDemo.plugins
import io.ktor.server.application.Application
import io.ktor.server.application.install
diff --git a/src/main/kotlin/io/github/flecomte/plugins/Serialization.kt b/src/main/kotlin/io/github/flecomte/plugins/Serialization.kt
deleted file mode 100644
index 4c37b9d..0000000
--- a/src/main/kotlin/io/github/flecomte/plugins/Serialization.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package io.github.flecomte.plugins
-
-import io.ktor.serialization.kotlinx.json.json
-import io.ktor.server.application.Application
-import io.ktor.server.application.call
-import io.ktor.server.application.install
-import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
-import io.ktor.server.response.respond
-import io.ktor.server.routing.get
-import io.ktor.server.routing.routing
-
-fun Application.configureSerialization() {
- install(ContentNegotiation) {
- json()
- }
- routing {
- get("/json/kotlinx-serialization") {
- call.respond(mapOf("hello" to "world"))
- }
- }
-}
diff --git a/src/test/kotlin/eventDemo/app/actions/Client.kt b/src/test/kotlin/eventDemo/app/actions/Client.kt
new file mode 100644
index 0000000..d2195a1
--- /dev/null
+++ b/src/test/kotlin/eventDemo/app/actions/Client.kt
@@ -0,0 +1,25 @@
+package eventDemo.app.actions
+
+import eventDemo.plugins.UUIDSerializer
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.serialization.kotlinx.json.json
+import io.ktor.server.testing.ApplicationTestBuilder
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.modules.SerializersModule
+import java.util.UUID
+
+fun ApplicationTestBuilder.httpClient(): HttpClient {
+ return createClient {
+ install(ContentNegotiation) {
+ json(
+ Json {
+ serializersModule =
+ SerializersModule {
+ contextual(UUID::class) { UUIDSerializer }
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/src/test/kotlin/eventDemo/app/actions/PutCardTest.kt b/src/test/kotlin/eventDemo/app/actions/PutCardTest.kt
new file mode 100644
index 0000000..1540482
--- /dev/null
+++ b/src/test/kotlin/eventDemo/app/actions/PutCardTest.kt
@@ -0,0 +1,61 @@
+package eventDemo.app.actions
+
+import eventDemo.app.Card
+import eventDemo.app.EventStream
+import eventDemo.app.GameId
+import eventDemo.app.PlayCardEvent
+import eventDemo.module
+import io.kotest.core.spec.style.FunSpec
+import io.ktor.client.call.body
+import io.ktor.client.request.accept
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType.Application.Json
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.testing.testApplication
+import org.koin.core.context.stopKoin
+import org.koin.ktor.ext.inject
+import kotlin.test.assertEquals
+
+class PutCardTest : FunSpec({
+ test("/game/{id}/card") {
+ testApplication {
+ val client = httpClient()
+ application {
+ stopKoin()
+ module()
+ }
+ val id = GameId().toString()
+ client.post("/game/$id/card") {
+ contentType(Json)
+ accept(Json)
+ setBody(Card.Simple(1, Card.Color.Blue))
+ }.apply {
+ assertEquals(status, HttpStatusCode.OK)
+ }
+ }
+ }
+
+ test("/game/{id}/card/last") {
+ testApplication {
+ val client = httpClient()
+ val id = GameId()
+ val card = Card.Simple(1, Card.Color.Blue)
+ application {
+ stopKoin()
+ module()
+ val eventStream by inject>()
+ eventStream.publish(
+ PlayCardEvent(id, card),
+ )
+ }
+
+ client.get("/game/$id/card/last").apply {
+ assertEquals(HttpStatusCode.OK, status)
+ assertEquals(card, this.call.body())
+ }
+ }
+ }
+})
diff --git a/src/test/kotlin/io/github/flecomte/ApplicationTest.kt b/src/test/kotlin/io/github/flecomte/ApplicationTest.kt
deleted file mode 100644
index 4120beb..0000000
--- a/src/test/kotlin/io/github/flecomte/ApplicationTest.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package io.github.flecomte
-
-import io.github.flecomte.plugins.configureRouting
-import io.ktor.client.request.get
-import io.ktor.client.statement.bodyAsText
-import io.ktor.http.HttpStatusCode
-import io.ktor.server.testing.testApplication
-import kotlin.test.Test
-import kotlin.test.assertEquals
-
-class ApplicationTest {
- @Test
- fun testRoot() =
- testApplication {
- application {
- configureRouting()
- }
- client.get("/").apply {
- assertEquals(HttpStatusCode.OK, status)
- assertEquals("Hello World!", bodyAsText())
- }
- }
-}