Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reified T> decodeFromString(value: String): T? {
return try {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OkHttpConfig>) -> 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")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
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

object RetrofitProvider {

private fun provideConverterFactory(): Converter.Factory {
val network = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
return network.asConverterFactory(
return JsonUtil.json.asConverterFactory(
"application/json".toMediaType()
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,24 +11,21 @@ import java.net.ConnectException
import java.net.UnknownHostException
import javax.net.ssl.SSLException

inline fun <reified Model : ErrorModel> Throwable.mapApiError(): ErrorState {

suspend inline fun <reified Model : ErrorModel> Throwable.mapApiError(): ErrorState {
return when (this) {
is UnknownHostException,
is SSLException,
is InterruptedIOException -> ErrorState.Network

is ConnectException -> ErrorState.Server
is HttpException -> mapHttpException<Model>(this)
else -> ErrorState.Common
}
}
is HttpException -> {
ErrorState.Api(
JsonUtil.decodeFromString<Model>(response()?.errorBody()?.string().orEmpty())
)
}

inline fun <reified Model : ErrorModel> mapHttpException(
exception: HttpException
): ErrorState {
val errorResponse = exception.response()?.let { response ->
val jsonString = response.errorBody()?.string() ?: return@let null
JsonUtil.decodeFromString<Model>(jsonString)
is ClientRequestException -> ErrorState.Api(error = response.body<Model>())
else -> ErrorState.Common
}
return ErrorState.Api(error = errorResponse)
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,14 @@ import kotlinx.serialization.Serializable
@Serializable
open class ErrorModel(
@SerialName("message")
val message: String,
)
val message: String? = null,
@SerialName("code")
val code: Int? = null,
) {
companion object {
val UnknownErrorModel = ErrorModel(
message = "Unknown error",
code = 0,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ErrorModel>()
_error.tryEmit(error)
action(error)
}

open fun onErrorConfirmation(errorState: ErrorState) {
Expand All @@ -54,7 +63,7 @@ abstract class BaseViewModel : ViewModel() {
hideError()
}

fun hideError() {
protected fun hideError() {
_error.tryEmit(ErrorState.None)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
Expand All @@ -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<ErrorModel>()
assertEquals(ErrorState.Server, result)
}

@Test
fun `mapApiError returns Api ErrorState for HttpException`() {
fun `mapApiError returns Api ErrorState for HttpException`() = runTest {
val mockResponse: Response<Unit> = mockk()
every { mockResponse.errorBody()?.string() } returns """{"message":"API error occurred"}"""
every { mockResponse.code() } returns 400
Expand All @@ -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<ErrorModel>()
assertEquals(ErrorState.Common, result)
Expand All @@ -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<ErrorModel>()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<GitUser> {
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/"
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}
}
13 changes: 12 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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" }

Expand Down Expand Up @@ -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"]

Expand Down