Kotlin/SpringBoot/MySQLでRESTAPIを作ってみる


## 概要
`Kotlin`を触ってみたかったので簡単なRESTAPIを作成してみた。

## 環境
- Kotlin: 1.3.50
- Springboot: 2.2.1
- MySQL: 5.6.46

## リポジトリ
今回、説明するものは[こちらのリポジトリ](https://github.com/yamee-dev/kotlin_restapi)です。

```console
git clone git@github.com:yamee-dev/kotlin_restapi.git
git checkout 831e1028078cfe796077d6f8f6df230361da1c66
```

## 作成機能
|HTTPメソッド  |URL |概要 |
|---|---|---|
|GET  |/api/v1/characters|全件取得 |
|POST  |/api/v1/characters|新規登録|
|GET  |/api/v1/characters/{id}|一件取得|
|PUT  |/api/v1/characters/{id}|編集|
|DELETE  |/api/v1/characters/{id}|削除|


## 雛形の作成
[Spring Initializr](https://start.spring.io/)を用いて雛形を作成する。

 - Project : Gradle Project
 - Language : Kotlin
 - Spring Boot : 2.2.1
 - Project MetaData : com.example.kotlin_restapi
 - Dependencies : Web,MySQL,JPA,H2
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/330768/eeca6e2b-ca77-e5e7-ac9d-dc1d06b4727e.png)

Generateされたzipファイルを展開して、適当なディレクトリに配置。
生成されたプロジェクトは以下のような構成になる。

```console

$ tree
.
├── HELP.md
├── build.gradle.kts
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   │   └── com
    │   │       └── example
    │   │           └── kotlin_restapi
    │   │               └── KotlinRestapiApplication.kt
    │   └── resources
    │       ├── application.properties
    │       ├── static
    │       └── templates
    └── test
        └── kotlin
            └── com
                └── example
                    └── kotlin_restapi
                        └── KotlinRestapiApplicationTests.kt
```

## DBの設定

DBを作成する。今回は予めローカル環境に設置したものを使用する。

```console
$ mysql -u ${username} -p
Enter password: 
mysql> CREATE DATABASE kotlin_restapi_db;
Query OK, 1 row affected (0.00 sec)

```

## 実装
### 設定ファイル
作成したDBへの接続設定と、JPAのインサート設定、コンソール表示設定を記載する。

```./src/main/resources/application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/kotlin_restapi_db
    username: root
    password:
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
```

### Entity
Entityで定義されたテーブル情報が、自動インサートの対象になる。
データクラスを使用することでスッキリ書けた。

```./src/main/kotlin/com/example/kotlin_restapi/entity/Character.kt

package com.example.kotlin_restapi.entity

import javax.persistence.*

@Entity
@Table(name = "characters")
data class Character (
    @Id
    @GeneratedValue
    val id: Long? = null,

    @Column(name = "username", length = 100, nullable = false)
    val username: String,

    @Column(name = "age", nullable = false)
    val age: Long,

    @Column(name = "jobs", length = 100)
    val jobs: String? = null
)
```

### Repository
JPA用のインターフェイスを定義する。

```./src/main/kotlin/com/example/kotlin_restapi/repository/CharacterRepository.kt
package com.example.kotlin_restapi.repository

import com.example.kotlin_restapi.entity.Character
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface CharacterRepository : JpaRepository<Character, Long> {
}
```

### Service
ビジネスロジック書く。

```./src/main/kotlin/com/example/kotlin_restapi/service/CharacterService.kt
package com.example.kotlin_restapi.service

import com.example.kotlin_restapi.entity.Character
import com.example.kotlin_restapi.repository.CharacterRepository
import org.springframework.stereotype.Service

@Service
class CharacterService(private val characterRepository: CharacterRepository) {

    fun findAll(): List<Character> = characterRepository.findAll()

    fun findById(id: Long) = characterRepository.findById(id)

    fun save(character: Character) = characterRepository.save(character)

    fun delete(id: Long) = characterRepository.deleteById(id)
}
```

### Controller

作成機能に対応したメソッドを作成。


```./src/main/kotlin/com/example/kotlin_restapi/controller/CharacterController.kt
package com.example.kotlin_restapi.controller

import com.example.kotlin_restapi.entity.Character
import com.example.kotlin_restapi.service.CharacterService
import org.springframework.web.bind.annotation.*
import java.util.*


@RestController
@RequestMapping("/api/v1/characters")
class CharacterController (private val characterService: CharacterService){

    @GetMapping("")
    fun findAll(): List<Character> {
        return characterService.findAll()
    }

    @PostMapping("")
    fun create(@RequestBody character: Character): Character {
        characterService.save(character)
        return character
    }

    @GetMapping("{id}")
    fun findById(@PathVariable id: Long): Optional<Character> {
        return characterService.findById(id)
    }

    @PutMapping("{id}")
    fun update(@PathVariable id: Long, @RequestBody character: Character): Character {
        characterService.save(character.copy(id = id))
        return character.copy(id = id)
    }

    @DeleteMapping("{id}")
    fun delete(@PathVariable id: Long): String {
        characterService.delete(id)
        return "Delete Complete"
    }
}
```

## テスト

### build.gradle.ktsの編集

テスト用の環境と整えるために、dependenciesを編集する。

```./build.gradle.kts
dependencies {
	implementation("org.springframework.boot:spring-boot-starter-data-jpa")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	runtimeOnly("com.h2database:h2")
	runtimeOnly("mysql:mysql-connector-java")

//	追加
	testCompile("org.springframework.boot:spring-boot-starter-test")
	testCompile("org.assertj:assertj-core:3.8.0")

//	削除
//	testImplementation("org.springframework.boot:spring-boot-starter-test") {
//		exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
//	}
}

```

### 設定ファイル

`./src/test/kotlin`以下に`resources/application.yml`を作成。
テスト環境では、DBをH2に変更したいので、H2の接続設定を記載。

```./src/test/resources/application.yml
spring:
  datasource:
    url: jdbc:h2:./db;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password:
    driver-class-name: org.h2.Driver
```

### 初期実行sql

テスト実行じに呼び出せれるsqlを記載。

```./src/test/resources/setup.sql
DROP TABLE IF EXISTS characters;
CREATE TABLE IF NOT EXISTS characters (
    id bigint(20) NOT NULL,
    age bigint(20) DEFAULT NULL,
    jobs varchar(100) NOT NULL,
    username varchar(100) NOT NULL,
    PRIMARY KEY (id)
);
```


### テストの実装
Controllerのメソッドを呼び出し、正常動作を確認するテストを書いた。
テストが呼び出されると、`setup.sql`が実行され、初期状態のDBで実行される。

```./src/test/kotlin/com/example/kotlin_restapi/CharacterControllerTest.kt
package com.example.kotlin_restapi

import com.example.kotlin_restapi.controller.CharacterController
import com.example.kotlin_restapi.entity.Character
import org.assertj.core.api.Assertions
import org.junit.Test
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.context.junit4.SpringRunner


@RunWith(value = SpringRunner::class)
@SpringBootTest
@Sql("classpath:setup.sql")
class CharacterControllerTest {
    @Autowired
    private lateinit var characterController: CharacterController

    @Test
    fun noDataTest() {
        val characters = characterController.findAll()
        Assertions.assertThat(characters).isEmpty()
    }

    @Test
    fun createTest() {
        val createCharacter = characterController.create(Character(username = "Kazuma", age = 16, jobs = "NEET"))
        Assertions.assertThat(createCharacter.username).isEqualTo("Kazuma")
        Assertions.assertThat(createCharacter.age).isEqualTo(16)
        Assertions.assertThat(createCharacter.jobs).isEqualTo("NEET")
        Assertions.assertThat(createCharacter.id).isEqualTo(createCharacter.id)
    }

    @Test
    fun findByIdTest() {
        val createCharacter = characterController.create(Character(username = "Kazuma", age = 16, jobs = "NEET"))
        val findCharacter = characterController.findById(createCharacter.id ?: 0)
        Assertions.assertThat(findCharacter.orElse(null).username).isEqualTo("Kazuma")
        Assertions.assertThat(findCharacter.orElse(null).age).isEqualTo(16)
        Assertions.assertThat(findCharacter.orElse(null).jobs).isEqualTo("NEET")
    }

    @Test
    fun findAllTest() {
        val createCharacter1 = characterController.create(Character(username = "Kazuma", age = 16, jobs = "NEET"))
        val createCharacter2 = characterController.create(Character(username = "Megumin", age = 13, jobs = "Arc Wizard"))
        val findCharacters = characterController.findAll()
        Assertions.assertThat(findCharacters[0].id).isEqualTo(createCharacter1.id)
        Assertions.assertThat(findCharacters[0].username).isEqualTo("Kazuma")
        Assertions.assertThat(findCharacters[0].age).isEqualTo(16)
        Assertions.assertThat(findCharacters[0].jobs).isEqualTo("NEET")
        Assertions.assertThat(findCharacters[1].id).isEqualTo(createCharacter2.id)
        Assertions.assertThat(findCharacters[1].username).isEqualTo("Megumin")
        Assertions.assertThat(findCharacters[1].age).isEqualTo(13)
        Assertions.assertThat(findCharacters[1].jobs).isEqualTo("Arc Wizard")
    }

    @Test
    fun updateTest() {
        val createCharacter = characterController.create(Character(username = "Kazuma", age = 16, jobs = "NEET"))
        val findCharacter = characterController.findById(createCharacter.id ?: 0)
        Assertions.assertThat(findCharacter.orElse(null).username).isEqualTo("Kazuma")
        Assertions.assertThat(findCharacter.orElse(null).age).isEqualTo(16)
        Assertions.assertThat(findCharacter.orElse(null).jobs).isEqualTo("NEET")

        characterController.update(createCharacter.id ?: 0, Character(username = "Aqua", age = 999, jobs = "Arc Priest"))
        val updateCharacter = characterController.findById(createCharacter.id ?: 0)
        Assertions.assertThat(updateCharacter.orElse(null).username).isEqualTo("Aqua")
        Assertions.assertThat(updateCharacter.orElse(null).age).isEqualTo(999)
        Assertions.assertThat(updateCharacter.orElse(null).jobs).isEqualTo("Arc Priest")
    }

    @Test
    fun deleteTest() {
        val createCharacter = characterController.create(Character(username = "Kazuma", age = 16, jobs = "NEET"))
        val findCharacter = characterController.findById(createCharacter.id ?: 0)
        Assertions.assertThat(findCharacter.orElse(null).username).isEqualTo("Kazuma")
        Assertions.assertThat(findCharacter.orElse(null).age).isEqualTo(16)
        Assertions.assertThat(findCharacter.orElse(null).jobs).isEqualTo("NEET")
        characterController.delete(createCharacter.id ?: 0)
        val deleteCharacter = characterController.findById(createCharacter.id ?: 0)
        Assertions.assertThat(deleteCharacter).isEmpty
    }
}

```

## 動作確認
### テスト

```console
$ gradle test

> Task :test
2019-11-08 17:25:21.860  INFO 5154 --- [extShutdownHook] o.s.s.concurrent.ThreadPoolTaskExecutor  : Shutting down ExecutorService 'applicationTaskExecutor'
2019-11-08 17:25:21.861  INFO 5154 --- [extShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2019-11-08 17:25:21.861  INFO 5154 --- [extShutdownHook] .SchemaDropperImpl$DelayedDropActionImpl : HHH000477: Starting delayed evictData of schema as part of SessionFactory shut-down'
2019-11-08 17:25:21.864  INFO 5154 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2019-11-08 17:25:21.869  INFO 5154 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

BUILD SUCCESSFUL in 6s
5 actionable tasks: 2 executed, 3 up-to-date

```

### 起動


```console
$ gradle build

BUILD SUCCESSFUL in 1s
7 actionable tasks: 2 executed, 5 up-to-date

$ java -jar ./build/libs/kotlin_restapi-0.0.1-SNAPSHOT.jar

省略

2019-11-08 17:30:40.437  INFO 5222 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-11-08 17:30:40.690  INFO 5222 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2019-11-08 17:30:40.692  INFO 5222 --- [           main] c.e.k.KotlinRestapiApplicationKt         : Started KotlinRestapiApplicationKt in 4.306 seconds (JVM running for 4.71)

```

### APIを叩いてみる

#### jqをインストール
```console
$ brew install jq
```

#### 新規作成
```console
$ curl -XPOST -H "Content-type: application/json" -d '{"username": "Kazuma","age": 17,"jobs": "NEET"}' 'http://localhost:8080/api/v1/characters'
{
  "id": 1,
  "username": "Kazuma",
  "age": 17,
  "jobs": "NEET"
}
```

#### 一件取得
```console
$ curl http://localhost:8080/api/v1/characters/1 |jq
{
  "id": 1,
  "username": "Kazuma",
  "age": 17,
  "jobs": "NEET"
}
```

#### 全件取得
```console
$ curl http://localhost:8080/api/v1/characters |jq
[
  {
    "id": 1,
    "username": "Kazuma",
    "age": 17,
    "jobs": "NEET"
  },
  {
    "id": 2,
    "username": "Megumin",
    "age": 13,
    "jobs": "Arc Wizard"
  }
]
```

#### 編集
```console
$ curl -XPUT -H "Content-type: application/json" -d '{"username": "Aqua","age": 999,"jobs": "Arc Priest"}' 'http://localhost:8080/api/v1/characters/1' |jq
{
  "id": 1,
  "username": "Aqua",
  "age": 999,
  "jobs": "Arc Priest"
}

$ curl http://localhost:8080/api/v1/characters/1 |jq
{
  "id": 1,
  "username": "Aqua",
  "age": 999,
  "jobs": "Arc Priest"
}
```

#### 削除
```console
$ curl -XDELETE -H "Content-type: application/json" 'http://localhost:8080/api/v1/characters/1'

Delete Complete

$ curl http://localhost:8080/api/v1/characters/1 |jq

null
```

良い感じに作れた。