takeda_san’s blog

KotlinとVRを頑張っていく方向。

SpringでGETのAPI作ったときに結果なしをどうやって返そうか考える

きっかけ

それは、Springの諸々をKotlin書いていたときの出来事です。
id指定で1件レコードを返すAPIを愚直に書いていたのですが…
(関数名の適当さは目をつぶってほしい)

Controller

@GetMapping("{id}")
fun getAddress(@PathVariable("id") id: Int): FindAddressResponse {
    return addressService.find(id)
}

Service

fun find(addressId: Int): FindAddressResponse {
    val result = addressDomaDao.find(addressId)
    return FindAddressConverter.of(result)
}

Dao

@Select
AddressReadEntity find(int addressId);

Converter

fun of(from: AddressReadEntity): FindAddressResponse {
    return FindAddressResponse(
            personId = from.personId,
            addressId = from.addressId,
            name = from.name,
            sexType = from.sexType,
            address = from.address
    )
}

ここでふと… 存在しないidが引数で指定されたときにDaoからnullが返るからServiceあたりで落ちるのでは…?

実行結果

java.lang.IllegalStateException: result must not be null
    at jp.takeda.doma2sample.domain.service.AddressService.find(AddressService.kt:25) ~[main/:na]
    at jp.takeda.doma2sample.controller.AddressController.getAddress(AddressController.kt:22) ~[main/:na]

うん、落ちたね。

こういう場合にAPI的には何を返したらいいのか。
また、Kotlin的なカッコヨイ処理の仕方って何だろう。

この時点のコード。
GitHub - takedasan/doma2samplekotlin at afeb44af77492b9c3fcb4817d4cdd0757dc495f5

何を返したらいいのか

これに同意。
HTTP GET REST API — No Content — 404 vs 204 vs 200 – Santhosh Kumar Krishna – Medium

(3/10) twitterでコメント頂いてちょっと考えなおしました。『HTTPステータスは何を返すべきなのか(再)』に追記します

ステータスコードは200、Bodyは空。
ほかの取得結果と同じで、結果なかったよーという正常レスポンスを返すのが素直な気がする。

カッコヨイ処理の仕方

いくつかありそう。

Javaおじさんとtry-catch構文

まず思いつくのは、ServiceなりDaoなりで任意の例外を投げて、Controllerでcatch。
その場合に空のレスポンスを返すというもの。

Controller

@GetMapping("{id}")
fun getAddress(@PathVariable("id") id: Int): ResponseEntity<FindAddressResponse> {
    return try {
        ResponseEntity.ok(addressService.find(id))
    } catch (e: IllegalArgumentException) {
        ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).build<FindAddressResponse>()
    }
}

Service

fun find(addressId: Int): FindAddressResponse {
    val result = addressDomaDao.find(addressId)
    result ?: throw IllegalArgumentException("存在しないidですよ")
    return FindAddressConverter.of(result)
}

なんかこの感じ落ち着く…
実家のような安心感。
これでも機能としては十分満たしていると思うのですよね。

この時点でのコード
GitHub - takedasan/doma2samplekotlin at 7d613f97f116593224e6cf90425fce297e359549

🤔なところ

Serviceで0件の時に例外投げるのでControllerでキャッチしてね!みたいなルールができちゃうところ。
(あと、今bodyって空なので"{}"って返したい場合ってどう書けばいいんですかね…)

(3/10) twitterでコメント頂きました!『body部に"{}"でレスポンスを返したい』に追記します

あと単純に結果がなかったら空にしたいだけなのにtry-catchが出てくるのが違和感。
レスポンス的には異常(404)じゃないぜ正常(200)だぜーヘイヘイ!って感じなのに、内部の処理はこんなん。
別に結果がないのは例外じゃなくない…?
whenとかifで振り分けたほうが素直な感じしないですか。

シールドクラスによるエラーの表現

ここの記事読んで、感動してしまったのですが、こんな書き方あるんですねぇ…

例外だけに頼らない Kotlin のエラーハンドリング - Qiita

ControllerとServiceを書き換えればよさそう

Controller

@GetMapping("{id}", produces = ["application/json"])
fun getAddress(@PathVariable("id") id: Int): ResponseEntity<FindAddressResponse> {
    val result = addressService.find(id)

     return when (result) {
        is FindAddressResult.Found -> ResponseEntity.ok(result.response)
        is FindAddressResult.NotFound -> ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).build<FindAddressResponse>()
    }
}

Service

fun find(addressId: Int): FindAddressResult {
    val result = addressDomaDao.find(addressId)

    // nullだった場合は結果なしとして扱う
    return if (result == null) {
        FindAddressResult.NotFound
    } else {
        FindAddressResult.Found(FindAddressConverter.of(result))
    }
}

やったぜハッピー。例外を追い出せた。
これはKotlinっぽくてカッコイイ。

この時点でのコード
GitHub - takedasan/doma2samplekotlin at 404af4598e2f42dd6cc19875fb1af4f34dbc2c17

(3/10追記)body部に"{}"でレスポンスを返したい

公開した後に、このお悩みをボソッとつぶやいたのですが、
リプライで方法を教えてもらったので追記です。
(感謝 🙏。ブログ書いててよかった!)

空のクラスをつくる

空のクラスですって!?
試してみよう。

Empty response

@JsonSerialize
class EmptyResponse

Controller

@GetMapping("{id}")
fun getAddress(@PathVariable("id") id: Int): ResponseEntity<Any> {
    val result = addressService.find(id)

    return when (result) {
        is FindAddressResult.Found -> ResponseEntity.ok(result.response)
        is FindAddressResult.NotFound -> ResponseEntity.ok(EmptyResponse())
    }
}

動作確認

curl http://localhost:8080/addresses/4
{}

直感的で分かりやすい!
(EmptyResponseみたいな名前にすれば空を返しているのがよくわかる)
あとは戻り値のAnyを消し去るところが腕の見せ所さん。

Collections.emptyMap()を使う

空のMapですって!?
試してみよう。

Controller

@GetMapping("{id}")
fun getAddress(@PathVariable("id") id: Int): ResponseEntity<Any> {
    val result = addressService.find(id)

    return when (result) {
        is FindAddressResult.Found -> ResponseEntity.ok(result.response)
        is FindAddressResult.NotFound -> ResponseEntity.ok(Collections.EMPTY_MAP)
    }
}

※型の関係でEMPTY_MAPにしました

動作確認

curl http://localhost:8080/addresses/4
{}

Controller内だけで解決できるのがうれしい。
これも戻り値のAnyを消し去るところが腕の見せ所さん。

結局どうするのがよいのか(個人的に)

クライアント側が、body部ないと受け取れないんですけど💢
みたいなことでなければ、なしでもよいと思います。
ここのAny型を消し去るために複雑なことをやるぐらいなら、やらんでもなぁという気持ち。

参照した記事にも書いてある通り、やんごとなき理由で"{}"を返す場合はこれらの方法で返してあげるのがよさそう。
(それが受け取るクライアント側にとって優しいかは、いったん置いておく…)

(3/10追記)HTTPステータスは何を返すべきなのか(再)

完璧なコードやんと思ってスッターン!とpushして、
一晩寝かしたコードを見てみると意外に直すところがみつかることがありますね。
今そんな気持ちです。

URLに対応するリソースがなければ404を返却する。
確かに静的なリソースにアクセスした場合と合わせて考えると、レスポンスの期待ってそっちな気がします。

今回の場合、クライアント側が、200台じゃないとないと例外としてcatchしちゃうんですけど💢
というやんごとなき理由もあるので、200で返すことにしていたのだけれども、素直なのは404だなぁという気持ち。
(驚くべきことにクライアントとサーバのアプリ両方、自分で設計してるのに…というのがアレな感じ)