From e53eef16e130bf22c28dca426acb88dc270a40e4 Mon Sep 17 00:00:00 2001 From: logan Date: Sat, 10 May 2025 11:38:53 +0700 Subject: [PATCH] Add Ktor Http Client --- .../leegroup/module/core/util/JsonUtil.kt | 10 +++-- .../data/provider/KtorHttpClientProvider.kt | 44 +++++++++++++++++++ .../module/data/provider/RetrofitProvider.kt | 8 +--- .../support/extensions/ThrowableExt.kt | 23 +++++----- .../designsystem/ui/models/ErrorModel.kt | 13 +++++- .../designsystem/ui/models/ErrorState.kt | 2 +- .../ui/viewmodel/BaseViewModel.kt | 15 +++++-- .../module/designsystem/BaseViewModelTest.kt | 2 +- .../module/designsystem/TestErrorModel.kt | 12 ++--- .../remote/services/GitUserApiServiceImpl.kt | 35 +++++++++++++++ .../di/data/GitUserApiServiceModule.kt | 24 ++++++++++ .../di/data/GitUserKtorHttpClientModule.kt | 31 +++++++++++++ .../gituser/di/data/GitUserRetrofitModule.kt | 6 --- gradle/libs.versions.toml | 13 +++++- 14 files changed, 197 insertions(+), 41 deletions(-) create mode 100644 core/data/src/main/java/leegroup/module/data/provider/KtorHttpClientProvider.kt create mode 100644 gituser/src/main/java/leegroup/module/sample/gituser/data/remote/services/GitUserApiServiceImpl.kt create mode 100644 gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserApiServiceModule.kt create mode 100644 gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserKtorHttpClientModule.kt diff --git a/core/core-ktx/src/main/java/leegroup/module/core/util/JsonUtil.kt b/core/core-ktx/src/main/java/leegroup/module/core/util/JsonUtil.kt index 1554cc1..ac4bd16 100644 --- a/core/core-ktx/src/main/java/leegroup/module/core/util/JsonUtil.kt +++ b/core/core-ktx/src/main/java/leegroup/module/core/util/JsonUtil.kt @@ -8,10 +8,12 @@ import kotlinx.serialization.json.jsonPrimitive object JsonUtil { - val json = Json { - ignoreUnknownKeys = true - explicitNulls = false - } + val json + get() = Json { + ignoreUnknownKeys = true + explicitNulls = false + isLenient = true + } inline fun decodeFromString(value: String): T? { return try { diff --git a/core/data/src/main/java/leegroup/module/data/provider/KtorHttpClientProvider.kt b/core/data/src/main/java/leegroup/module/data/provider/KtorHttpClientProvider.kt new file mode 100644 index 0000000..ea8baeb --- /dev/null +++ b/core/data/src/main/java/leegroup/module/data/provider/KtorHttpClientProvider.kt @@ -0,0 +1,44 @@ +package leegroup.module.data.provider + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.engine.okhttp.OkHttpConfig +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.ANDROID +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.header +import io.ktor.serialization.kotlinx.json.json +import leegroup.module.core.util.JsonUtil + +object KtorHttpClientProvider { + + fun provideHttpClient( + isLoggingEnable: Boolean, + configs: (HttpClientConfig) -> Unit, + block: DefaultRequest.DefaultRequestBuilder.() -> Unit + ): HttpClient { + return HttpClient(OkHttp) { + configs(this) + install(ContentNegotiation) { + json(JsonUtil.json) + } + + if (isLoggingEnable) { + install(Logging) { + logger = Logger.ANDROID + level = LogLevel.ALL // Các mức: NONE, HEADERS, BODY, ALL + } + } + + defaultRequest { + block() + header("Accept", "application/json") + } + } + } +} \ No newline at end of file diff --git a/core/data/src/main/java/leegroup/module/data/provider/RetrofitProvider.kt b/core/data/src/main/java/leegroup/module/data/provider/RetrofitProvider.kt index cbc3955..29a9724 100644 --- a/core/data/src/main/java/leegroup/module/data/provider/RetrofitProvider.kt +++ b/core/data/src/main/java/leegroup/module/data/provider/RetrofitProvider.kt @@ -1,7 +1,7 @@ package leegroup.module.data.provider import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import kotlinx.serialization.json.Json +import leegroup.module.core.util.JsonUtil import okhttp3.MediaType.Companion.toMediaType import retrofit2.Converter import retrofit2.Retrofit @@ -9,11 +9,7 @@ import retrofit2.Retrofit object RetrofitProvider { private fun provideConverterFactory(): Converter.Factory { - val network = Json { - ignoreUnknownKeys = true - explicitNulls = false - } - return network.asConverterFactory( + return JsonUtil.json.asConverterFactory( "application/json".toMediaType() ) } diff --git a/core/designsystem/src/main/java/leegroup/module/designsystem/support/extensions/ThrowableExt.kt b/core/designsystem/src/main/java/leegroup/module/designsystem/support/extensions/ThrowableExt.kt index 3d9b9e5..81160c3 100644 --- a/core/designsystem/src/main/java/leegroup/module/designsystem/support/extensions/ThrowableExt.kt +++ b/core/designsystem/src/main/java/leegroup/module/designsystem/support/extensions/ThrowableExt.kt @@ -1,5 +1,7 @@ package leegroup.module.designsystem.support.extensions +import io.ktor.client.call.body +import io.ktor.client.plugins.ClientRequestException import leegroup.module.core.util.JsonUtil import leegroup.module.designsystem.ui.models.ErrorModel import leegroup.module.designsystem.ui.models.ErrorState @@ -9,24 +11,21 @@ import java.net.ConnectException import java.net.UnknownHostException import javax.net.ssl.SSLException -inline fun Throwable.mapApiError(): ErrorState { + +suspend inline fun Throwable.mapApiError(): ErrorState { return when (this) { is UnknownHostException, is SSLException, is InterruptedIOException -> ErrorState.Network is ConnectException -> ErrorState.Server - is HttpException -> mapHttpException(this) - else -> ErrorState.Common - } -} + is HttpException -> { + ErrorState.Api( + JsonUtil.decodeFromString(response()?.errorBody()?.string().orEmpty()) + ) + } -inline fun mapHttpException( - exception: HttpException -): ErrorState { - val errorResponse = exception.response()?.let { response -> - val jsonString = response.errorBody()?.string() ?: return@let null - JsonUtil.decodeFromString(jsonString) + is ClientRequestException -> ErrorState.Api(error = response.body()) + else -> ErrorState.Common } - return ErrorState.Api(error = errorResponse) } diff --git a/core/designsystem/src/main/java/leegroup/module/designsystem/ui/models/ErrorModel.kt b/core/designsystem/src/main/java/leegroup/module/designsystem/ui/models/ErrorModel.kt index eb6082f..7c943e4 100644 --- a/core/designsystem/src/main/java/leegroup/module/designsystem/ui/models/ErrorModel.kt +++ b/core/designsystem/src/main/java/leegroup/module/designsystem/ui/models/ErrorModel.kt @@ -6,5 +6,14 @@ import kotlinx.serialization.Serializable @Serializable open class ErrorModel( @SerialName("message") - val message: String, -) \ No newline at end of file + val message: String? = null, + @SerialName("code") + val code: Int? = null, +) { + companion object { + val UnknownErrorModel = ErrorModel( + message = "Unknown error", + code = 0, + ) + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/leegroup/module/designsystem/ui/models/ErrorState.kt b/core/designsystem/src/main/java/leegroup/module/designsystem/ui/models/ErrorState.kt index 5066216..76d7121 100644 --- a/core/designsystem/src/main/java/leegroup/module/designsystem/ui/models/ErrorState.kt +++ b/core/designsystem/src/main/java/leegroup/module/designsystem/ui/models/ErrorState.kt @@ -2,7 +2,7 @@ package leegroup.module.designsystem.ui.models import leegroup.module.designsystem.R -sealed interface ErrorState { +interface ErrorState { data object None : ErrorState interface MessageError : ErrorState { diff --git a/core/designsystem/src/main/java/leegroup/module/designsystem/ui/viewmodel/BaseViewModel.kt b/core/designsystem/src/main/java/leegroup/module/designsystem/ui/viewmodel/BaseViewModel.kt index e7aacb5..d85a857 100644 --- a/core/designsystem/src/main/java/leegroup/module/designsystem/ui/viewmodel/BaseViewModel.kt +++ b/core/designsystem/src/main/java/leegroup/module/designsystem/ui/viewmodel/BaseViewModel.kt @@ -41,9 +41,18 @@ abstract class BaseViewModel : ViewModel() { _loading.value = LoadingState.None } - protected open fun handleError(e: Throwable) { + protected open fun sendErrorState( + errorState: ErrorState + ) { + _error.tryEmit(errorState) + } + + protected open suspend fun handleError( + e: Throwable, + action: (ErrorState) -> Unit = { sendErrorState(it) } + ) { val error = e.mapApiError() - _error.tryEmit(error) + action(error) } open fun onErrorConfirmation(errorState: ErrorState) { @@ -54,7 +63,7 @@ abstract class BaseViewModel : ViewModel() { hideError() } - fun hideError() { + protected fun hideError() { _error.tryEmit(ErrorState.None) } diff --git a/core/designsystem/src/test/java/leegroup/module/designsystem/BaseViewModelTest.kt b/core/designsystem/src/test/java/leegroup/module/designsystem/BaseViewModelTest.kt index 03f62e6..a3f397b 100644 --- a/core/designsystem/src/test/java/leegroup/module/designsystem/BaseViewModelTest.kt +++ b/core/designsystem/src/test/java/leegroup/module/designsystem/BaseViewModelTest.kt @@ -129,7 +129,7 @@ class MockBaseViewModelTest { private class MockBaseViewModel : BaseViewModel() { - fun handleAction(action: Action) { + suspend fun handleAction(action: Action) { when (action) { is Action.ShowLoading -> showLoading() is Action.HideLoading -> hideLoading() diff --git a/core/designsystem/src/test/java/leegroup/module/designsystem/TestErrorModel.kt b/core/designsystem/src/test/java/leegroup/module/designsystem/TestErrorModel.kt index 3c17263..d46e228 100644 --- a/core/designsystem/src/test/java/leegroup/module/designsystem/TestErrorModel.kt +++ b/core/designsystem/src/test/java/leegroup/module/designsystem/TestErrorModel.kt @@ -2,6 +2,7 @@ package leegroup.module.designsystem import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.test.runTest import leegroup.module.designsystem.support.extensions.mapApiError import leegroup.module.designsystem.ui.models.ErrorModel import leegroup.module.designsystem.ui.models.ErrorState @@ -16,7 +17,7 @@ import javax.net.ssl.SSLException class MapApiErrorTest { @Test - fun `mapApiError returns Network ErrorState for network-related exceptions`() { + fun `mapApiError returns Network ErrorState for network-related exceptions`() = runTest { val networkExceptions = listOf( UnknownHostException(), SSLException("SSL error"), @@ -30,14 +31,14 @@ class MapApiErrorTest { } @Test - fun `mapApiError returns Server ErrorState for ConnectException`() { + fun `mapApiError returns Server ErrorState for ConnectException`() = runTest { val exception = ConnectException() val result = exception.mapApiError() assertEquals(ErrorState.Server, result) } @Test - fun `mapApiError returns Api ErrorState for HttpException`() { + fun `mapApiError returns Api ErrorState for HttpException`() = runTest { val mockResponse: Response = mockk() every { mockResponse.errorBody()?.string() } returns """{"message":"API error occurred"}""" every { mockResponse.code() } returns 400 @@ -51,7 +52,7 @@ class MapApiErrorTest { } @Test - fun `mapApiError returns Common ErrorState for other exceptions`() { + fun `mapApiError returns Common ErrorState for other exceptions`() = runTest { val exception = IllegalArgumentException("Some error") val result = exception.mapApiError() assertEquals(ErrorState.Common, result) @@ -69,7 +70,8 @@ class MapApiErrorTest { } @Test - fun `mapApiError returns Api ErrorState with null error for HttpException with invalid body`() { + fun `mapApiError returns Api ErrorState with null error for HttpException with invalid body`() = + runTest { val httpException = MockUtil.apiErrorInvalidMessage val result = httpException.mapApiError() diff --git a/gituser/src/main/java/leegroup/module/sample/gituser/data/remote/services/GitUserApiServiceImpl.kt b/gituser/src/main/java/leegroup/module/sample/gituser/data/remote/services/GitUserApiServiceImpl.kt new file mode 100644 index 0000000..e5a8664 --- /dev/null +++ b/gituser/src/main/java/leegroup/module/sample/gituser/data/remote/services/GitUserApiServiceImpl.kt @@ -0,0 +1,35 @@ +package leegroup.module.sample.gituser.data.remote.services + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import leegroup.module.sample.gituser.data.models.GitUser +import leegroup.module.sample.gituser.data.models.GitUserDetail +import leegroup.module.sample.gituser.di.data.GitUserKtorHttpClientModule.Companion.GIT_USER_KTOR_HTTP_CLIENT +import javax.inject.Inject +import javax.inject.Named + +internal class GitUserApiServiceImpl @Inject constructor( + @Named(GIT_USER_KTOR_HTTP_CLIENT) private val httpClient: HttpClient +) : GitUserApiService { + override suspend fun getGitUser( + since: Long, + perPage: Int + ): List { + return httpClient.get(GET_USER) { + url { + parameters.append("since", since.toString()) + parameters.append("per_page", perPage.toString()) + } + }.body() + } + + override suspend fun getGitUserDetail(login: String): GitUserDetail { + return httpClient.get(GET_USER_DETAIL + login).body() + } + + companion object { + private const val GET_USER = "users" + private const val GET_USER_DETAIL = "users/" + } +} \ No newline at end of file diff --git a/gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserApiServiceModule.kt b/gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserApiServiceModule.kt new file mode 100644 index 0000000..0f239af --- /dev/null +++ b/gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserApiServiceModule.kt @@ -0,0 +1,24 @@ +package leegroup.module.sample.gituser.di.data + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import leegroup.module.sample.gituser.data.remote.services.GitUserApiService +import leegroup.module.sample.gituser.data.remote.services.GitUserApiServiceImpl + +@Module +@InstallIn(SingletonComponent::class) +internal class GitUserApiServiceModule { + +// @Provides +// fun provideService(@GitUserRetrofit retrofit: Retrofit): GitUserApiService { +// return retrofit.create(GitUserApiService::class.java) +// } + + + @Provides + fun provideService(gitUserApiServiceImpl: GitUserApiServiceImpl): GitUserApiService { + return gitUserApiServiceImpl + } +} \ No newline at end of file diff --git a/gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserKtorHttpClientModule.kt b/gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserKtorHttpClientModule.kt new file mode 100644 index 0000000..07b71ae --- /dev/null +++ b/gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserKtorHttpClientModule.kt @@ -0,0 +1,31 @@ +package leegroup.module.sample.gituser.di.data + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.ktor.client.HttpClient +import leegroup.module.data.provider.KtorHttpClientProvider +import leegroup.module.sample.gituser.BuildConfig +import javax.inject.Named + +@Module +@InstallIn(SingletonComponent::class) +internal class GitUserKtorHttpClientModule { + + @Named(GIT_USER_KTOR_HTTP_CLIENT) + @Provides + fun provideGitUserKtorHttpClient(): HttpClient { + return KtorHttpClientProvider.provideHttpClient( + isLoggingEnable = BuildConfig.DEBUG, + configs = {}, + block = { + url(BuildConfig.BASE_API_URL) + } + ) + } + + companion object { + const val GIT_USER_KTOR_HTTP_CLIENT = "gitUserKtorHttpClient" + } +} \ No newline at end of file diff --git a/gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserRetrofitModule.kt b/gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserRetrofitModule.kt index d88a119..970f7be 100644 --- a/gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserRetrofitModule.kt +++ b/gituser/src/main/java/leegroup/module/sample/gituser/di/data/GitUserRetrofitModule.kt @@ -6,7 +6,6 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import leegroup.module.data.provider.RetrofitProvider import leegroup.module.sample.gituser.BuildConfig -import leegroup.module.sample.gituser.data.remote.services.GitUserApiService import retrofit2.Retrofit import javax.inject.Qualifier @@ -26,9 +25,4 @@ internal class GitUserRetrofitModule { baseUrl = BuildConfig.BASE_API_URL ) } - - @Provides - fun provideService(@GitUserRetrofit retrofit: Retrofit): GitUserApiService { - return retrofit.create(GitUserApiService::class.java) - } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5bd3db5..0a46999 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,11 @@ kotlinxSerializationJson = "1.8.0" kover = "0.9.1" ksp = "2.1.10-1.0.30" +ktorClientContentNegotiation = "3.1.2" +ktorClientCore = "3.1.2" +ktorClientLogging = "3.1.2" +ktorClientOkhttp = "3.1.2" +ktorSerializationKotlinxJson = "3.1.2" lifecycleRuntimeKtx = "2.8.7" leakcanary = "2.14" @@ -112,6 +117,11 @@ firebase-inappmessaging-display-ktx = { module = "com.google.firebase:firebase-i firebase-messaging-ktx = { module = "com.google.firebase:firebase-messaging-ktx" } firebase-perf = { module = "com.google.firebase:firebase-perf" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorClientContentNegotiation" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktorClientLogging" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktorClientOkhttp" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientCore" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorSerializationKotlinxJson" } library-chucker = { module = "com.github.chuckerteam.chucker:library", version.ref = "chucker" } library-chucker-no-op = { module = "com.github.chuckerteam.chucker:library-no-op", version.ref = "chucker" } @@ -162,7 +172,8 @@ material = { group = "com.google.android.material", name = "material", version.r androidx-lifecycle = ["androidx-lifecycle-runtime-ktx", "androidx-lifecycle-viewmodel-ktx", "androidx-lifecycle-process", "androidx-lifecycle-viewmodel-compose"] coil = ["coil", "coil-compose", "coil-gif", "coil-svg"] firebase = ["firebase-analytics", "firebase-config", "firebase-crashlytics", "firebase-inappmessaging-display-ktx", "firebase-messaging-ktx", "firebase-perf"] -networking = ["retrofit", "okhttp", "okhttp-logging-interceptor", "retrofit2-kotlin-coroutines-adapter", "retrofit2-kotlinx-serialization-converter", "okhttp-dnsoverhttps"] +networking = ["retrofit", "okhttp", "okhttp-logging-interceptor", "retrofit2-kotlin-coroutines-adapter", "retrofit2-kotlinx-serialization-converter", "okhttp-dnsoverhttps", + "ktor-client-core", "ktor-client-okhttp", "ktor-client-content-negotiation", "ktor-serialization-kotlinx-json", "ktor-client-logging"] room = ["room-runtime", "room-ktx"] test = ["junit", "androidx-core", "turbine", "kotest-assertions-core", "room-testing", "mockk", "kotlinx-coroutines-test", "robolectric"]