Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Ktor Workshop

Ktor Workshop

Anton Arhipov

May 22, 2024
Tweet

More Decks by Anton Arhipov

Other Decks in Technology

Transcript

  1. Part 1. Introduction & overview Part 2. Persistence with Exposed

    Part 3. Non-functional requirements Part 4. Extending Ktor Hands-On Kotlin Web Development with Ktor
  2. Part 1. Introduction & overview Part 2. Persistence with Exposed

    Part 3. Non-functional requirements Part 4. Extending Ktor Hands-On Kotlin Web Development with Ktor Demonstration kitchensink
  3. For each part, we will: Rules of the game: hands-on

    1. Provide you with a starter project 2. Give you some time to add functionality 3. Provide a sample solution and walk you through the code
  4. https://github.com/antonarhipov/ktor-workshop Setup % git branch * main branch01 branch02 branch03

    branch04 branch05 branch06 branch07 branch08 Initial state, generated application First tests - empty First tests - implementation CRUD implementation Adding structure and DI Database access with Exposed - basics Database access with Exposed - adding relations Database access with Exposed - adding entities Integration testing with Testcontainers
  5. import com.example.plugins.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* fun main()

    { embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application :: module) .start(wait = true) } fun Application.module() { configureRouting() }
  6. import com.example.plugins.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* fun main()

    { embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application :: module) .start(wait = true) } fun Application.module() { routing { get("/") { call.respondText("Hello World!") } } }
  7. import com.example.plugins.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* fun main()

    { embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application :: module) .start(wait = true) } fun Application.module() { routing { get("/") { call.respondText("Hello World!") } } } Start an embedded server with Netty engine on port 8080
  8. import com.example.plugins.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* fun main()

    { embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application :: module) .start(wait = true) } fun Application.module() { routing { get("/") { call.respondText("Hello World!") } } } Adding routes
  9. import com.example.plugins.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* fun main()

    { embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application :: module) .start(wait = true) } fun Application.module() { routing { get("/") { call.respondText("Hello World!") } } } Request mapping
  10. import com.example.plugins.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* fun main()

    { embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application :: module) .start(wait = true) } fun Application.module() { routing { get("/") { call.respondText("Hello World!") } } } Write response
  11. Database Persistence Service Routing Model DAO Model DTO HTTP JSON

    Libraries, DI, plugins, patterns, best practices, etc Grey area:
  12. Database Persistence Service Routing Model DAO Model DTO HTTP JSON

    Coroutines Libraries, DI, plugins, patterns, best practices, etc Grey area:
  13. TODO: more intro? No "magic". No plugin starts automatically Use

    the install() function to con fi gure plugins
  14. Hands-on time! Use the project generator to create new project.

    Add Routing, Content Negotiation, and kotlinx.serialization plugins. Run it!!! What do you see? Warmup
  15. Hands-on time! Use the project generator to create new project.

    Add Routing, Content Negotiation, and kotlinx.serialization plugins. Run it!!! What do you see? Warmup Hint: if you select kotlinx.serialization at the plugins stage of the wizard, the other two will be selected automatically
  16. Hands-on time! Use the project generator to create new project.

    Add Routing, Content Negotiation, and kotlinx.serialization plugins. * Run it!!! What do you see? * - Add more plugins if you like, of course!
  17. Hands-on time! Use the project generator to create new project.

    Add Routing, Content Negotiation, and kotlinx.serialization plugins. Run it!!! What do you see? get("/") { call.respondText("Hello World!") } get("/json/kotlinx-serialization") { call.respond(mapOf("hello" to "world")) }
  18. Hands-on time! Use the project generator to create new project.

    Add Routing, Content Negotiation, and kotlinx.serialization plugins. Run it!!! What do you see? https: // github.com/antonarhipov/ktor-workshop % git branch * main branch01 branch02 branch03
  19. Hands-on time! Use the project generator to create new project.

    Add Routing, Content Negotiation, and kotlinx.serialization plugins. Run it!!! What do you see? https: // github.com/antonarhipov/ktor-workshop % git branch * main branch01 branch02 branch03 This is our starting point
  20. Hands-on time! Implement endpoints for CRUD operations for the User

    data class. @Serializable data class User( val userId: Int, val userType: UserType, val displayName: String, val link: String, val aboutMe: String? = null ) enum class UserType { REGISTERED, MODERATOR, }
  21. Hands-on time! Implement endpoints for CRUD operations for the User

    data class. @Serializable data class User( val userId: Int, val userType: UserType, val displayName: String, val link: String, val aboutMe: String? = null ) enum class UserType { REGISTERED, MODERATOR, } Tests
  22. import kotlin.test.* class ApplicationTest { @Test fun testRoot() = testApplication

    { application { routing { get("/") { call.respondText("Hello World!") } } } client.get("/").apply { assertEquals(HttpStatusCode.OK, status) assertEquals("Hello World!", bodyAsText()) } } } Creates a TestApplication
  23. import kotlin.test.* class ApplicationTest { @Test fun testRoot() = testApplication

    { application { routing { get("/") { call.respondText("Hello World!") } } } client.get("/").apply { assertEquals(HttpStatusCode.OK, status) assertEquals("Hello World!", bodyAsText()) } } } De fi ne functionality to be tested
  24. import kotlin.test.* class ApplicationTest { @Test fun testRoot() = testApplication

    { application { routing { get("/") { call.respondText("Hello World!") } } } client.get("/").apply { assertEquals(HttpStatusCode.OK, status) assertEquals("Hello World!", bodyAsText()) } } } Use client instance interaction with the TestApplication
  25. import kotlin.test.* class ApplicationTest { @Test fun testRoot() = testApplication

    { application { routing { get("/") { call.respondText("Hello World!") } } } client.get("/").apply { assertEquals(HttpStatusCode.OK, status) assertEquals("Hello World!", bodyAsText()) } } }
  26. import kotlin.test.* class ApplicationTest { @Test fun testRoot() = testApplication

    { application { routing { get("/") { call.respondText("Hello World!") } } } client.get("/").apply { assertEquals(HttpStatusCode.OK, status) assertEquals("Hello World!", bodyAsText()) } } } It creates an application instance for each test. What if we want to reuse the application instance for several tests?
  27. class ApplicationTest { val testApp = TestApplication { application {

    ... } } val client = testApp.createClient {} @Test fun testRoot() = runBlocking<Unit> { val response = client.get("/") assertEquals(HttpStatusCode.OK, response.status) assertEquals("Hello World!", response.bodyAsText()) } }
  28. class ApplicationTest { val testApp = TestApplication { application {

    ... } } val client = testApp.createClient {} @Test fun testRoot() = runBlocking<Unit> { val response = client.get("/") assertEquals(HttpStatusCode.OK, response.status) assertEquals("Hello World!", response.bodyAsText()) } } Alternatively, create TestApplication instance
  29. class ApplicationTest { val testApp = TestApplication { application {

    ... } } val client = testApp.createClient {} @Test fun testRoot() = runBlocking<Unit> { val response = client.get("/") assertEquals(HttpStatusCode.OK, response.status) assertEquals("Hello World!", response.bodyAsText()) } } Con fi gure HttpClient for the TestApplication
  30. class ApplicationTest { val testApp = TestApplication { application {

    ... } } val client = testApp.createClient {} @Test fun testRoot() = runBlocking<Unit> { val response = client.get("/") assertEquals(HttpStatusCode.OK, response.status) assertEquals("Hello World!", response.bodyAsText()) } } Need to use runBlocking to run in a coroutine scope
  31. class ApplicationTest { val testApp = TestApplication { application {

    ... } } val client = testApp.createClient { install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json() } } @Test fun testRoot() = runBlocking<Unit> { . .. ContentNegotiation needs to be con fi gured for the HttpClient
  32. class ApplicationTest { val testApp = TestApplication { application {

    ... } } val client = testApp.createClient { install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json() } } @Test fun testRoot() = runBlocking<Unit> { . .. ContentNegotiation needs to be con fi gured for the HttpClient
  33. HttpClient val response: HttpResponse = client.get("/data") assertEquals(HttpStatusCode.OK, response.status) assertEquals( ...

    , response.bodyAsText()) private val testApp = TestApplication { .. . } private val client = testApp.createClient { ... } Make request, check response https://ktor.io/docs/client-requests.html
  34. HttpClient private val testApp = TestApplication { .. . }

    private val client = testApp.createClient { ... } val data = Json.decodeFromString<List<User >> (bodyAsText()) assertEquals( ... , data.size) Deserialize response body into objects val response: HttpResponse = client.get("/data") assertEquals(HttpStatusCode.OK, response.status) assertEquals( ... , response.bodyAsText()) Make request, check response https://ktor.io/docs/client-requests.html
  35. HttpClient private val testApp = TestApplication { .. . }

    private val client = testApp.createClient { ... } val data = Json.decodeFromString<List<User >> (bodyAsText()) assertEquals( ... , data.size) Deserialize response body into objects val response: HttpResponse = client.get("/data") assertEquals(HttpStatusCode.OK, response.status) assertEquals( ... , response.bodyAsText()) Make request, check response val response = client.post("/data") { contentType(ContentType.Application.Json) setBody( ... ) } POST with HttpRequestBuilder https://ktor.io/docs/client-requests.html
  36. Hands-on time! Implement endpoints for CRUD operations @Serializable data class

    User( val userId: Int, val userType: UserType, val displayName: String, val link: String, val aboutMe: String? = null ) enum class UserType { REGISTERED, MODERATOR, }
  37. Hands-on time! Implement endpoints for CRUD operations @Test fun `get

    all data`(): Unit = runBlocking { ... } @Test fun `post data instance`(): Unit = runBlocking { ... } @Test fun `put data instance`(): Unit = runBlocking { ... } @Test fun `delete data instance`(): Unit = runBlocking { ... } @Serializable data class User( val userId: Int, val userType: UserType, val displayName: String, val link: String, val aboutMe: String? = null ) enum class UserType { REGISTERED, MODERATOR, } Implement these tests git branch * branch01
  38. Sending responses get("/") { call.respondText("Hello World!") } post("/") { val

    user = call.receive<User>() ... call.respond(status = HttpStatusCode.Created, message = "Data added successfully") } Respond with HTTP code Add response content
  39. Receiving requests get("/") { call.respondText("Hello World!") } post("/") { val

    user = call.receive<User>() ... call.respond(status = HttpStatusCode.Created, message = "Data added successfully") } Deserialize request content
  40. Receiving requests get("/") { call.respondText("Hello World!") } post("/") { val

    user = call.receive<User>() ... call.respond(status = HttpStatusCode.Created, message = "Data added successfully") } get("/{userId}") { val userId = call.parameters["userId"] ... } Access request parameters
  41. Hands-on time! Implement endpoints for CRUD operations for the User

    data class. @Serializable data class User( val userId: Int, val userType: UserType, val displayName: String, val link: String, val aboutMe: String? = null ) enum class UserType { REGISTERED, MODERATOR, } routing { route("/users") { get { ... } post { .. . } get("/{userId}") { . .. } put("/{userId}") { . .. } delete("/{userId}") { ... } } } Hint
  42. implementation("io.insert-koin:koin-ktor:$koin_version") implementation("io.insert-koin:koin-logger-slf4j:$koin_version") Adding Koin build.gradle.kts In Ktor app: install(Koin) {

    slf4jLogger() modules(usersDataModule) } val usersDataModule = module { single<IRepository> { RepositoryImpl() } } fun Application.configureRouting() { val repository by inject<IRepository>()
  43. Hands-on time! Add Koin (or KodeinDI) dependencies to the project:

    implementation("io.insert-koin:koin-ktor:$koin_version") implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
  44. Hands-on time! Abstract the repository implementation with an interface (UserRepository)

    Inject the repository implementation (UserRepositoryImpl) using Koin Don't forget to adjust the tests code! Add Koin (or KodeinDI) dependencies to the project: implementation("io.insert-koin:koin-ktor:$koin_version") implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
  45. Hands-on time! Abstract the repository implementation with an interface (UserRepository)

    Inject the repository implementation (UserRepositoryImpl) using Koin Don't forget to adjust the tests code! class UsersRepositoryImpl : UsersRepository { override fun findAll(): List<User> { TODO("Not yet implemented") } ... git branch * branch04 Add Koin (or KodeinDI) dependencies to the project: implementation("io.insert-koin:koin-ktor:$koin_version") implementation("io.insert-koin:koin-logger-slf4j:$koin_version")
  46. Adding Exposed: dependencies build.gradle.kts val exposed_version = "0.50.1" implementation("org.jetbrains.exposed:exposed-core:$exposed_version") implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")

    implementation("org.jetbrains.exposed:exposed-dao:$exposed_version") implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposed_version")
  47. Using Exposed: connecting to DB val database = Database.connect( url

    = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", user = "root", driver = "org.h2.Driver", password = "" )
  48. val database = Database.connect( HikariDataSource(HikariConfig().apply { jdbcUrl = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" username

    = "root" driverClassName = "org.h2.Driver" password = "" }) ) Using Exposed: connecting to DB
  49. val database = Database.connect( HikariDataSource(HikariConfig().apply { jdbcUrl = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" username

    = "root" driverClassName = "org.h2.Driver" password = "" }) ) Using Exposed: connecting to DB transaction(database) { SchemaUtils.createMissingTablesAndColumns(Datum) }
  50. val database = Database.connect( HikariDataSource(HikariConfig().apply { if (embedded) { username

    = "root" password = "" driverClassName = "org.h2.Driver" jdbcUrl = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" } else { username = "test" password = "test" driverClassName = "org.postgresql.Driver" jdbcUrl = "jdbc:postgresql: // localhost:5432/test" } } )) Using Exposed: connecting to DB Check branch05, Database.kt
  51. @Serializable data class Data(val id: Int, val text: String) object

    Datum : Table("data") { val id = integer("id").autoIncrement() val text = varchar("text", 255) }
  52. @Serializable data class Data(val id: Int, val text: String) object

    Datum : Table("data") { val id = integer("id").autoIncrement() val text = varchar("text", 255) } Datum.selectAll().map { Data(it[Datum.id], it[Datum.text]) } Select and map
  53. @Serializable data class Data(val id: Int, val text: String) object

    Datum : Table("data") { val id = integer("id").autoIncrement() val text = varchar("text", 255) } Datum.selectAll().map { Data(it[Datum.id], it[Datum.text]) } Datum.selectAll().where { Datum.id eq id }.singleOrNull() ? . let { Data(it[Datum.id], it[Datum.text]) } Select and map
  54. @Serializable data class Data(val id: Int, val text: String) object

    Datum : Table("data") { val id = integer("id").autoIncrement() val text = varchar("text", 255) } Datum.selectAll().map { Data(it[Datum.id], it[Datum.text]) } Datum.selectAll().where { Datum.id eq id }.singleOrNull() ? . let { Data(it[Datum.id], it[Datum.text]) } Datum.insert { it[Datum.id] = data.id it[Datum.text] = data.text } Datum.update({ Datum.id eq data.id }) { it[Datum.text] = data.text } update / insert
  55. Hands-on time! 1. Add Exposed dependencies to the project val

    exposed_version = "0.50.1" implementation("org.jetbrains.exposed:exposed-core:$exposed_version") implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version") implementation("org.jetbrains.exposed:exposed-dao:$exposed_version") implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposed_version") https://github.com/JetBrains/exposed
  56. Hands-on time! 2. De fi ne table objects for the

    data classes 1. Add Exposed dependencies to the project val exposed_version = "0.50.1" implementation("org.jetbrains.exposed:exposed-core:$exposed_version") implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version") implementation("org.jetbrains.exposed:exposed-dao:$exposed_version") implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposed_version") https://github.com/JetBrains/exposed
  57. Hands-on time! 2. De fi ne table objects for the

    data classes 3. Implement UserRepository using Exposed with the real database 1. Add Exposed dependencies to the project val exposed_version = "0.50.1" implementation("org.jetbrains.exposed:exposed-core:$exposed_version") implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version") implementation("org.jetbrains.exposed:exposed-dao:$exposed_version") implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposed_version") https://github.com/JetBrains/exposed
  58. Hands-on time! 2. De fi ne table objects for the

    data classes 3. Implement UserRepository using Exposed with the real database 1. Add Exposed dependencies to the project 4. Integrate the new implementation into Ktor application using DI val exposed_version = "0.50.1" implementation("org.jetbrains.exposed:exposed-core:$exposed_version") implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version") implementation("org.jetbrains.exposed:exposed-dao:$exposed_version") implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposed_version") https://github.com/JetBrains/exposed
  59. object Datum : Table("data_table") { val id = integer("id").autoIncrement() val

    text = varchar("text", 255) override val primaryKey = PrimaryKey(id) } object Items : Table("items_table") { val id = integer("id").autoIncrement() val name = varchar("name", 255) val datum_ref = reference("datum_ref", Datum.id) override val primaryKey = PrimaryKey(id) }
  60. object Datum : Table("data_table") { val id = integer("id").autoIncrement() val

    text = varchar("text", 255) override val primaryKey = PrimaryKey(id) } object Items : Table("items_table") { val id = integer("id").autoIncrement() val name = varchar("name", 255) val datum_ref = reference("datum_ref", Datum.id) override val primaryKey = PrimaryKey(id) }
  61. val dataId = Datum.insert { it[Datum.text] = data.text } get

    Datum.id Items.insert { it[Items.name] = item.name it[Items.datum_ref] = dataId }
  62. val dataId = Datum.insert { it[Datum.text] = data.text } get

    Datum.id Items.insert { it[Items.name] = item.name it[Items.datum_ref] = dataId } val item = Item("Some item") val data = Data("Some data")
  63. Hands-on time! Update the UserRepository implementation to handle the relation

    object ContentTable : Table("content") { ... val author = reference("author_id", UserTable.id) Update data classes and application code
  64. object Datum : Table("data_table") { val id = integer("id").autoIncrement() val

    text = varchar("text", 255) override val primaryKey = PrimaryKey(id) } object Items : Table("items_table") { val id = integer("id").autoIncrement() val name = varchar("name", 255) val datum_ref = reference("datum_ref", Datum.id) override val primaryKey = PrimaryKey(id) }
  65. object Datum : LongIdTable("data_table") { val id = integer("id").autoIncrement() val

    text = varchar("text", 255) override val primaryKey = PrimaryKey(id) } object Items : Table("items_table") { val id = integer("id").autoIncrement() val name = varchar("name", 255) val datum_ref = reference("datum_ref", Datum.id) override val primaryKey = PrimaryKey(id) }
  66. object Datum : LongIdTable("data_table") { val id = integer("id").autoIncrement() val

    text = varchar("text", 255) override val primaryKey = PrimaryKey(id) } object Items : IdTable<Int>("items_table") { override val id = integer("id").autoIncrement().entityId() val name = varchar("name", 255) val datum_ref = reference("datum_ref", Datum.id) override val primaryKey = PrimaryKey(id) }
  67. class DataEntity(id: EntityID<Long>) : LongEntity(id) { var text by DatumEntityTable.text

    companion object : LongEntityClass<DataEntity>(DatumEntityTable) } Defining entities
  68. class DataEntity(id: EntityID<Long>) : LongEntity(id) { var text by DatumEntityTable.text

    companion object : LongEntityClass<DataEntity>(DatumEntityTable) } class ItemsEntity(id: EntityID<Int>) : IntEntity(id) { var name by ItemsEntityTable.name companion object : IntEntityClass<ItemsEntity>(ItemsEntityTable) } Defining entities
  69. class DataEntity(id: EntityID<Long>) : LongEntity(id) { var text by DatumEntityTable.text

    companion object : LongEntityClass<DataEntity>(DatumEntityTable) } class ItemsEntity(id: EntityID<Int>) : IntEntity(id) { var name by ItemsEntityTable.name var data_ref by DataEntity referencedOn ItemsEntityTable.datum_ref companion object : IntEntityClass<ItemsEntity>(ItemsEntityTable) } Defining entities
  70. val date = DataEntity.new { text = "Some text" }

    ItemsEntity.new { name = "Some item" data_ref = date } Operations with entities
  71. val date = DataEntity.new { text = "Some text" }

    ItemsEntity.new { name = "Some item" data_ref = date } Operations with entities DataEntity.find { DatumEntityTable.text like "A%" }.toList()
  72. class DataEntity(id: EntityID<Long>) : LongEntity(id) { var text by DatumEntityTable.text

    companion object : LongEntityClass<DataEntity>(DatumEntityTable) } class ItemsEntity(id: EntityID<Int>) : IntEntity(id) { var name by ItemsEntityTable.name var data_ref by DataEntity referencedOn ItemsEntityTable.datum_ref companion object : IntEntityClass<ItemsEntity>(ItemsEntityTable) } Defining entities
  73. class DataEntity(id: EntityID<Long>) : LongEntity(id) { var text by DatumEntityTable.text

    val items by ItemsEntity referrersOn ItemsEntityTable.datum_ref companion object : LongEntityClass<DataEntity>(DatumEntityTable) } class ItemsEntity(id: EntityID<Int>) : IntEntity(id) { var name by ItemsEntityTable.name var data_ref by DataEntity referencedOn ItemsEntityTable.datum_ref companion object : IntEntityClass<ItemsEntity>(ItemsEntityTable) } Defining entities
  74. DataEntity.find { DatumEntityTable.text like "A%" } .singleOrNull() ?. let {

    data -> DataInfo( text = data.text, items = data.items.map { ItemInfo(it.name) } ) } Operations with entities
  75. DataEntity.find { DatumEntityTable.text like "A%" } .singleOrNull() ?. let {

    data -> DataInfo( text = data.text, items = data.items.map { ItemInfo(it.name) } ) } Operations with entities val selectedData = Datum.selectAll() .where { Datum.text like "A%" } .singleOrNull() ?. let { Data( text = it[Datum.text], items = Items.selectAll().where { Items.datum_ref eq it[Datum.id] }.map { Item(it[Items.name]) } ) } DAO vs DSL
  76. More features & plugins status pages request validation static content

    type safe routing default headers, CORS CallLogging, CallId, MDC Logging & monitoring Docker image GraalVM native image