Skip to content

Commit

Permalink
Merge pull request Kaaveh#219 from mhmd-android/enhance/error-handling
Browse files Browse the repository at this point in the history
Enhance/error handling
  • Loading branch information
VahidGarousi authored Sep 2, 2024
2 parents dccb169 + 8980a80 commit ff93337
Show file tree
Hide file tree
Showing 29 changed files with 390 additions and 92 deletions.
1 change: 1 addition & 0 deletions core/base/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ android {
dependencies {
projects.library.apply {
api(projects.core.test)
api(projects.core.network.ktor)
implementation(projects.library.designsystem)
}
libs.apply {
Expand Down
4 changes: 3 additions & 1 deletion core/base/src/main/java/ir/composenews/base/BaseContract.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package ir.composenews.base

import ir.composenews.network.Errors

interface BaseContract :
BaseUnidirectionalViewModel<BaseContract.BaseEvent, BaseContract.BaseEffect, BaseContract.BaseState> {

sealed class BaseState {
data object OnLoading : BaseState()
data object OnLoadingDialog : BaseState()
data class OnError(val errorMessage: String) : BaseState()
data class OnError(val errors: Errors) : BaseState()
data object OnSuccess : BaseState()
}

Expand Down
15 changes: 14 additions & 1 deletion core/base/src/main/java/ir/composenews/base/BaseScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import ir.composenews.designsystem.widget.ErrorView
import ir.composenews.designsystem.widget.LoadingView
import ir.composenews.network.Errors

@Composable
fun BaseRoute(
Expand Down Expand Up @@ -69,7 +70,7 @@ private fun BaseScreen(
BaseContract.BaseState.OnLoadingDialog -> TODO()

is BaseContract.BaseState.OnError -> {
ErrorView(errorMessage = targetState.errorMessage)
ErrorView(errorMessage = errorViewMapper(targetState.errors))
}

BaseContract.BaseState.OnSuccess -> content()
Expand All @@ -78,4 +79,16 @@ private fun BaseScreen(
}
}

fun errorViewMapper(errors: Errors): String {
return when (errors) {
is Errors.ApiError -> {
errors.message ?: "Unknown Error"
}

is Errors.ExceptionError -> {
errors.message ?: "Unknown Error"
}
}
}

const val TRANSITION_DURATION = 500
1 change: 1 addition & 0 deletions core/network/ktor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
17 changes: 17 additions & 0 deletions core/network/ktor/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
plugins {
alias(libs.plugins.composenews.android.library)
libs.plugins.apply {
alias(kotlinx.serialization)
}
}

android {
namespace = "ir.composenews.ktor"

}

dependencies {
libs.apply {
implementation(bundles.ktor)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package ir.composenews.network

sealed interface ApiResponse<out T> {
data class Success<T>(val data: T) : ApiResponse<T>

/**
* There are two subtypes: [ApiResponse.Failure.Error] and [ApiResponse.Failure.Exception].
*/
sealed interface Failure<T> : ApiResponse<T> {

/**
* API communication conventions do not match or applications need to handle errors.
* e.g., internal server error and etc...
*/
data class Error(val payload: Any?) : Failure<Nothing> {
val message = payload.toString()
}

/**
* An unexpected exception occurs while creating requests or processing an response in the client side.
* e.g., network connection error, timeout and etc...
*/
data class Exception(val throwable: Throwable) : Failure<Nothing> {
val message: String? = throwable.message
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@

package ir.composenews.network

import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse

val successCode: IntRange = 200..299

fun HttpResponse.getStatusCode(): StatusCode {
return StatusCode.entries.find {
it.code == status.value
} ?: StatusCode.Unknown
}

val ApiResponse.Failure.Error.payloadResponse: HttpResponse
inline get() = (payload as? HttpResponse) ?: throw IllegalArgumentException()

val ApiResponse.Failure.Error.statusCode: StatusCode
inline get() = payloadResponse.getStatusCode()

suspend inline fun <reified T> apiResponseOf(
successCodeRange: IntRange = successCode,
crossinline f: suspend () -> HttpResponse,
): ApiResponse<T> = try {
val response = f()
if (response.status.value in successCodeRange) {
ApiResponse.Success(
data = response.body() ?: Unit as T,
)
} else {
ApiResponse.Failure.Error(response)
}
} catch (ex: Exception) {
ApiResponse.Failure.Exception(ex)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@

package ir.composenews.network

fun StatusCode.mapMessageStatusCode(): String {
return when (this) {
StatusCode.Unknown -> "Unknown"
StatusCode.BadRequest -> "Bad Request"
StatusCode.Forbidden -> "Forbidden"
StatusCode.RequestTimeout -> "Request Timeout"
StatusCode.TooManyRequests -> "Too many Requests"
StatusCode.InternalServerError -> "Internal Server Error"
StatusCode.ServiceUnavailable -> "Service Unavailable"
StatusCode.AccessDenied -> "Access Denied"
StatusCode.LimitRequest -> "Limit Request"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

package ir.composenews.network

sealed class Errors {
data class ApiError(val message: String?, val code: Int) : Errors()

data class ExceptionError(val message: String?, val throwable: Throwable? = null) : Errors()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package ir.composenews.network

import io.ktor.client.HttpClient
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.request
import io.ktor.http.HttpMethod

suspend inline fun <reified T> HttpClient.get(
builder: HttpRequestBuilder,
): ApiResponse<T> {
builder.method = HttpMethod.Get
return apiResponseOf { request(builder) }
}

suspend inline fun <reified T> HttpClient.get(
block: HttpRequestBuilder.() -> Unit,
): ApiResponse<T> = this.get(HttpRequestBuilder().apply(block))
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package ir.composenews.network

sealed class Resource<out T, out E> {
data class Success<out T>(val data: T) : Resource<T, Nothing>()
data class Error<out E>(val error: E) : Resource<Nothing, E>()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@

package ir.composenews.network

import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract

@OptIn(ExperimentalContracts::class)
inline fun <T> ApiResponse<T>.onSuccess(
crossinline onResult: ApiResponse.Success<T>.() -> Unit,
): ApiResponse<T> {
contract { callsInPlace(onResult, InvocationKind.AT_MOST_ONCE) }
if (this is ApiResponse.Success) {
onResult(this)
}
return this
}

@OptIn(ExperimentalContracts::class)
suspend inline fun <T> ApiResponse<T>.suspendOnSuccess(
crossinline onResult: suspend ApiResponse.Success<T>.() -> Unit,
): ApiResponse<T> {
contract { callsInPlace(onResult, InvocationKind.AT_MOST_ONCE) }
if (this is ApiResponse.Success) {
onResult(this)
}
return this
}

@OptIn(ExperimentalContracts::class)
suspend inline fun <T, V> ApiResponse.Success<T>.suspendMap(
crossinline mapper: suspend (ApiResponse.Success<T>) -> V,
): V {
contract { callsInPlace(mapper, InvocationKind.AT_MOST_ONCE) }
return mapper(this)
}

@OptIn(ExperimentalContracts::class)
suspend inline fun <T> ApiResponse<T>.suspendOnError(
crossinline onResult: suspend ApiResponse.Failure.Error.() -> Unit,
): ApiResponse<T> {
contract { callsInPlace(onResult, InvocationKind.AT_MOST_ONCE) }
if (this is ApiResponse.Failure.Error) {
onResult(this)
}
return this
}

@OptIn(ExperimentalContracts::class)
inline fun <T> ApiResponse<T>.onError(
crossinline onResult: ApiResponse.Failure.Error.() -> Unit,
): ApiResponse<T> {
contract { callsInPlace(onResult, InvocationKind.AT_MOST_ONCE) }
if (this is ApiResponse.Failure.Error) {
onResult(this)
}
return this
}

@OptIn(ExperimentalContracts::class)
suspend inline fun <V> ApiResponse.Failure.Error.suspendMap(
crossinline mapper: suspend (ApiResponse.Failure.Error) -> V,
): V {
contract { callsInPlace(mapper, InvocationKind.AT_MOST_ONCE) }
return mapper(this)
}

@OptIn(ExperimentalContracts::class)
suspend inline fun <T> ApiResponse<T>.suspendOnException(
crossinline onResult: suspend ApiResponse.Failure.Exception.() -> Unit,
): ApiResponse<T> {
contract { callsInPlace(onResult, InvocationKind.AT_MOST_ONCE) }
if (this is ApiResponse.Failure.Exception) {
onResult(this)
}
return this
}

@OptIn(ExperimentalContracts::class)
inline fun <T> ApiResponse<T>.onException(
crossinline onResult: ApiResponse.Failure.Exception.() -> Unit,
): ApiResponse<T> {
contract { callsInPlace(onResult, InvocationKind.AT_MOST_ONCE) }
if (this is ApiResponse.Failure.Exception) {
onResult(this)
}
return this
}

@OptIn(ExperimentalContracts::class)
suspend inline fun <V> ApiResponse.Failure.Exception.suspendMap(
crossinline mapper: suspend (ApiResponse.Failure.Exception) -> V,
): V {
contract { callsInPlace(mapper, InvocationKind.AT_MOST_ONCE) }
return mapper(this)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@

package ir.composenews.network

/**
* https://docs.coingecko.com/reference/common-errors-rate-limit
*/
enum class StatusCode(val code: Int) {
Unknown(0),
BadRequest(400),
Forbidden(403),
RequestTimeout(408),
TooManyRequests(429),
InternalServerError(500),
ServiceUnavailable(500),
AccessDenied(500),
LimitRequest(10005),
}
1 change: 1 addition & 0 deletions data/market-remote/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ android {
}

dependencies {
api(projects.core.network.ktor)
libs.apply {
implementation(bundles.ktor)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

package ir.composenews.remotedatasource.api

import ir.composenews.network.ApiResponse
import ir.composenews.remotedatasource.dto.MarketChartResponse
import ir.composenews.remotedatasource.dto.MarketDetailResponse
import ir.composenews.remotedatasource.dto.MarketResponse
Expand All @@ -13,13 +14,13 @@ interface MarketsApi {
perPage: Int,
page: Int,
sparkline: Boolean,
): List<MarketResponse>
): ApiResponse<List<MarketResponse>>

suspend fun getMarketChart(
id: String,
currency: String,
days: Int,
): MarketChartResponse
): ApiResponse<MarketChartResponse>

suspend fun getMarketDetail(id: String): MarketDetailResponse
suspend fun getMarketDetail(id: String): ApiResponse<MarketDetailResponse>
}
Loading

0 comments on commit ff93337

Please sign in to comment.