Implement first routes & tests

install Koin
install kotest
declare first events
create EventStream
This commit is contained in:
2024-02-28 23:31:55 +01:00
parent 5434c59129
commit 43b5f27e50
21 changed files with 422 additions and 72 deletions

View File

@@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="ApplicationKt" type="KtorApplicationConfigurationType" factoryName="Ktor" nameIsGenerated="true">
<module name="event-demo.main" />
<option name="alternativeJrePath" />
<option name="alternativeJrePathEnabled" value="false" />
<option name="includeProvidedScope" value="true" />
<option name="mainClass" value="eventDemo.ApplicationKt" />
<option name="passParentEnvs" value="true" />
<option name="programParameters" value="" />
<option name="shortenCommandLine" value="NONE" />
<option name="vmParameters" value="-Dio.ktor.development=true" />
<option name="workingDirectory" value="$PROJECT_DIR$" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

24
.run/ktlintFormat.run.xml Normal file
View File

@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="ktlintFormat" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="ktlintFormat" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

24
.run/test.run.xml Normal file
View File

@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="test" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="test" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@@ -1,7 +1,12 @@
@file:Suppress("PropertyName")
@Suppress("ktlint:standard:property-naming") @Suppress("ktlint:standard:property-naming")
val ktor_version: String by project val ktor_version: String by project
val kotlin_version: String by project val kotlin_version: String by project
val logback_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 { plugins {
kotlin("jvm") version "1.9.22" kotlin("jvm") version "1.9.22"
@@ -14,7 +19,7 @@ group = "io.github.flecomte"
version = "0.0.1" version = "0.0.1"
application { application {
mainClass.set("io.github.flecomte.ApplicationKt") mainClass.set("eventDemo.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development") val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
@@ -24,6 +29,10 @@ repositories {
mavenCentral() mavenCentral()
} }
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies { dependencies {
implementation("io.ktor:ktor-server-core-jvm") implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-auth-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-host-common-jvm")
implementation("io.ktor:ktor-server-status-pages-jvm") implementation("io.ktor:ktor-server-status-pages-jvm")
implementation("io.ktor:ktor-server-netty-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("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("io.ktor:ktor-server-tests-jvm")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") 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")
} }

View File

@@ -1,4 +1,8 @@
ktor_version=2.3.8 ktor_version=2.3.8
kotlin_version=1.9.22 kotlin_version=1.9.22
logback_version=1.4.14 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 kotlin.code.style=official

View File

@@ -1,16 +1,17 @@
package io.github.flecomte package eventDemo
import io.github.flecomte.plugins.configureHTTP import eventDemo.plugins.configureHTTP
import io.github.flecomte.plugins.configureRouting import eventDemo.plugins.configureKoin
import io.github.flecomte.plugins.configureSecurity import eventDemo.plugins.configureRouting
import io.github.flecomte.plugins.configureSerialization import eventDemo.plugins.configureSecurity
import io.github.flecomte.plugins.configureSockets import eventDemo.plugins.configureSerialization
import eventDemo.plugins.configureSockets
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty import io.ktor.server.netty.Netty
fun main() { 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) .start(wait = true)
} }
@@ -20,4 +21,5 @@ fun Application.module() {
configureSockets() configureSockets()
configureHTTP() configureHTTP()
configureRouting() configureRouting()
configureKoin()
} }

View File

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

View File

@@ -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
}

View File

@@ -0,0 +1,27 @@
package eventDemo.app
import io.github.oshai.kotlinlogging.KotlinLogging
class EventStream<ID : AggregateId> {
private val logger = KotlinLogging.logger {}
private val eventBus: MutableMap<ID, MutableList<Event<ID>>> = mutableMapOf()
fun publish(event: Event<ID>) {
eventBus.getOrPut(event.aggregateId) { mutableListOf() }.add(event)
logger.atInfo {
message = "Event published"
payload = mapOf("event" to event)
}
}
fun <U : Event<ID>> read(
aggregateId: ID,
eventClass: Class<U>,
): U? {
return eventBus.get(aggregateId)?.filterIsInstance(eventClass)?.firstOrNull()
}
}
inline fun <reified U : Event<ID>, ID : AggregateId> EventStream<ID>.read(aggregateId: ID): U? {
return this.read(aggregateId, U::class.java)
}

View File

@@ -0,0 +1,10 @@
package eventDemo.app
sealed interface Event<ID : AggregateId> {
val aggregateId: ID
}
data class PlayCardEvent(
override val aggregateId: GameId,
val card: Card,
) : Event<GameId>

View File

@@ -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<EventStream<GameId>>()
post<Game.Card.PutCard> {
val card = call.receive<Card.Simple>()
eventStream.publish(PlayCardEvent(it.card.game.id, card))
call.respondNullable<Any?>(HttpStatusCode.OK, null)
}
get<Game.Card.LastCard> {
eventStream.read<PlayCardEvent, GameId>(it.card.game.id)
?.let { it1 -> call.respond<Card>(it1.card) }
?: call.response.status(HttpStatusCode.BadRequest)
}
}

View File

@@ -1,7 +1,8 @@
package io.github.flecomte.plugins package eventDemo.plugins
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.plugins.cors.routing.CORS import io.ktor.server.plugins.cors.routing.CORS
@@ -10,6 +11,7 @@ fun Application.configureHTTP() {
install(CORS) { install(CORS) {
allowMethod(HttpMethod.Options) allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put) allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Delete) allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch) allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.Authorization)
@@ -17,3 +19,18 @@ fun Application.configureHTTP() {
anyHost() // @TODO: Don't do this in production if possible. Try to limit it. 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<InvalidParam>,
) {
val statusCode: Int = statusCode.value
data class InvalidParam(
val name: String,
val reason: String,
)
}

View File

@@ -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<GameId>>(::EventStream)
}

View File

@@ -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.http.HttpStatusCode
import io.ktor.resources.Resource
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.plugins.autohead.AutoHeadResponse import io.ktor.server.plugins.autohead.AutoHeadResponse
import io.ktor.server.plugins.statuspages.StatusPages import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.resources.Resources 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.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable import io.ktor.util.converters.DataConversion.Configuration
fun Application.configureRouting() { fun Application.configureRouting() {
install(AutoHeadResponse) install(AutoHeadResponse)
install(Resources) install(Resources)
install(StatusPages) { install(StatusPages) {
exception<BadRequestException> { call, cause ->
call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest)
}
exception<Throwable> { call, cause -> exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError) call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
} }
} }
routing { routing {
get("/") { putCard()
call.respondText("Hello World!")
}
get<Articles> { article ->
// Get all articles ...
call.respond("List of articles sorted starting from ${article.sort}")
}
} }
} }
@Serializable private typealias ConverterDeclaration = Configuration.() -> Unit
@Resource("/articles")
class Articles(val sort: String? = "new")

View File

@@ -1,4 +1,4 @@
package io.github.flecomte.plugins package eventDemo.plugins
import com.auth0.jwt.JWT import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.algorithms.Algorithm

View File

@@ -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<GameId> {
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<UUID> {
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)
}

View File

@@ -1,4 +1,4 @@
package io.github.flecomte.plugins package eventDemo.plugins
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install

View File

@@ -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"))
}
}
}

View File

@@ -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 }
}
},
)
}
}
}

View File

@@ -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<GameId>>()
eventStream.publish(
PlayCardEvent(id, card),
)
}
client.get("/game/$id/card/last").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(card, this.call.body<Card>())
}
}
}
})

View File

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