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,25 @@
package eventDemo
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, watchPaths = listOf("classes"))
.start(wait = true)
}
fun Application.module() {
configureSecurity()
configureSerialization()
configureSockets()
configureHTTP()
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

@@ -0,0 +1,36 @@
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
fun Application.configureHTTP() {
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization)
allowHeader("MyCustomHeader")
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

@@ -0,0 +1,31 @@
package eventDemo.plugins
import eventDemo.app.actions.putCard
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
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.response.respondText
import io.ktor.server.routing.routing
import io.ktor.util.converters.DataConversion.Configuration
fun Application.configureRouting() {
install(AutoHeadResponse)
install(Resources)
install(StatusPages) {
exception<BadRequestException> { call, cause ->
call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest)
}
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
}
routing {
putCard()
}
}
private typealias ConverterDeclaration = Configuration.() -> Unit

View File

@@ -0,0 +1,74 @@
package eventDemo.plugins
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.auth.UserIdPrincipal
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.authentication
import io.ktor.server.auth.basic
import io.ktor.server.auth.form
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.auth.principal
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
fun Application.configureSecurity() {
// Please read the jwt property from the config file if you are using EngineMain
val jwtAudience = "jwt-audience"
val jwtDomain = "https://jwt-provider-domain/"
val jwtRealm = "ktor sample app"
val jwtSecret = "secret"
authentication {
jwt {
realm = jwtRealm
verifier(
JWT
.require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience)
.withIssuer(jwtDomain)
.build(),
)
validate { credential ->
if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null
}
}
}
authentication {
basic(name = "myauth1") {
realm = "Ktor Server"
validate { credentials ->
if (credentials.name == credentials.password) {
UserIdPrincipal(credentials.name)
} else {
null
}
}
}
form(name = "myauth2") {
userParamName = "user"
passwordParamName = "password"
challenge {
//
}
}
}
routing {
authenticate("myauth1") {
get("/protected/route/basic") {
val principal = call.principal<UserIdPrincipal>()!!
call.respondText("Hello ${principal.name}")
}
}
authenticate("myauth2") {
get("/protected/route/form") {
val principal = call.principal<UserIdPrincipal>()!!
call.respondText("Hello ${principal.name}")
}
}
}
}

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

@@ -0,0 +1,36 @@
package eventDemo.plugins
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.pingPeriod
import io.ktor.server.websocket.timeout
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import java.time.Duration
fun Application.configureSockets() {
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(15)
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket("/ws") { // websocketSession
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
outgoing.send(Frame.Text("YOU SAID: $text"))
if (text.equals("bye", ignoreCase = true)) {
close(CloseReason(CloseReason.Codes.NORMAL, "Client said BYE"))
}
}
}
}
}
}