Ktorで型安全なRoutingを提供する

基本的なことはドキュメントに書いてあるのでそれに倣う。

https://ktor.io/docs/server-resources.html

今回はExposedを使ったプロジェクトに型安全なRoutingを当て込んでみる。

# 環境

このリポジトリに定義している。型安全でないブランチが`answear`で型安全にしたブランチが`type-safe-routing`だ。

この記事にも実装の一部は掲載するが、全体像はブランチを切り替えて見てほしい。

https://github.com/ysknsid25/hands-on-exposed

# 修正前

以下のような`Routing.kt`がある。

```kotlin
package example.koin

import example.koin.controller.ExposedController
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.inject

fun Application.configureRouting() {
    val exposedController by inject<ExposedController>()
    routing {
        get("/") {
            call.respondText("Hello! hands on exposed!!")
        }
        get("/inorin"){
            exposedController.getInorin(call)
        }
        get("/allEmployees"){
            exposedController.getAllEmployees(call)
        }
        get("/allEmployeesNames"){
            exposedController.getAllEmployeesNames(call)
        }
        get("/employeeNameOfGeneralOrAccounting"){
            exposedController.getEmployeeNameOfGeneralOrAccounting(call)
        }
        get("/employeeBySorted"){
            exposedController.getEmployeeBySorted(call)
        }
        get("/howManyApplyExpenseByEmployee"){
            exposedController.getHowManyApplyExpenseByEmployee(call)
        }
        get("/howMuchExpenseByEmployee"){
            exposedController.getHowMuchExpenseByEmployee(call)
        }
        get("/employeeLimitOffset"){
            exposedController.getEmployeeLimitOffset(call)
        }
        get("/employeeNameAndDepartment"){
            exposedController.getEmployeeNameAndDepartment(call)
        }
        get("/hasExpenseEmployeeNames"){
            exposedController.hasExpenseEmployeeNames(call)
        }
        get("/hasExpenseEmployeeNamesWithBetween"){
            exposedController.hasExpenseEmployeeNamesWithBetween(call)
        }
        get("/allEmployeeTypeAndNames"){
            exposedController.getAllEmployeeTypeAndNames(call)
        }
        get("/allEmployeeTypeAndNamesDistinct"){
            exposedController.getAllEmployeeTypeAndNamesDistinct(call)
        }
        get("/hasExpenseEmployeeIdAndNames"){
            exposedController.getHasExpenseEmployeeIdAndNames(call)
        }
        get("/overExpenseEmployeeIdAndNames"){
            exposedController.getOverExpenseEmployeeIdAndNames(call)
        }
        get("/existsOverExpenseEmployeeIdAndNames"){
            exposedController.getExistsOverExpenseEmployeeIdAndNames(call)
        }
        get("/latestEmployeeIdByDepartmentId"){
            exposedController.getLatestEmployeeIdByDepartmentId(call)
        }
        get("/employeeNamesAndEnrollmentStatus"){
            exposedController.getEmployeeNamesAndEnrollmentStatus(call)
        }
        get("/concatEmployeeNames"){
            exposedController.getConcatEmployeeNames(call)
        }
        get("/concatPartnerNames"){
            exposedController.getConcatPartnerNames(call)
        }
        get("/employeeFirstNameStrByte"){
            exposedController.getEmployeeFirstNameStrByte(call)
        }
        get("/employeeFirstNameCharLength"){
            exposedController.getEmployeeFirstNameCharLength(call)
        }
        get("/employeeFirstNameCharLengthOver3"){
            exposedController.getEmployeeFirstNameCharLengthOver3(call)
        }
        post("/insertUpdateDeleteEmployee"){
            exposedController.insertUpdateDeleteEmployee(call)
        }
        put("/updateApplyExpenseEmployee"){
            exposedController.updateApplyExpenseEmployee(call)
        }
        get("/allPartners"){
            exposedController.getAllPartners(call)
        }
        get("/allPartnersNames"){
            exposedController.getAllPartnersNames(call)
        }
        get("/partnerNameById"){
            exposedController.getPartnerNameById(call)
        }
        get("/partnerNameByLikeKeyword"){
            exposedController.getAllPartnersNamesByLikeKeyword(call)
        }
        get("/partnerBySorted"){
            exposedController.getPartnerBySorted(call)
        }
        get("/partnerLimitOffset"){
            exposedController.getPartnerLimitOffset(call)
        }
        get("/partnerNameAndDepartment"){
            exposedController.getPartnerNameAndDepartment(call)
        }
        get("/partnerNamesAndEnrollmentStatus"){
            exposedController.getPartnerNamesAndEnrollmentStatus(call)
        }
        post("/insertUpdateDeletePartner"){
            exposedController.insertUpdateDeletePartner(call)
        }
    }
}
```

このうちのルート`/partnerNameById`の実装を見る。

```kotlin
    suspend fun getPartnerNameById(call: ApplicationCall){
        val id = call.parameters["partnerId"]?.toIntOrNull() ?: -1
        val message = selectService.selectPartnerById(id)
        call.respondText(message)
    }
```

以上から、現状の`Routing.kt`の問題点を列挙する。

- Emplpoyee,Partnerに関するルートが自由に定義できてしまっている
  - これらのルートが今は整理できているが、将来的に乱雑になる可能性がある
- 引数の型検証が必要になっている
  - call.parameters["partnerId"]?.toIntOrNull() ?: -1が冗長
  - nullの場合のテストコードが無駄に必要になっている

この辺りを`Type Safe Routing`を導入することでスマートに解決する。

# 準備

`Type Safe Routing`を導入するためには`kotlinx.serialization`が必要になるのでその準備をまずしていく。

https://github.com/Kotlin/kotlinx.serialization#setup

`build.gradle.kts`に以下を追加する。

```diff
val ktor_version: String by project
val kotlin_version: String by project
val logback_version: String by project

plugins {
    kotlin("jvm") version "1.9.0"
+    kotlin("plugin.serialization") version "1.9.0"
    id("io.ktor.plugin") version "2.3.3"
}

group = "example.koin"
version = "0.0.1"

application {
    mainClass.set("io.ktor.server.tomcat.EngineMain")

    val isDevelopment: Boolean = project.ext.has("development")
    applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}

repositories {
    mavenCentral()
}

dependencies {
    // When Set up
    implementation("io.ktor:ktor-server-core-jvm")
    implementation("io.ktor:ktor-server-tomcat-jvm")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    implementation("io.ktor:ktor-server-config-yaml:2.3.3")
    implementation("io.insert-koin:koin-core:3.3.3")
    implementation("io.insert-koin:koin-ktor:3.4.3")
    testImplementation("io.ktor:ktor-server-tests-jvm")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")

+    // for serialization
+    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0")
+    implementation("io.ktor:ktor-server-resources:$ktor_version")

    // for exposed
    implementation("org.jetbrains.exposed", "exposed-core", "0.41.1")
    implementation("org.jetbrains.exposed", "exposed-dao", "0.41.1")
    implementation("org.jetbrains.exposed", "exposed-jdbc", "0.41.1")
    implementation("org.jetbrains.exposed:exposed-java-time:0.41.1")
    implementation("mysql:mysql-connector-java:8.0.33")
}
```

# リソースクラスの作成

これが型安全なルーティングの実態。今回は`src/resources/Partner.kt`を作成し、ルートとパラメーターを定義してみる。

```kotlin
package example.koin.resources

import io.ktor.resources.*

@Resource("/partners")
class Partner {
    @Resource("all")
    class all(val parent: Partner)

    @Resource("names")
    class names(val parent: Partner){
        @Resource("all")
        class all(val parent: names)

        @Resource("{id}")
        class byId(val parent: names, val id: Int)
    }
}
```

これを`Routing.kt`に適用する。すると`/allPartners`, `/allPartnersNames`, `/partnerNameById`と同様の型安全なルートを`/partners`, `/partners/names/all`, `/partners/names/1`のように扱えるようになる。

```kotlin
        get("/allPartners"){
            exposedController.getAllPartners(call)
        }
        get<Partner.all>{
            exposedController.getAllPartners(call)
        }
        get("/allPartnersNames"){
            exposedController.getAllPartnersNames(call)
        }
        get<Partner.names.all>{
            exposedController.getAllPartnersNames(call)
        }
        get("/partnerNameById"){
            val id = call.parameters["partnerId"]?.toIntOrNull() ?: -1
            exposedController.getPartnerNameById(call, id)
        }
        get<Partner.names.byId>{
            partner -> exposedController.getPartnerNameById(call, partner.id)
        }
```

これにより型安全なルーティングがひとまずはできた。

# post, putのようなbodyに詳細値を入れて送る場合

この場合はRecourceはルートを提供するのみで、bodyの値は`Content-Type`によって取り出して扱う必要がある。

いくつか方法はあるが、型安全に扱うなら"Objects"になると思う。

https://ktor.io/docs/server-requests.html#objects

# おわりに

パス階層の作り方とかを意識できるので非常によさそう。

実際に置き換えてみたが、`/allPartners`みたいなルートの作り方はイケてないことがよくわかった。