mcpField
K
kmp-ktor

kmp-ktor

github.com/rcosteira79開発165146 回インストール6 週間前に更新
リポジトリ

kmp-ktor について

Kotlin Multiplatform と Android 向けの Ktor クライアント設定を扱うリファレンススキルです。基本設定ではなく、プラグインの順序、トークン更新、シリアライズ、テストの注意点に焦点を当てています。

主な機能

  1. 01Ktorプラグインのインストール順序
  2. 02シリアライズのデフォルト値設定
  3. 03Bearerトークン更新の処理
  4. 04SSEとWebSocketのコンバーター
  5. 05エラー変換とMockEngineテスト

活用シーン

  1. 01タイムアウトが再試行されない問題の調査
  2. 02トークン更新リクエストのループ防止
  3. 03共有Ktorクライアント設定のテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/rcosteira79/android-skills --skill kmp-ktor

インストール後、Claude Code で/kmp-ktorを入力して読み込まれたか確認します。

詳細

Ktor Client for KMP and Android

このリファレンスでは、Ktor クライアントの設定でつまずきやすい点、つまりプラグインのインストール順序、シリアライズのフラグ、認証の更新、エラーのマッピングを扱います。共有 HttpClient、エンジンの選択、ContentNegotiation の設定といった基本事項は扱いません。関連: android-skills:android-data-layer(リポジトリと DataError エラーモデル — その正式な配置場所)、android-skills:android-retrofit(Android のみを対象とした同等の内容)。

プラグインのインストール順序 — HttpRequestRetry は HttpTimeout より先に

最も間違えやすいインストール順序は、HttpTimeout を HttpRequestRetry より先にインストールすることです。送信リクエストではプラグインがインストール順に実行されます。リトライでタイムアウトエラーを捕捉できるように、リトライがタイムアウトをラップする必要があります。

val json = Json { ignoreUnknownKeys = true; coerceInputValues = true; encodeDefaults = true }  // encodeDefaults: see the section below

HttpClient(engine) {
    install(ContentNegotiation) { json(json) }
    install(Auth) { bearer { /* loadTokens / refreshTokens */ } }
    install(HttpRequestRetry) {                 // BEFORE HttpTimeout
        retryOnServerErrors(maxRetries = 3)
        exponentialDelay()
    }
    install(HttpTimeout) {                       // AFTER HttpRequestRetry
        requestTimeoutMillis = 30_000; connectTimeoutMillis = 15_000; socketTimeoutMillis = 15_000
    }
}

逆の順序では、HttpTimeout がリトライプラグインより先にリクエストを失敗として処理するため、タイムアウトはリトライされません。(一方、Auth プラグインは 401 の更新を個別に処理します。HttpRequestRetry では一時的な障害や 5xx の失敗を処理し、同じステータスコードに対して両者を連鎖させないでください。)

encodeDefaults = true — そうしないとプロトコル定数のフィールドがひそかに消えます

kotlinx.serialization のデフォルトは encodeDefaults = false です。この設定では、宣言時のデフォルト値と一致する値を持つプロパティがすべてシリアライズ結果から除外されます。jsonrpc: String = "2.0"(または version = "1.0"、type = "...")がペイロードから消え、サーバーはすべてのリクエストを汎用的な「invalid request」として拒否します。修正はフラグを一行追加するだけですが、HTTP 層の誤った手掛かりを何時間も追いかけた末にしか見つからないことがあります。クライアント API では必ず設定してください。このファイルの先頭で定義されている val json では、ignoreUnknownKeys と coerceInputValues とともに設定されています。クライアント全体で共有するのは、この設定済みインスタンスです。install(ContentNegotiation) { json(json) } と WebSocket コンバーターの両方で使用します。

expectSuccess — どちらかのモデルを選び、一貫して使う

expectSuccess = true にすると、2xx 以外のレスポンスで Ktor が ClientRequestException(4xx)または ServerResponseException(5xx)をスローします。このスローは手動のステータスチェックより先に実行されるため、その後の if (response.status == OK) 分岐は到達不能なコードになります。プロジェクト全体でどちらか一方のモデルを選んでください。expectSuccess = true と try/catch の組み合わせ(リポジトリのパターンに一致)、または expectSuccess = false と response.status.isSuccess() による明示的な検査です。両者を混在させないでください。

Bearer の更新 — markAsRefreshTokenRequest() を使わないとループします

Auth の bearer { refreshTokens { … } } ブロック内では、更新 POST に markAsRefreshTokenRequest() を指定し、同じ Auth プラグインにインターセプトされないようにしてください。指定しないと、更新の失敗によって再度更新が実行され、無限ループになります。これは HttpRequestBuilder の拡張関数です。refreshTokens { } 内で単独で呼び出すのではなく、リクエストビルダーブロック内で呼び出してください(単独で呼び出すとコンパイルできません)。

install(Auth) {
    bearer {
        loadTokens { tokenStorage.getTokens()?.let { BearerTokens(it.access, it.refresh) } }
        refreshTokens {
            val refresh = oldTokens?.refreshToken ?: return@refreshTokens null
            val r = client.post("auth/refresh") {
                markAsRefreshTokenRequest()                      // skip the Auth plugin for this call
                setBody(RefreshRequestDto(refresh))
            }.body<TokenResponseDto>()
            tokenStorage.save(r.accessToken, r.refreshToken); BearerTokens(r.accessToken, r.refreshToken)
        }
        sendWithoutRequest { it.url.pathSegments.none { seg -> seg in listOf("login", "register") } }
    }
}

BearerTokens はプラグインとの境界で使用し、それ以外のアプリでは独自のトークン型を使ってください。TokenStorage はプロジェクトで定義します(Android/JVM では DataStore、iOS では Keychain)。

WebSockets と SSE — シリアライズコンバーターを使う

リアルタイムのトランスポートでは、kotlinx.serialization コンバーターをインストールして、型付きメッセージが ContentNegotiation と同じ Json 設定を使って送受信されるようにします。これがないと、Frame.Text を手動でエンコード/デコードすることになります。(SSE はサーバーからクライアントへの一方向通信で、通常の HTTP を使用し、組み込みの再接続機能があります。WebSocket は双方向通信で、再接続は手動で行い、バイナリフレームにも対応します。クライアントが受信のみの場合は、SSE をデフォルトにしてください。)

val client = HttpClient(engine) {
    install(WebSockets) {
        pingIntervalMillis = 30_000
        contentConverter = KotlinxWebsocketSerializationConverter(json)  // the shared configured instance — bare `Json` reverts to encodeDefaults = false
    }
    install(SSE)
}

client.webSocket("wss://api.example.com/ws") {
    sendSerialized(SubscribeMessage(topic = "items"))
    while (true) { val msg = receiveDeserialized<ServerMessage>(); /* handle */ }
}

// SSE — incoming is a Flow<ServerSentEvent>
client.sse("https://api.example.com/events") { incoming.collect { event -> /* event.event / event.data / event.id */ } }

SSE/WebSocket のコレクションは LaunchedEffect またはリポジトリのコルーチンでラップし、コンシューマーがいなくなったときにキャンセルによって HTTP 接続が閉じるようにしてください。

エラーのマッピングとテスト

リポジトリ層では、ClientRequestException/ServerResponseException/HttpRequestTimeoutException/IOException などの具体的なKtor 型を捕捉し、DataError にマッピングしてください。catch (e: Exception) を使うと CancellationException まで捕捉してしまいます。リポジトリのパターン全体と DataError の分類については、android-skills:android-data-layer を参照してください。エラーごとにより詳細な UI 状態(Unauthorized、RateLimited、Forbidden、…)を扱う場合は、expectSuccess = false を設定した sealed ApiResult<T> と safeRequest ラッパーを使う方法もあります。プロジェクトごとにどちらかを選んでください。

テストで MockEngine に差し替えられるよう HttpClientEngine を注入し、本番環境の createHttpClient ファクトリーを再利用して、プラグイン構成を一致させてください。

val mockEngine = MockEngine { request ->
    assertEquals("/users/42", request.url.encodedPath)
    respond("""{"id":"42","name":"Ada","created_at":1700000000000}""",
        HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"))
}
val repo = UserRepository(UserService(createHttpClient(mockEngine, baseUrl = "https://api.example.com/")))

コメント