Implement first routes & tests
install Koin install kotest declare first events create EventStream
This commit is contained in:
25
src/main/kotlin/eventDemo/Application.kt
Normal file
25
src/main/kotlin/eventDemo/Application.kt
Normal 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()
|
||||
}
|
||||
14
src/main/kotlin/eventDemo/app/AggregateId.kt
Normal file
14
src/main/kotlin/eventDemo/app/AggregateId.kt
Normal 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()
|
||||
}
|
||||
30
src/main/kotlin/eventDemo/app/Card.kt
Normal file
30
src/main/kotlin/eventDemo/app/Card.kt
Normal 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
|
||||
}
|
||||
27
src/main/kotlin/eventDemo/app/EventStream.kt
Normal file
27
src/main/kotlin/eventDemo/app/EventStream.kt
Normal 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)
|
||||
}
|
||||
10
src/main/kotlin/eventDemo/app/Events.kt
Normal file
10
src/main/kotlin/eventDemo/app/Events.kt
Normal 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>
|
||||
54
src/main/kotlin/eventDemo/app/actions/PutCard.kt
Normal file
54
src/main/kotlin/eventDemo/app/actions/PutCard.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
36
src/main/kotlin/eventDemo/plugins/HTTP.kt
Normal file
36
src/main/kotlin/eventDemo/plugins/HTTP.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
22
src/main/kotlin/eventDemo/plugins/Koin.kt
Normal file
22
src/main/kotlin/eventDemo/plugins/Koin.kt
Normal 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)
|
||||
}
|
||||
31
src/main/kotlin/eventDemo/plugins/Routing.kt
Normal file
31
src/main/kotlin/eventDemo/plugins/Routing.kt
Normal 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
|
||||
74
src/main/kotlin/eventDemo/plugins/Security.kt
Normal file
74
src/main/kotlin/eventDemo/plugins/Security.kt
Normal 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/main/kotlin/eventDemo/plugins/Serialization.kt
Normal file
55
src/main/kotlin/eventDemo/plugins/Serialization.kt
Normal 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)
|
||||
}
|
||||
36
src/main/kotlin/eventDemo/plugins/Sockets.kt
Normal file
36
src/main/kotlin/eventDemo/plugins/Sockets.kt
Normal 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user