Kotlinでktorを使ってRESTを書いてみた

# Introduction

Kotlin勉強シリーズ。今回はktorの続編。

前回までのKotlinシリーズはこちら。

* [KotlinとGradleとSpekとIntelliJIdeaで環境構築](https://qiita.com/dynamitecoolguy/items/e106948b59ec2e32f291)
* [KotlinでReactorのハンズオン書いてみた](https://qiita.com/dynamitecoolguy/items/f8d61edb4587db0773df)
* [KotlinでProtocol Buffersを使ってみた](https://qiita.com/dynamitecoolguy/items/619d8b22ab257c180976)
* [Kotlinでlettuceを使ってみた](https://qiita.com/dynamitecoolguy/items/015e90cc61edb6699be3)
* [Kotlinでktorをちょろっと使ってみた](https://qiita.com/dynamitecoolguy/items/ae370a7b3b2c4dcd0796)

今回は、簡単なTo-DoリストのRESTな感じのアプリケーションを作成しました。
内容は、[黒ベコ本](https://honto.jp/netstore/pd-book_28653920.html)にあるものを参考にしました。


## バージョン

本記事作成に用いたOS、言語、ミドルウェア等のバージョンは以下の通りです。
なお、JUnitを5.3.1にするとSpekがうまく動作しませんでした。

|種類|名前|バージョン|
|---|---|---|
|OS|Ubuntu|18.04(Bionic Beaver)|
|開発環境|Intellij IDEA|ULTIMATE 2018.2|
|開発言語|Kotlin|1.2.71|
|JVM|OpenJDK|10.0.2|
|ビルド|Gradle|4.10.2|
|テスト環境1|JUnit|5.2|
|テスト環境2|Spek|1.2.1|
|依存ライブラリ|Ktor|0.9.5|
|依存ライブラリ|jackson|2.9.6|
|依存ライブラリ|logback|1.2.3|


# 環境設定

## Gradle各種設定

ビルドに使用したGradle用の設定です。

### gradle.properties

[環境構築の記事](https://qiita.com/dynamitecoolguy/items/e106948b59ec2e32f291#gradleproperties)の際に作成したものと同じです。　

```gradle.properties
org.gradle.jvmargs=-Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStorePassword=changeit -Dorg.gradle.daemon=false
```

### settings.gradle

```settings.gradle
pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()

        // Kotlin
        maven {
            url { 'https://dl.bintray.com/kotlin/kotlin-dev' }
        }
    }
}

rootProject.name = 'todoserver1'
```

### build.gradle

Ktorを使用するためのgradleです。
gradle runでアプリを実行するためのApplicationプラグインと、その際のクラス名としてio.ktor.server.netty.DevelopmentEngineを指定しています。
また、ktorでは、Kotlinのexperimentalなcoroutine機能を使用しているので、それを有効にしています(dependencyとkotlin.experimental.coroutines設定)。


```
buildscript {
    ext.versions = [
            'kotlin': '1.2.71',
            'kotlin_coroutines': '0.26.1',
            'junit': '5.2.0',
            'junit_platform': '1.2.0',
            'spek': '1.2.1',
            'ktor': '0.9.5',
            'jackson': '2.9.6',
            'logback': '1.2.3'
    ]
}

plugins {
    id 'org.jetbrains.kotlin.jvm' version '1.2.71'

    // Application
    id 'application'
}

group 'jp.spock.grandala'
version '1.0-SNAPSHOT'

repositories {
    mavenCentral()

    // Spek
    maven {
        url { 'https://dl.bintray.com/spekframework/spek' }
    }

    // Ktor
    maven {
        url { 'https://dl.bintray.com/kotlin/ktor' }
    }
    maven {
        url { 'https://repo.spring.io/plugins-release' }
    }
}

dependencies {
    // Kotlin
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"

    // JUnit5
    testImplementation "org.jetbrains.kotlin:kotlin-test-junit5:${versions.kotlin}"
    testImplementation "org.junit.jupiter:junit-jupiter-api:${versions.junit}"
    testRuntimeOnly "org.junit.platform:junit-platform-launcher:${versions.junit_platform}"
    testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${versions.junit}"

    // Spek
    testImplementation "org.jetbrains.spek:spek-api:${versions.spek}"
    implementation "org.jetbrains.kotlin:kotlin-reflect:${versions.kotlin}"
    testRuntimeOnly "org.jetbrains.spek:spek-junit-platform-engine:${versions.spek}"

    // Ktor
    implementation "io.ktor:ktor-server-netty:${versions.ktor}"
    implementation "ch.qos.logback:logback-classic:${versions.logback}"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.kotlin_coroutines}"
    implementation "io.ktor:ktor-jackson:${versions.ktor}"
    testImplementation "io.ktor:ktor-server-test-host:${versions.ktor}"

    // Jackson
    implementation "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson}"
    implementation "com.fasterxml.jackson.module:jackson-module-kotlin:${versions.jackson}"
}

compileKotlin {
    kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
    kotlinOptions.jvmTarget = "1.8"
}

kotlin {
    experimental { coroutines 'enable' }
}

test {
    useJUnitPlatform()
}

mainClassName = 'io.ktor.server.netty.DevelopmentEngine'
```

# ソース

## ツリー

今回作成したソースは以下の通りです。

```
src/
  main/
    kotlin/
      todoserver/
        controller/
          TaskController.kt
        model/
          Task.kt
        Application.kt
    resources/
      application.conf
  test/
    kotlin/
      todoserver/
        controller/
          TaskControllerSpec.kt
        ApplicationSpec.kt
```

## リソース

アプリケーションのエントリ、Ktorサーバのポートなどの設定ファイルを作成します。
エントリポイントのクラス名は、Kotlinクラスの真名である"Kt"が最後についているものを指定します。

```src/main/resources/application.conf
ktor.deployment.port = 8080

ktor.application.modules = [todoserver.ApplicationKt.main]
```

## 実行ソース

### エントリ

起動時のエントリポイントは、`io.ktor.application.Application`クラスにメソッドを追加する形で行いました。
エントリ内では、To-Doタスクのコントローラの作成、json入出力機能の導入、ルーティングの設定を行っています。

```src/main/kotlin/todoserver/Application.kt
package todoserver

import com.fasterxml.jackson.databind.SerializationFeature
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.http.HttpStatusCode
import io.ktor.jackson.jackson
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.*
import todoserver.controller.TaskController
import todoserver.model.PostTask
import todoserver.model.Task

fun Application.main() {
    // TO-DOタスクのコントローラ
    val taskController = TaskController()

    install(ContentNegotiation) {
        // Json用変換器の導入
        jackson {
            enable(SerializationFeature.INDENT_OUTPUT) // インデント出力機能付きで。
        }
    }

    // ルーティング
    routing {
        route("/tasks") {
            get("{id}") { // tasks/(数値)
                val t: Task? = call.parameters["id"]?.let {id ->
                    taskController.get(id.toLong())
                }
                call.respond(t ?: HttpStatusCode.NotFound)
            }

            post {
                val post = call.receive<PostTask>()
                taskController.add(post.content, post.done)
                call.respond(HttpStatusCode.OK)
            }

            put("{id}") {
                val put = call.receive<PostTask>()
                val id = call.parameters["id"]?.toLong()
                if (id != null) {
                    taskController.update(id, put.content, put.done)
                }
                call.respond(HttpStatusCode.OK)
            }

            delete("{id}") {
                call.parameters["id"]?.let {id ->
                    taskController.remove(id.toLong())
                }
                call.respond(HttpStatusCode.OK)
            }

            get("list") {
                call.respond(taskController.list())
            }
        }
    }
}
```

### モデル

To-Doリストのタスクのデータモデルと、通信用のモデルです。
通信用モデルは、複数のTaskを収納できるようにして、同時に複数のデータ登録などができるようにするべきなんでしょうが、今回はテスト用ということで単発にしています。

```src/main/kotlin/todoserver/model/Task.kt
package todoserver.model

data class Task(val id: Long, val content: String, val done: Boolean)

data class PostTask(val content: String, val done: Boolean)
```

### コントローラ

データのCRUDを行うためのクラスです。黒ベコ本の例を参考にしています。

```src/main/kotlin/todoserver/controller/TaskController.kt
package todoserver.controller

import todoserver.model.Task

class TaskController {
    var taskList = mutableListOf(
            Task(1, "宣教師を派遣する", false),
            Task(2, "同盟都市を増やす", true)
    )

    private fun nextId(): Long =
        taskList.fold(0L) {
            maxTaskId, task ->
            when {
                maxTaskId < task.id -> task.id
                else -> maxTaskId
            }
        } + 1L

    fun list() = taskList

    fun add(content: String, done: Boolean) {
        val task = Task(nextId(), content, done)
        taskList.add(task)
    }

    fun update(id: Long, content: String, done: Boolean) {
        remove(id)
        taskList.add(Task(id, content, done))
    }

    fun remove(id: Long) =
            taskList.removeIf {
                it.id == id
            }

    fun get(id: Long): Task? =
        taskList.find {
            it.id == id
        }
}
```

## テストソース

### TaskControllerのテスト

TaskControllerのテスト用クラスです。
だいぶ手を抜いています(カバレージ? なんのことやら)。

```test/kotlin/todoserver/controller/TaskControllerSpec.kt
package todoserver.controller

import org.jetbrains.spek.api.Spek
import org.jetbrains.spek.api.dsl.describe
import org.jetbrains.spek.api.dsl.it
import org.jetbrains.spek.api.dsl.on
import org.junit.jupiter.api.Assertions.assertEquals
import todoserver.model.Task

object TaskControllerSpec: Spek({
    describe("Task controller") {
        on("CRUD") {
            it("has default values") {
                val tc = TaskController()
                assertEquals(tc.list().size, 2)
            }

            it("can be added") {
                val tc = TaskController()
                tc.add("hogehoge", false)
                assertEquals(Task(3L, "hogehoge", false), tc.get(3L))
                assertEquals(tc.list().size, 3)
            }

            it("can be updated") {
                val tc = TaskController()
                tc.update(2L, "fugafuga", false)
                assertEquals(Task(2L, "fugafuga", false), tc.get(2L))
            }

            it("can be removed") {
                val tc = TaskController()
                tc.remove(1L)
                tc.remove(2L)
                assertEquals(tc.list().size, 0)
            }
        }
    }
})
```

### Applicationのテスト

Applicationクラスのテストクラスです。
実際にサーバを立ち上げなくても、テストモジュールを使えばテストできます。便利ですね。
なお、こちらもテストパターンがまったく網羅していませんが、動作確認用なのでこの程度でご勘弁を。

```test/kotlin/todoserver/ApplicationSpec.kt
package todoserver

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import io.ktor.application.Application
import io.ktor.application.ApplicationCallPipeline
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.setBody
import io.ktor.server.testing.withTestApplication
import org.apache.http.entity.ContentType
import org.jetbrains.spek.api.Spek
import org.jetbrains.spek.api.dsl.describe
import org.jetbrains.spek.api.dsl.it
import org.jetbrains.spek.api.dsl.on
import org.junit.jupiter.api.Assertions.assertEquals
import todoserver.controller.TaskController
import todoserver.model.PostTask
import todoserver.model.Task

fun Application.testableModule() {
    main()

    intercept(ApplicationCallPipeline.Call) {
        // intercept something
    }
}

object ApplicationSpec: Spek({
    val mapper = jacksonObjectMapper()

    describe("Application") {
        on("routines and responses") {
            withTestApplication({testableModule()}) {
                it("returns default list for GET list") {
                    with(handleRequest(HttpMethod.Get, "/tasks/list")) {
                        assertEquals(HttpStatusCode.OK, response.status())
                        val content = response.content ?: ""
                        val task = mapper.readValue<Array<Task>>(content)
                        assertEquals(task.size, 2)
                        assertEquals(task[0].id, 1)
                        assertEquals(task[1].content, "同盟都市を増やす")
                    }
                }

                it("is ok for POST") {
                    with(handleRequest(HttpMethod.Post, "/tasks") {
                        addHeader(HttpHeaders.ContentType, ContentType.APPLICATION_JSON.toString())
                        val post = PostTask("hoghog", false) // id=3
                        val content = mapper.writeValueAsString(post)
                        setBody(content)
                    }) {
                        assertEquals(HttpStatusCode.OK, response.status())
                    }
                }

                it("is ok for PUT") {
                    with(handleRequest(HttpMethod.Put, "/tasks/3") {
                        addHeader(HttpHeaders.ContentType, ContentType.APPLICATION_JSON.toString())
                        val post = PostTask("hoghog", false) // id=3
                        val content = mapper.writeValueAsString(post)
                        setBody(content)
                    }) {
                        assertEquals(HttpStatusCode.OK, response.status())
                    }
                }

                it("returns previous task for GET") {
                    with(handleRequest(HttpMethod.Get, "/tasks/3")) {
                        assertEquals(HttpStatusCode.OK, response.status())
                        val content = response.content ?: ""
                        val task = mapper.readValue<Task>(content)
                        assertEquals(task.id, 3L)
                        assertEquals(task.content, "hoghog")
                    }
                }

                it("delete previous task for DELETE") {
                    with(handleRequest(HttpMethod.Delete, "/tasks/3")) {
                        assertEquals(HttpStatusCode.OK, response.status())
                    }
                }

                it("returns no tasks after DELETE action") {
                    with(handleRequest(HttpMethod.Get, "/tasks/3")) {
                        assertEquals(HttpStatusCode.NotFound, response.status())
                    }
                }
            }
        }
    }
})
```

# 実行

## Run

IntelliJ IDEAのRun/Debug Configurationsで、Gradle設定を追加し、Gradle projectにこのプロジェクト、Tasksにrunを指定します。
この設定を実行した後、ブラウザで"http://localhost:8080/tasks/list"などとアクセスすると、デフォルトタスク一覧が表示されます。

## Test

今度はテスト用の設定を追加します。Runと同様にGradle設定を追加した後、Tasksにtestを指定します。
この設定を実行すると、Spekテストが実行されます。(上記のRunを実行したままだとポート使用中エラーが出るので注意)