開発ブログ

株式会社Nextatのスタッフがお送りする技術コラムメインのブログ。

電話でのお問合わせ 075-744-6842 ([月]-[金] 10:00〜17:00)

  1. top >
  2. 開発ブログ >
  3. Kotlin >
  4. 【Compose Multiplatform】Ktorを使用したHTTP通信とRepositoryの実装

【Compose Multiplatform】Ktorを使用したHTTP通信とRepositoryの実装

logo.png
みなさんこんにちは。今回はCompose Multiplatform記事の7回目になります。
HTTP通信処理を実装してきましょう。

これまでのCompose Multiplatform記事はこちら
Compose Multiplatform入門(環境構築編)
Compose Multiplatform入門(プロジェクト作成編)
Compose Multiplatform入門(画面遷移編)
Compose Multiplatform入門(ロギング編)
KMPAuthを使用したFirebaseソーシャルログイン(Android編)
KMPAuthを使用したFirebaseソーシャルログイン(iOS編)
 

Ktorを使用したHTTP通信

Ktorとは、KotlinとCompose Multiplatformの開発をしているJetBrainsによってサポートされている非同期クライアントおよびサーバ用ライブラリです。
https://ktor.io/
公式がサポートしているだけあって安心感がある気がします。

環境構築

Ktorを使用するために、いつものようにlibs.versions.toml、build.gradle.ktsを更新しましょう

libs.versions.toml

Ktorはkotlinx.coroutinesとの組み合わせで使用するので、kotlinx.coroutinesも合わせて追加します。

[versions]
ktor = "2.3.9"
kotlinx-coroutine = "1.7.3"

[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutine" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutine" }
ktor-client-content-negociation = { group = "ktor", module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { group = "ktor", module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-darwin = { group = "ktor", module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-logging = { group = "ktor", module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-okhttp = { group = "ktor", module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }

build.gradle.kts


kotlin {
    sourceSets {
        androidMain.dependencies {
            implementation(libs.kotlinx.coroutines.android)
            implementation(libs.ktor.client.okhttp)
        }

        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.content.negociation)
            implementation(libs.ktor.client.logging)
        }

        iosMain {
            dependencies {
                implementation(libs.ktor.client.darwin)
            }
        }
    }
}

自己署名証明書を信頼させる方法

開発時はHTTPS通信時に自己署名証明書(オレオレ証明書)を利用する方も多いかと思いますが、その場合は自己署名証明書を信頼するための手続きがAndroid、iOS両方に必要になるので
各プラットフォームごとにHttpClientを用意し、どんな証明書でも信頼する処理を追加する必要があります。

commonMain/kotlin/data/http/HttpClient.kt

以前のロギングの記事で使用した、Kermitを通信時のログに仕込んでいます。

package data.http

import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging

internal val HttpClient by lazy {
    HttpClient {
        configure()
    }
}

internal fun HttpClientConfig<*>.configure() {

    install(Logging) {
        level = LogLevel.ALL
        logger = object : Logger {
            override fun log(message: String) {
                co.touchlab.kermit.Logger.d(tag = "KtorClient", messageString = message)
            }
        }
    }
    configureForPlatform()
}

internal expect fun HttpClientConfig<*>.configureForPlatform()

androidMain/kotlin/data/http/HttpClient.android.kt


package data.http

import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.okhttp.OkHttpConfig
import java.security.SecureRandom
import javax.net.ssl.SSLContext

internal actual fun HttpClientConfig<*>.configureForPlatform() {
    engine {
        this as OkHttpConfig
        config {
            val trustAllCert = AllCertsTrustManager()
            val sslContext = SSLContext.getInstance("SSL")
            sslContext.init(null, arrayOf(trustAllCert), SecureRandom())
            sslSocketFactory(sslContext.socketFactory, trustAllCert)
        }
    }
}

androidMain/kotlin/data/http/AllCertsTrustManager.kt


package data.http

import java.security.cert.X509Certificate
import javax.net.ssl.X509TrustManager

internal class AllCertsTrustManager : X509TrustManager {

    @Suppress("TrustAllX509TrustManager")
    override fun checkServerTrusted(
        chain: Array<X509Certificate>,
        authType: String,
    ) {
        // no-op
    }

    @Suppress("TrustAllX509TrustManager")
    override fun checkClientTrusted(
        chain: Array<X509Certificate>,
        authType: String,
    ) {
        // no-op
    }

    override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}

iosMain/kotlin/data/http/TrustAllChallengeHandler.kt


package data.http

import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.darwin.DarwinClientEngineConfig

internal actual fun HttpClientConfig<*>.configureForPlatform() {
    engine {
        this as DarwinClientEngineConfig
        handleChallenge(TrustAllChallengeHandler())
    }
}

iosMain/kotlin/data/http/TrustAllChallengeHandler.kt


internal class TrustAllChallengeHandler : ChallengeHandler {
    @OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
    override fun invoke(
        session: NSURLSession,
        task: NSURLSessionTask,
        challenge: NSURLAuthenticationChallenge,
        completionHandler: (NSURLSessionAuthChallengeDisposition, NSURLCredential?) -> Unit
    ) {
        // Check that we want to handle this kind of challenge
        val protectionSpace = challenge.protectionSpace
        if (protectionSpace.authenticationMethod != NSURLAuthenticationMethodServerTrust) {
            // Not a 'NSURLAuthenticationMethodServerTrust', default handling...
            completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, null)
            return
        }

        val serverTrust = challenge.protectionSpace.serverTrust
        if (serverTrust == null) {
            // Server trust is null, default handling...
            completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, null)
            return
        }

        // Get the servers certs
        val certChain = SecTrustCopyCertificateChain(serverTrust)
        // Set those certs as trusted anchors
        SecTrustSetAnchorCertificates(serverTrust, certChain)

        if (serverTrust.trustIsValid()) {
            // ✔ Server trust is valid, continue...
            val credential = NSURLCredential.credentialForTrust(serverTrust)
            completionHandler(NSURLSessionAuthChallengeUseCredential, credential)
        } else {
            // ✖ Server trust not valid, cancel challenge...
            completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null)
        }
    }
}

/**
 * Evaluates trust for the specified certificate and policies.
 */
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
private fun SecTrustRef.trustIsValid(): Boolean {
    var isValid = false

    val version = cValue<NSOperatingSystemVersion> {
        majorVersion = 12
        minorVersion = 0
        patchVersion = 0
    }
    if (NSProcessInfo().isOperatingSystemAtLeastVersion(version)) {
        memScoped {
            val result = alloc<CFErrorRefVar>()
            // https://developer.apple.com/documentation/security/2980705-sectrustevaluatewitherror
            isValid = SecTrustEvaluateWithError(this@trustIsValid, result.ptr)
        }
    } else {
        // https://developer.apple.com/documentation/security/1394363-sectrustevaluate
        memScoped {
            val result = alloc<SecTrustResultTypeVar>()
            result.value = kSecTrustResultInvalid
            val status = SecTrustEvaluate(this@trustIsValid, result.ptr)
            if (status == errSecSuccess) {
                isValid = result.value == kSecTrustResultUnspecified ||
                        result.value == kSecTrustResultProceed
            }
        }
    }

    return isValid
}

Info.plistの更新


<code>
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSExceptionDomains</key>
        <dict>
            <key>example.com</key>
            <dict>
                <key>NSExceptionAllowsInsecureHTTPLoads</key>
                <true/>
            </dict>
        </dict>
    </dict>
</code>

API, Repository, Moduleの追加

通信を受け取るための各種クラスを追加していきます。今回は自前でサーバを立ててJSONでレスポンスを受け取ってモデルに変換する...などということはせず、簡単のため、例示用のドメインのHTTPサーバの https://example.com/test  にアクセスしてステータスコードを返すだけのAPIクラスを作成します。
成功レスポンスが返ってきたら無事に実装完了、ということにします。

commonMain/kotlin/domain/test/TestRepository.kt


package domain.test

import domain.Resource
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.flow.Flow

interface TestRepository {
    fun streamTest(): Flow<Resource<HttpStatusCode>>
    suspend fun testConnection()
}

commonMain/data/api/TestApi.kt


package data.api

import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode

class TestApi(
    private val baseUrl: String,
    private val client: HttpClient
) {

    suspend fun get(): HttpStatusCode {
        return client.get("$baseUrl/test").status
    }
}

commonMain/kotlin/domain/test/TestRemoteDataSource.kt


package domain.test

import data.api.TestApi
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext

class TestRemoteDataSource(
    private val testApi: TestApi,
    private val ioDispatcher: CoroutineDispatcher,
) {
    suspend fun get(): HttpStatusCode {
        val status: HttpStatusCode = withContext(ioDispatcher) {
            try {
                val res = testApi.get()
                res.status
            } catch (e: Exception) {
                throw e
            }
        }

        return status
    }
}

commonMain/kotlin/domain/Resource.kt


package domain

enum class ErrorCode {
    NETWORK_NOT_AVAILABLE,
    NETWORK_CONNECTION_FAILED,
}

data class Error(
    val code: ErrorCode,
)

data class Resource<out T>(val status: Status, val data: T?, val error: Error?) {
    companion object {

        fun <T> success(data: T): Resource<T> = Resource(
            status = Status.SUCCESS,
            data = data,
            error = null,
        )

        fun <T> loading(data: T? = null): Resource<T> = Resource(
            status = Status.LOADING,
            data = data,
            error = null,
        )

        fun <T> error(data: T? = null, error: Error? = null): Resource<T> = Resource(
            status = Status.ERROR,
            data = data,
            error = error,
        )

        fun <T> idle(data: T?) = Resource(status = Status.INIT, data = data, error = null)
    }

    enum class Status {
        INIT,
        SUCCESS,
        ERROR,
        LOADING,
    }
}

commonMain/kotkin/data/test/TestRepositoryImpl.kt


package data.test

import domain.Error
import domain.ErrorCode
import domain.Resource
import domain.test.TestRemoteDataSource
import domain.test.TestRepository
import io.ktor.http.HttpStatusCode
import io.ktor.http.isSuccess
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow

class TestRepositoryImpl(
    private val testRemoteDataSource: TestRemoteDataSource
) : TestRepository {

    private val testFlowMap = mutableMapOf<Int, MutableStateFlow<Resource<HttpStatusCode>>>()

    override fun streamTest(): Flow<Resource<HttpStatusCode>> = getOrCreateTestFlow().asSharedFlow()

    override suspend fun testConnection() {

        val testFlow = getOrCreateTestFlow()
        testFlow.value = Resource.loading(testFlow.value.data)
        try {
            val test = testRemoteDataSource.get()
            if(test.isSuccess()) {
                testFlow.value = Resource.success(test)
            }
            else {
                testFlow.value = Resource.error(testFlow.value.data, Error(code = ErrorCode.NETWORK_CONNECTION_FAILED))
            }
        } catch (ex: Exception) {
            testFlow.value = Resource.error(testFlow.value.data, Error(code = ErrorCode.NETWORK_CONNECTION_FAILED))
        }

    }

    private fun getOrCreateTestFlow(): MutableStateFlow<Resource<HttpStatusCode>> {
        var testFlow = testFlowMap[0]
        if(testFlow != null) {
            return testFlow
        }

        testFlow = MutableStateFlow(Resource.idle(null))
        testFlowMap[0] = testFlow
        return testFlow
    }
}

commonMain/kotlin/modules/ApiModule.kt


package modules

import data.api.TestApi
import data.http.HttpClient
import org.koin.dsl.module

val apiModule = module {
    val baseUri = "https://example.com"

    single {
        TestApi(
            baseUrl = baseUri,
            client = HttpClient,
        )
    }
}

commonMain/kotlin/modules/IOModule.kt


package modules

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import org.koin.core.qualifier.named
import org.koin.dsl.module

val ioModule = module {
    single(named("IODispatcher")) {
        Dispatchers.IO
    }
}

commonMain/kotlin/modules/TestModule.kt


package modules

import data.test.TestRepositoryImpl
import domain.test.TestRemoteDataSource
import domain.test.TestRepository
import org.koin.core.qualifier.named
import org.koin.dsl.module

val testModule = module {
    single {
        TestRemoteDataSource(
            testApi = get(),
            ioDispatcher = get(named("IODispatcher")),
        )
    }
    single<TestRepository> {
        TestRepositoryImpl(get())
    }
}

commonMain/kotlin/modules/ViewModelModule.kt


package modules

import Greeting
import org.koin.dsl.module
import ui.screens.another.AnotherScreenViewModel
import ui.screens.home.HomeScreenViewModel

val viewModelModule = module {
    factory {
        HomeScreenViewModel(get())
    }
    factory {
        Greeting()
    }
    factory {
        params -> AnotherScreenViewModel(postId = params.get(), get())
    }
}

commonMain/kotlin/modules/AppModule.kt


package modules

import org.koin.core.module.Module

fun appModules(): List<Module> = listOf(
    apiModule,
    ioModule,
    testModule,
    viewModelModule
)

Screen、ViewModelの更新

さて、ここまで書いてようやく準備ができました。あとはViewとViewModelを更新して実際に通信の処理を動かしてみましょう。
画面遷移の実装の記事で追加したAnotherScreenに通信を開始するボタンを追加します。

AnotherScreen.kt


package ui.screens.another

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.lifecycle.LifecycleEffect
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import co.touchlab.kermit.Logger
import org.koin.core.parameter.parametersOf

class AnotherScreen(private val postId: String) : Screen {

    @Composable
    override fun Content() {

        val viewModel = getScreenModel<AnotherScreenViewModel>{ parametersOf(postId) }
        val navigator = LocalNavigator.currentOrThrow

        val uiState = viewModel.uiState.collectAsState()
        val uiStateValue = uiState.value

        LifecycleEffect(
            onStarted = { Logger.i { "Open Another Screen" } }
        )

        Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
            Button(onClick = { navigator.pop() }) {
                Text("戻る")
            }

            Button(onClick = { viewModel.testConnection() }) {
                Text("通信を開始")
            }

            when (uiStateValue) {
                is AnotherScreenViewModel.State.Init -> Init()
                is AnotherScreenViewModel.State.Loading -> Loading()
                is AnotherScreenViewModel.State.Success -> Success(uiStateValue.statusCode)
                is AnotherScreenViewModel.State.Error -> Error(uiStateValue.message)
            }
        }
    }

    @Composable
    private fun Init() {
        Text("初期状態")
    }

    @Composable
    private fun Loading() {
        Text("読み込み中...")
    }

    @Composable
    private fun Success(statusCode: Int) {
        Text("通信成功 StatusCode:$statusCode")
    }

    @Composable
    private fun Error(message: String) {
        Text("エラー: $message")
    }
}

AnotherScreenViewModel.kt


package ui.screens.another

import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import domain.Resource
import domain.test.TestRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class AnotherScreenViewModel(
    private val postId: String,
    private val testRepository: TestRepository
) : ScreenModel {

    sealed class State {
        data object Init: State()
        data object Loading: State()
        data class Success(val events: List<Event>): State()
        data class Error(val message: String): State()
    }

    val uiState = testRepository.streamTest().map {
             testResource ->
             when(testResource.status) {
                Resource.Status.INIT -> State.Init
                Resource.Status.LOADING -> State.Loading
                Resource.Status.SUCCESS -> when(testResource.data) {
                         null -> State.Error("読み込み失敗")
                         else -> State.Success(testResource.data.value)
                }
                Resource.Status.ERROR -> State.Error("読み込み失敗")
            }
    }.stateIn(screenModelScope, SharingStarted.WhileSubscribed(500L), State.Init)


    fun testConnection() {
        screenModelScope.launch {
            testRepository.testConnection()
        }
    }
}

動作確認

実際に動かしてみましょう。
http-connection-success-1.png
「通信成功」と表示されればOKです!

さいごに

最後までご覧いただきありがとうございました。
Compose Multiplatformの週刊連載ブログも、次回が最終回になります。
  • posted by りっちゃん
  • Kotlin
TOPに戻る