Kotlin + SpringBootでndjsonのリクエストを受けられるようにする。

ndjson

Newline Delimited JSON の略称。
仕様としては複数のJSONを改行文字で区切ったフォーマットです。

e.g.

{"foo":1,"bar":false,"quux":true}
{"foo":2,"bar":true,"quux":true}
あまり一般的ではない仕様だとは思うのですが、JSONを扱ったAPIで一括で処理を行いたい時に使えるフォーマットです。
例としてはElasticSearchのBulk APIが参考になるかと思います。

ndjsonのパース

SpringBootではデフォルトで入っているjacksonがjsonのパース時に使えるようになっています。
2020年7月現在、jacksonは公式にndjsonに対応していませんが、Issueは作成されています。
今回はKotlin + SpringBootでTodoを一括登録するようなサンプルコードを書いてみます。
Mapping対象のクラスはdata classで定義します。
data class Todo(val id: String, val name: String, val priority: Int)
コントローラの引数にListで受けられるように設定します。
@RestController
class BulkController(private val todoService: TodoService) {

    @PostMapping("/bulk")
    fun bulkInput(@RequestBody requestBody: List<Todo>) {
        todoService.saveTodos(requestBody)
    }

}

実際にcurlでリクエストの投げてみると400エラーになります。

$ curl -X POST "http://localhost:8080/bulk" -H "accept: */*" -H "Content-Type: application/json" -d "{ \"id\": 1, \"name\": \"Work\", \"priority\": 1 }
{ \"id\": 2, \"name\": \"Todo\", \"priority\": 2 }"
{"timestamp":"2020-07-01T14:09:18.064+00:00","status":400,"error":"Bad Request","message":"","path":"/test"}
SpringとしてはHTTPのリクエスト内容を引数や戻り値の型に変換するデシリアライズ/シリアライズ処理が入っていますが、ndjsonのフォーマットで受け付けるConverterの設定が無い為、リクエストが400エラーになっています。
基本的にリクエストしてきたメッセージをHttpMessageConverterを使ってデシリアライズ/シリアライズしています。(標準でStringHttpMessageConverter, MappingJacksonHttpMessageConverter等が使われています。)
ですので、今回はndjsonに合ったHttpMessageConverterを作成します。

ndjsonに対応してHttpMessageConverterの作成

今回の仕様は次のとおりです。
  • ndjsonをパースしてList形式で指定した引数にマッピングさせる
  • ContentTypeは application/x-ndjson を使う
AbstractHttpMessageConverter を継承したConverterクラスを作成します。
class TodoListHttpMessageConverter :
        AbstractHttpMessageConverter<List<Todo>>(
                // 対応するContentTypeを指定する。今回はx-ndjson
                MediaType("application", "x-ndjson")
        ) {

    private val objectMapper = jacksonObjectMapper()

    // このConverterがサポートするクラスの判定
    override fun supports(clazz: Class<*>): Boolean {
        return List::class.java.isAssignableFrom(clazz)
    }

    // シリアライズ
    override fun writeInternal(t: List<Todo>, outputMessage: HttpOutputMessage) {
        val responses = t.joinToString("\n") { objectMapper.writeValueAsString(it) }
        outputMessage.body.bufferedWriter().use { it.write(responses) }
    }

    // デシリアライズ
    override fun readInternal(clazz: Class<out List<Todo>>, inputMessage: HttpInputMessage): List<Todo> {
        val requestBody = inputMessage.body.bufferedReader().use { it.readLines() }
        val requests = requestBody.map {
            objectMapper.readValue<Todo>(it)
        }
        return requests
    }
}
次に作成したConverterを利用する設定を作成します。
WebMvcConfigurer を継承したクラスを作成します。
@Configuration
class WebConfig : WebMvcConfigurer {

    // 作成したConverter追加する
    override fun extendMessageConverters(converters: MutableList<HttpMessageConverter<*>>) {
        converters.add(TodoListHttpMessageConverter())
    }

}
起動させて、curlを実行します。
curl -i -X POST "http://localhost:8080/bulk" -H "accept: */*" -H "Content-Type: application/x-ndjson" -d "{ \"id\": 1, \"name\": \"Work\", \"priority\": 1 }
{ \"id\": 2, \"name\": \"Todo\", \"priority\": 2 }"

HTTP/1.1 201
Content-Type: application/x-ndjson
Content-Length: 85
Date: Wed, 01 Jul 2020 23:54:48 GMT
nd-jsonを受け付けることができました。

テストコード

Converterのテストはコントローラのテストと同様にMockMvcを使ってエラーが発生しないかテストします。
まずはConverterを使う為にMockMvcBuildersを使ってsetMessageConvertersに作成したConverterを追加します。
@SpringBootTest
internal class TodoListHttpMessageConverterTest {

    private lateinit var mockMvc: MockMvc
    private val todoService: TodoService = mockk()

    @BeforeEach
    fun setUp() {
        val multiEventController = BulkController(todoService)
        val builder = MockMvcBuilders
                .standaloneSetup(multiEventController)
                .setMessageConverters(TodoListHttpMessageConverter(),
                        MappingJackson2HttpMessageConverter())
        mockMvc = builder.build()
    }
}
今回はエラーが発生しないかどうか、コントローラーで呼び出すServiceクラスの引数に適切に値が入っているかをキャプチャして確認します。
その為のmockライブラリにmockk, アサーションはassertjを利用します。
@Test
fun convertNdJsonToRequestModel() {
    val requestBody = """
        { "id": "test_1", "name": "Work", "priority": 1 }
        { "id": "test_2", "name": "Todo", "priority": 2 }
    """.trimIndent()

    val expected = listOf(
            Todo(id = "test_1", name = "Work", priority = 1),
            Todo(id = "test_2", name = "Todo", priority = 2)
    )

    // contentTypeがx-ngjsonのpostリクエストを作成
    val builder = MockMvcRequestBuilders
            .post("/bulk")
            .contentType("application/x-ndjson")
            .content(requestBody)

    // コントローラで呼び出すServiceクラスの引数をキャプチャする
    val slot = slot<List<Todo>>()
    every { todoService.saveTodos(capture(slot)) } just Runs

    // isCreatedが返ってくるを確認する, パースエラー時は400が返ってくる為
    mockMvc.perform(builder)
            .andExpect(MockMvcResultMatchers.status().isCreated)
            .andDo(MockMvcResultHandlers.print())

    val todos = slot.captured
    Assertions.assertThat(todos).containsAll(expected)
}

まとめ

今回の動作するサンプルコードはGithubに上がっていますので、興味ある方はそちらを動かして確認してみてください。