Ktor for Mobile Developers: Fear the server no more!

1b4a6c2e7bafdb5c945865dac35a9eb3?s=47 Dan Kim
December 05, 2019

Ktor for Mobile Developers: Fear the server no more!

Building mobile apps is what we love to do, but there’s always one nagging problem — writing server side components to support our apps can be surprisingly complex and difficult. There’s a lot of overhead and a bunch of unfamiliar languages, frameworks, and styles of programming that we're not used to.

But fear the server no more, here comes Ktor!

In this talk we'll walk through a real world example of how Ktor (and your existing knowledge of Kotlin) makes building server side components for your app a breeze. We'll start with a basic introduction of Ktor and its components, but we’ll quickly get to building something real. Using a well known service/API, we’ll walk through everything you normally need to get up and running: authentication, getting data, posting data, and deployment.

By the end of the talk you'll have a good idea of how to hook up your own server side components to pretty much any API out there. Let’s conquer the server with Ktor!

1b4a6c2e7bafdb5c945865dac35a9eb3?s=128

Dan Kim

December 05, 2019
Tweet

Transcript

  1. KTOR FOR MOBILE DEVELOPERS DAN KIM @dankim

  2. Server side code? Me?

  3. None
  4. It's been...a while

  5. The paradox of choice

  6. "I wish there was a way to do simple server

    stuff with what I already know..."
  7. Something familiar

  8. Something that maintains my focus

  9. Something lightweight and simple

  10. Enter Ktor!

  11. So here's what we're gonna do...

  12. A Google "Dashboard"

  13. RESTful API

  14. Bonus: WebApp!

  15. Let's build the RESTful API!

  16. Things we'll be using

  17. Ktor OAuth2 Retrofit Moshi Google APIs Google Cloud

  18. Things we're not going to get into

  19. Quickstart Database Sessions Testing

  20. Assumptions

  21. You've setup an application on Google Cloud

  22. You've authenticated to Google's OAuth2

  23. Ktor's basic structure

  24. ApplicationEngine

  25. CIO Jetty Netty Tomcat

  26. Netty CIO Jetty Tomcat

  27. fun main(args: Array<String>) { io.ktor.server.netty.EngineMain.main(args) }

  28. ApplicationEnvironment

  29. application.conf

  30. ktor { deployment { port = 8080 watch = [Dev/kotlin/ktor/src]

    }1 application { modules = [com.dankim.ApplicationKt.module] }1 }1
  31. ktor { deployment { port = 8080 watch = [Dev/kotlin/ktor/src]

    }1 application { modules = [com.dankim.ApplicationKt.module] }1 }1
  32. fun main(args: Array<String>) { io.ktor.server.netty.EngineMain.main(args) } fun Application.module() { }

  33. Application

  34. class Application(val environment: ApplicationEnvironment) : ApplicationCallPipeline(), CoroutineScope { ... }1

  35. class Application(val environment: ApplicationEnvironment) : ApplicationCallPipeline(), CoroutineScope { ... }1

  36. ApplicationCallPipeline

  37. ApplicationCallPipeline

  38. Phase 1 Phase 2 Phase 3 Phase 5 Phase 4

  39. Interceptor Interceptor Interceptor Interceptor Interceptor Interceptor Interceptor Interceptor

  40. 1. Setup 2. Monitoring 3. Features 4. Call 5. Fallback

  41. Features Call

  42. Features

  43. None
  44. DefaultHeaders Feature

  45. fun Application.module() { }1

  46. curl -v http://0.0.0.0:8080/ > < HTTP/1.1 404 Not Found <

    Content-Length: 0
  47. fun Application.module() { }1

  48. fun Application.module() { install(DefaultHeaders) }1

  49. override fun install(...): DefaultHeaders { ... val feature = DefaultHeaders(config)

    pipeline.intercept(ApplicationCallPipeline.Features) { feature.intercept(call) } ... }
  50. curl -v http://0.0.0.0:8080/ < HTTP/1.1 404 Not Found < Date:

    Wed, 13 Nov 2019 02:08:13 GMT < Server: ktor-server-core/1.2.4 ktor-server-core/ 1.2.4 < Content-Length: 0
  51. Routes

  52. fun Application.module() { install(DefaultHeaders) }1

  53. fun Application.module() { install(DefaultHeaders) install(Routing) }1

  54. override fun install(...): Routing { ... val routing = Routing(pipeline).apply(configure)

    pipeline.intercept(ApplicationCallPipeline.Call) { routing.interceptor(this) } ... }
  55. Features Call

  56. fun Application.module() { install(DefaultHeaders) install(Routing) }

  57. curl -v http://0.0.0.0:8080/ < HTTP/1.1 404 Not Found < Date:

    Wed, 13 Nov 2019 02:08:13 GMT < Server: ktor-server-core/1.2.4 ktor-server-core/ 1.2.4 < Content-Length: 0
  58. fun Application.module() { install(DefaultHeaders) install(Routing) }11

  59. fun Application.module() { install(DefaultHeaders) install(Routing) { get("/dashboard.json") { call.respondText {

    "Hello KotlinConf!" }11 }11 }11 }11
  60. curl -v http://0.0.0.0:8080/ < HTTP/1.1 200 OK < Date: Tue,

    03 Dec 2019 15:33:23 GMT < Server: ktor-server-core/1.2.4 ktor-server-core/ 1.2.4 ... Hello KotlinConf!
  61. Let's get some data

  62. GET Gmail Data

  63. @JsonClass(generateAdapter = true)11 data class GmailThreads( @Json(name = "threads") val

    threads: List<GmailThread> )111
  64. @JsonClass(generateAdapter = true)11 data class GmailThreads( @Json(name = "threads") val

    threads: List<GmailThread> )111 @JsonClass(generateAdapter = true) data class GmailThread( @Json(name = "id") val id: String, @Json(name = "snippet") val snippet: String )1
  65. @GET("gmail/v1/users/{user}/threads") fun getGmailThreads(@Path("user") user: String): Call<GmailThreads>

  66. private fun getGmailData(): GmailThreads? { val response = getGoogleServicesInstance() .getGmailThreads("dan@basecamp.com")

    .execute() return when { response.isSuccessful -> response.body() else -> { handleError(response.errorBody().toString()) null }1 }1 }1
  67. private fun getGmailData(): GmailThreads? { val response = getGoogleServicesInstance() .getGmailThreads("dan@basecamp.com")

    .execute() return when { response.isSuccessful -> response.body() else -> { handleError(response.errorBody().toString()) null }1 }1 }1
  68. private fun getGmailData(): GmailThreads? { val response = getGoogleServicesInstance() .getGmailThreads("dan@basecamp.com")

    .execute() return when { response.isSuccessful -> response.body() else -> { handleError(response.errorBody().toString()) null }1 }1 }1
  69. fun Application.module() { install(DefaultHeaders) install(Routing) { }11 }1

  70. fun Application.module() { install(DefaultHeaders) install(Routing) { get("/dashboard.json") { val gmailData

    = getGmailData() call.respondText { gmailData.toJson() }1111 }111 }11 }1
  71. fun Application.module() { install(DefaultHeaders) install(Routing) { get("/dashboard.json") { val gmailData

    = getGmailData() call.respondText { gmailData.toJson() }1 }1 }1 }1
  72. fun Application.module() { install(DefaultHeaders) install(Routing) { get("/dashboard.json") { val gmailData

    = getGmailData() call.respondText { gmailData.toJson() }1 }1 }1 }1
  73. curl -H "Authorization: Bearer <bearer_token>" http://0.0.0.0:8080/dashboard.json

  74. curl -H "Authorization: Bearer <bearer_token>" http://0.0.0.0:8080/dashboard.json

  75. GET Gtasks Data

  76. @JsonClass(generateAdapter = true)111 data class GoogleTasks( @Json(name = "items") val

    tasks: List<GoogleTask>? = null )111
  77. @JsonClass(generateAdapter = true)111 data class GoogleTasks( @Json(name = "items") val

    tasks: List<GoogleTask>? = null )111 @JsonClass(generateAdapter = true)11 data class GoogleTask( @Json(name = "id") val id: String? = null, @Json(name = "title") val title: String? = null, @Json(name = "status") val status: String? = null )11
  78. @JsonClass(generateAdapter = true)111 data class GoogleTasks( @Json(name = "items") val

    tasks: List<GoogleTask>? = null )111 @JsonClass(generateAdapter = true)11 data class GoogleTask( @Json(name = "id") val id: String? = null, @Json(name = "title") val title: String? = null, @Json(name = "status") val status: String? = null )11 @JsonClass(generateAdapter = true)1 data class MyGoogleDashboard( @Json(name = "tasks") val tasks: List<GoogleTask>, @Json(name = "emails") val emails: List<GmailThread> )1
  79. @GET("tasks/v1/lists/{listId}/tasks") fun getTasks(@Path("listId") listId: String): Call<GoogleTasks>

  80. private fun getTaskList(listId: String): GoogleTasks? { val response = getGoogleServicesInstance().getTasks(listId).execute()

    return if (response.isSuccessful) { response.body() } else { handleError(response.errorBody().toString()) null }1 }1
  81. private fun getTaskList(listId: String): GoogleTasks? { val response = getGoogleServicesInstance().getTasks(listId).execute()

    return if (response.isSuccessful) { response.body() } else { handleError(response.errorBody().toString()) null }1 }1
  82. private fun getTaskList(listId: String): GoogleTasks? { val response = getGoogleServicesInstance().getTasks(listId).execute()

    return if (response.isSuccessful) { response.body() } else { handleError(response.errorBody().toString()) null }1 }1
  83. get("/dashboard.json") { val gmailData = getGmailData() val taskList = getTaskList("<listId>")

    val myGoogleDashboard = MyGoogleDashboard( tasks = taskList?.tasks, emails = gmailData?.threads ) call.respondText { myGoogleDashboard.toJson() }1 }1
  84. get("/dashboard.json") { val gmailData = getGmailData() val taskList = getTaskList("<listId>")

    val myGoogleDashboard = MyGoogleDashboard( tasks = taskList?.tasks, emails = gmailData?.threads ) call.respondText { myGoogleDashboard.toJson() }1 }1
  85. get("/dashboard.json") { val gmailData = getGmailData() val taskList = getTaskList("<listId>")

    val myGoogleDashboard = MyGoogleDashboard( tasks = taskList?.tasks, emails = gmailData?.threads ) call.respondText { myGoogleDashboard.toJson() }1 }1
  86. get("/dashboard.json") { val gmailData = getGmailData() val taskList = getTaskList("<listId>")

    val myGoogleDashboard = MyGoogleDashboard( tasks = taskList?.tasks, emails = gmailData?.threads ) call.respondText { myGoogleDashboard.toJson() }1 }1
  87. curl -H "Authorization: Bearer <bearer_token>" http://0.0.0.0:8080/dashboard.json

  88. curl -H "Authorization: Bearer <bearer_token>" http://0.0.0.0:8080/dashboard.json

  89. Gtasks POST

  90. @POST("tasks/v1/lists/{listId}/tasks") fun createTask(@Path("listId") listId: String, @Body task: GoogleTask): Call<ResponseBody>

  91. post("/tasks/new") { val postParams = call.receiveParameters() val taskName = postParams.entries()

    .find { it.key == "taskName" }?.value?.first() val task = GoogleTask(title = taskName) val response = getGoogleServicesInstance().createTask("<id>",task).execute() return if (response.isSuccessful) { call.respond(HttpStatusCode.OK) } else { handleError(response.errorBody().toString()) null }1 }1
  92. post("/tasks/new") { val postParams = call.receiveParameters() val taskName = postParams.entries()

    .find { it.key == "taskName" }?.value?.first() val task = GoogleTask(title = taskName) val response = getGoogleServicesInstance().createTask("<id>",task).execute() return if (response.isSuccessful) { call.respond(HttpStatusCode.OK) } else { handleError(response.errorBody().toString()) null }1 }1
  93. post("/tasks/new") { val postParams = call.receiveParameters() val taskName = postParams.entries()

    .find { it.key == "taskName" }?.value?.first() val task = GoogleTask(title = taskName) val response = getGoogleServicesInstance().createTask("<id>",task).execute() return if (response.isSuccessful) { call.respond(HttpStatusCode.OK) } else { handleError(response.errorBody().toString()) null }1 }1
  94. post("/tasks/new") { val postParams = call.receiveParameters() val taskName = postParams.entries()

    .find { it.key == "taskName" }?.value?.first() val task = GoogleTask(title = taskName) val response = getGoogleServicesInstance().createTask("<listId>",task).execute() return if (response.isSuccessful) { call.respond(HttpStatusCode.OK) } else { handleError(response.errorBody().toString()) null }1 }1
  95. post("/tasks/new") { val postParams = call.receiveParameters() val taskName = postParams.entries()

    .find { it.key == "taskName" }?.value?.first() val task = GoogleTask(title = taskName) val response = getGoogleServicesInstance().createTask("<listId>",task).execute() return if (response.isSuccessful) { call.respond(HttpStatusCode.OK) } else { handleError(response.errorBody().toString()) null }1 }1
  96. curl -v -F 'taskName=This is super cool!' -H "Accept: application/json"

    http://0.0.0.0:8080/tasks/new < HTTP/1.1 200 OK < Date: Tue, 19 Nov 2019 02:58:41 GMT < Server: ktor-server-core/1.2.4 ktor-server-core/1.2.4 < Content-Length: 0
  97. None
  98. Webapp

  99. Authentication

  100. OAuth 2

  101. None
  102. val googleOauthProvider = OAuthServerSettings.OAuth2ServerSettings( name = "google", authorizeUrl = "https://accounts.google.com/o/oauth2/auth",

    accessTokenUrl = "https://www.googleapis.com/oauth2/v3/token", requestMethod = HttpMethod.Post, clientId = "<yourClientId>", clientSecret = "<yourClientSecret>", defaultScopes = listOf( "profile", "email", "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/tasks" ) )
  103. fun Application.module() { // ... install(Authentication) { oauth("google-oauth") { client

    = HttpClient(Apache) providerLookup = { googleOauthProvider } urlProvider = { redirectUrl("/login") } } } }
  104. fun Application.module() { ... install(Routing) { authenticate("google-oauth") { route("/login") {

    handle { val principal = call.authentication.principal<OAuthAccessTokenResponse.OAuth2>() ?: error("No principal") saveOAuthToken(principal.accessToken) call.respondRedirect("/") }11111 }1111 }1111 }11 }11
  105. fun Application.module() { ... install(Routing) { authenticate("google-oauth") { route("/login") {

    handle { val principal = call.authentication.principal<OAuthAccessTokenResponse.OAuth2>() ?: error("No principal") saveOAuthToken(principal.accessToken) call.respondRedirect("/") }11111 }1111 }111 }11 }11
  106. fun Application.module() { ... install(Routing) { authenticate("google-oauth") { route("/login") {

    handle { val principal = call.authentication.principal<OAuthAccessTokenResponse.OAuth2>() ?: error("No principal") saveOAuthToken(principal.accessToken) call.respondRedirect("/") }1 }1 }1 }1 }1
  107. Web: showing the data

  108. get("/dashboard") { val gmailData = getGmailData() val taskList = getTaskList("<listId>")

    ... }
  109. call.respondHtml { body { p { h1 { +"Dashboard" }

    h2 { +"Tasks:" } ul { taskList?.tasks?.forEach { li { +it.title!! } }1 }1 h2 { +"Emails:" } ul { gmailData?.threads?.forEach { li { +StringEscapeUtils.unescapeHtml4(it.snippet) } }1 }1 }1 }1 }1
  110. body { p { h1 { +"Dashboard" } h2 {

    +"Tasks:" } ul { taskList?.tasks?.forEach { li { +it.title } }11 }11 h2 { +"Emails:" } ul { gmailData?.threads?.forEach { li { +StringEscapeUtils.unescapeHtml4(it.snippet) } }1 }1 }1 }1
  111. body { p { h1 { +"Dashboard" } h2 {

    +"Tasks:" } ul {11 taskList?.tasks?.forEach { li { +it.title } }11 }11 h2 { +"Emails:" } ul {1 gmailData?.threads?.forEach { li { +StringEscapeUtils.unescapeHtml4(it.snippet) } }1 }1 }1 }1
  112. body { p { h1 { +"Dashboard" } h2 {

    +"Tasks:" } ul {11 taskList?.tasks?.forEach { li { +it.title } }11 }11 h2 { +"Emails:" } ul {1 gmailData?.threads?.forEach { li { +StringEscapeUtils.unescapeHtml4(it.snippet) } }1 }1 }1 }1
  113. None
  114. Web: posting data

  115. get("/tasks/new") { call.respondHtml { body { form("/tasks/new", encType = FormEncType.applicationXWwwFormUrlEncoded,

    method = FormMethod.post ) { acceptCharset = "utf-8" p { label { +"Task: " } textInput { name = "taskName" } }1 p { submitInput { value = "send" } }1 }1 }1 }1 }1
  116. get("/tasks/new") { call.respondHtml { body { form("/tasks/new", encType = FormEncType.applicationXWwwFormUrlEncoded,

    method = FormMethod.post ) { acceptCharset = "utf-8" p { label { +"Task: " } textInput { name = "taskName" } }111111 p { submitInput { value = "send" } }11111 }1111 }111 }111 }1
  117. get("/tasks/new") { call.respondHtml { body { form("/tasks/new", encType = FormEncType.applicationXWwwFormUrlEncoded,

    method = FormMethod.post ) { acceptCharset = "utf-8" p { label { +"Task: " } textInput { name = "taskName" } }111111 p { submitInput { value = "send" } }11111 }1111 }111 }11 }1
  118. None
  119. post("/tasks/new") { val postParams = call.receiveParameters() val taskName = postParams.entries()

    .find { it.key == "taskName" }?.value?.first() val acceptHeader = call.request.headers.get("Accept") val task = GoogleTask(title = taskName) val response = getGoogleServicesInstance().createTask( "<listId>", task).execute() when (acceptHeader) { "application/json" -> call.respond(HttpStatusCode.OK) else -> call.respondRedirect("/dashboard") }1 }1
  120. post("/tasks/new") { val postParams = call.receiveParameters() val taskName = postParams.entries()

    .find { it.key == "taskName" }?.value?.first() val acceptHeader = call.request.headers.get("Accept") val task = GoogleTask(title = taskName) val response = getGoogleServicesInstance().createTask( "<listId>", task).execute() when (acceptHeader) { "application/json" -> call.respond(HttpStatusCode.OK) else -> call.respondRedirect("/dashboard") }1 }1
  121. post("/tasks/new") { val postParams = call.receiveParameters() val taskName = postParams.entries()

    .find { it.key == "taskName" }?.value?.first() val acceptHeader = call.request.headers.get("Accept") val task = GoogleTask(title = taskName) val response = getGoogleServicesInstance().createTask( "<listId>", task).execute() when (acceptHeader) { "application/json" -> call.respond(HttpStatusCode.OK) else -> call.respondRedirect("/dashboard") }1 }1
  122. None
  123. Deployment

  124. Google Cloud

  125. https://cloud.google.com/ community/tutorials/kotlin-ktor-app- engine-java8

  126. XML config files

  127. ../src/main/webapp/WEB-INF/appengine-web.xml

  128. ../src/main/webapp/WEB-INF/appengine-web.xml ../src/main/webapp/WEB-INF/web.xml

  129. Cloud projects cost money!

  130. None
  131. gcloud projects delete [PROJECT_ID]

  132. Wrap it up!

  133. THANK YOU AND REMEMBER TO VOTE Dan Kim @dankim