little star's memory

競プロ、なぞなぞ

Kotlinでサーバーサイドのお勉強 第5回 HTTPクライアント

今日もMicronautを触っていく。

今回はHTTPクライアント。

やりたいこと

AtCoder Problems API を叩いてみたい。

github.com

API叩くという表現、初めて使ったかも。

実装・前編

いつものHelloController.ktを書き換えていく。

package com.example

import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.client.HttpClient
import jakarta.inject.Inject

@Controller
class ViewController {
    @Inject lateinit var httpClient: HttpClient

    @Get("/{user}")
    fun index(@PathVariable user: String): String {
        return httpClient.toBlocking().retrieve("https://kenkoooo.com/atcoder/atcoder-api/v3/user/ac_rank?user=$user")
    }
}

これで/koboshiにアクセスする。

するとエラーになる。どうやら403 Forbiddenになっているらしい。

実装・中編

どうやらリクエストヘッダをいい感じに設定する必要があるらしい。そこで次のように書き換える。

package com.example

import io.micronaut.http.HttpHeaders
import io.micronaut.http.HttpRequest
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.client.HttpClient
import jakarta.inject.Inject

@Controller
class ViewController {
    @Inject lateinit var httpClient: HttpClient

    @Get("/{user}")
    fun index(@PathVariable user: String): String {
        val req = HttpRequest.GET<Any>("https://kenkoooo.com/atcoder/atcoder-api/v3/user/ac_rank?user=$user")
            .header(HttpHeaders.ACCEPT_ENCODING, "gzip")
        return httpClient.toBlocking().retrieve(req)
    }
}

ACCEPT_ENCODINGをgzipに設定。

こうして実行すると、うまくいった。次のように表示された。

{"count":667,"rank":4165}

実装・後編

JSON文字列で結果が返ってきているので、これをパースしたい。

これをするためのライブラリはいくつかあるけど、今回はkotlinx.serializationを使ってみる。

まずbuild.gradleを編集。pluginsにkotlin("plugin.serialization") version "1.6.21"を、dependenciesにimplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0")を追加する。

次に、以下のクラスを用意。

@Serializable
data class ACData(val count: Int, val rank: Int)

JSON文字列はこのクラスに変換される。

index.htmlも変える。

<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <title>Home</title>
</head>
<body>
<span th:text="${user}">user</span>さんのAC数は<span th:text="${count}">0</span>で、順位は<span th:text="${rank}">0</span>位です。
</body>
</html>

spanタグの中にあるテキストは、実行時にはth:textの中身で置き換えられる。実行せずにそのままhtmlファイルとして開けばspanタグの中身が表示されて、表示の調整に役立つ。

最後にJSON文字列をパースしてindex.htmlに渡す部分を編集する。完成したものがこちら。

package com.example

import io.micronaut.http.HttpHeaders.ACCEPT_ENCODING
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.client.HttpClient
import io.micronaut.views.View
import jakarta.inject.Inject
import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
data class ACData(val count: Int, val rank: Int)

@Controller
class ViewController {
    @Inject lateinit var httpClient: HttpClient

    @Get("/{user}")
    @View("index")
    fun index(@PathVariable user: String): HttpResponse<*> {
        val req = HttpRequest.GET<Any>("https://kenkoooo.com/atcoder/atcoder-api/v3/user/ac_rank?user=$user")
            .header(ACCEPT_ENCODING, "gzip")
        val jsonString = httpClient.toBlocking().retrieve(req)
        val data = Json.decodeFromString<ACData>(jsonString)
        return HttpResponse.ok(mapOf("user" to user, "count" to data.count, "rank" to data.rank))
    }
}

これで実行。すると以下のように表示される。

koboshiさんのAC数は667で、順位は4165位です。

無事動いた。

実装・最終編

/koboshiを別のユーザー名に変えても動くけれど、存在しないユーザー名を入れると壊れてしまう。そこで対策をする。

まずindex.htmlを書き換える。

<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <title>Home</title>
</head>
<body>
<div th:if="${isFound}">
    <span th:text="${user}">user</span>さんのAC数は<span th:text="${count}">0</span>で、順位は<span th:text="${rank}">0</span>位です。
</div>
<div th:if="${!isFound}">
    Not Found
</div>
</body>
</html>

存在するユーザーかどうかをisFoundで判定する。

package com.example

import io.micronaut.http.HttpHeaders.ACCEPT_ENCODING
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.PathVariable
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.views.View
import jakarta.inject.Inject
import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
data class ACData(val count: Int, val rank: Int)

@Controller
class ViewController {
    @Inject lateinit var httpClient: HttpClient

    @Get("/{user}")
    @View("index")
    fun index(@PathVariable user: String): HttpResponse<*> {
        val req = HttpRequest.GET<Any>("https://kenkoooo.com/atcoder/atcoder-api/v3/user/ac_rank?user=$user")
            .header(ACCEPT_ENCODING, "gzip")
        val (isFound, count, rank) = try {
            val jsonString = httpClient.toBlocking().retrieve(req)
            val data = Json.decodeFromString<ACData>(jsonString)
            Triple(true, data.count, data.rank)
        }
        catch (e: HttpClientResponseException) {
            Triple(false, 0, 0)
        }
        return HttpResponse.ok(mapOf("isFound" to isFound, "user" to user, "count" to count, "rank" to rank))
    }
}

存在しないユーザーの場合例外が発生するので、try-catchでとらえる。もっといい書き方がありそうな気はするけど。

このようにすることで、存在しなかった場合の処理ができた。

まとめ

HttpClientを使ってAtCoder ProblemsのAPIを叩いてみた。

質問に答えてくださったntkさんに感謝します。

参考文献

guides.micronaut.io

docs.micronaut.io

lifetime-engineer.com