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()) - } - } -}