Updated on 2026-08-14

This commit is contained in:
Tangem 2023-09-27 14:44:19 +03:00
parent f3f29813e0
commit b74e2b9168
25 changed files with 405 additions and 72 deletions

View file

@ -0,0 +1,40 @@
package com.tangem.datasource.api.common.response
/**
* Represents the possible responses from an API request.
*
* @param T The type of the data that is expected in a successful response.
*/
sealed class ApiResponse<T : Any> {
/**
* Represents a successful response from the API.
*
* @property data The data returned by the API.
*/
data class Success<T : Any>(val data: T) : ApiResponse<T>()
/**
* Represents an error response or failure from the API.
*
* @property cause The cause of the error.
*/
data class Error(val cause: ApiResponseError) : ApiResponse<Nothing>()
}
/**
* Wraps data in a [ApiResponse.Success] instance.
*
* @param data The data to wrap.
* @return A [ApiResponse.Success] instance containing the provided data.
*/
internal fun <T : Any> apiSuccess(data: T): ApiResponse<T> = ApiResponse.Success(data)
/**
* Wraps an [ApiResponseError] in a [ApiResponse.Error] instance.
*
* @param cause The error to wrap.
* @return A [ApiResponse.Error] instance containing the provided error.
*/
@Suppress("UNCHECKED_CAST")
internal fun <T : Any> apiError(cause: ApiResponseError): ApiResponse<T> = ApiResponse.Error(cause) as ApiResponse<T>

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.api.common.response
import retrofit2.Call
import retrofit2.CallAdapter
import java.lang.reflect.Type
internal class ApiResponseCallAdapter(
private val resultType: Type,
) : CallAdapter<Type, Call<ApiResponse<Type>>> {
override fun responseType(): Type = resultType
override fun adapt(call: Call<Type>): Call<ApiResponse<Type>> {
return ApiResponseCallDelegate(call)
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.api.common.response
import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Retrofit
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
internal class ApiResponseCallAdapterFactory private constructor() : CallAdapter.Factory() {
override fun get(returnType: Type, annotations: Array<out Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
if (getRawType(returnType) != Call::class.java) {
return null
}
val callType = getParameterUpperBound(0, returnType as ParameterizedType)
if (getRawType(callType) != ApiResponse::class.java) {
return null
}
val resultType = getParameterUpperBound(0, callType as ParameterizedType)
return ApiResponseCallAdapter(resultType)
}
companion object {
fun create() = ApiResponseCallAdapterFactory()
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.datasource.api.common.response
import okhttp3.Request
import okio.Timeout
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
internal class ApiResponseCallDelegate<T : Any>(
private val wrappedCall: Call<T>,
) : Call<ApiResponse<T>> {
override fun enqueue(callback: Callback<ApiResponse<T>>) {
wrappedCall.enqueue(ApiResponseCallback(callback))
}
override fun execute(): Response<ApiResponse<T>> = throw NotImplementedError()
override fun clone(): Call<ApiResponse<T>> = ApiResponseCallDelegate(wrappedCall.clone())
override fun request(): Request = wrappedCall.request()
override fun timeout(): Timeout = wrappedCall.timeout()
override fun isExecuted(): Boolean = wrappedCall.isExecuted
override fun isCanceled(): Boolean = wrappedCall.isCanceled
override fun cancel() { wrappedCall.cancel() }
private inner class ApiResponseCallback(
private val responseCallback: Callback<ApiResponse<T>>,
) : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
val safeResponse = response.toSafeApiResponse()
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
}
override fun onFailure(call: Call<T>, t: Throwable) {
val e = if (t.isNetworkException()) {
ApiResponseError.NetworkException
} else {
ApiResponseError.UnknownException(t)
}
val safeResponse = apiError<T>(e)
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
}
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.datasource.api.common.response
/**
* Represents the possible errors that can occur during an API request.
*/
sealed class ApiResponseError : Exception() {
/**
* Represents an HTTP exception, which typically occurs when the server responds
* with a non-2xx HTTP status code.
*
* @property code The HTTP status code.
* @property message A human-readable message describing the error.
*/
data class HttpException(val code: Code, override val message: String?) : ApiResponseError() {
// region Error Codes
enum class Code(val code: Int) {
// 4xx Server Errors
BAD_REQUEST(code = 400),
UNAUTHORIZED(code = 401),
PAYMENT_REQUIRED(code = 402),
FORBIDDEN(code = 403),
NOT_FOUND(code = 404),
METHOD_NOT_ALLOWED(code = 405),
NOT_ACCEPTABLE(code = 406),
PROXY_AUTHENTICATION_REQUIRED(code = 407),
REQUEST_TIMEOUT(code = 408),
CONFLICT(code = 409),
GONE(code = 410),
LENGTH_REQUIRED(code = 411),
PRECONDITION_FAILED(code = 412),
PAYLOAD_TOO_LARGE(code = 413),
URI_TOO_LONG(code = 414),
UNSUPPORTED_MEDIA_TYPE(code = 415),
RANGE_NOT_SATISFIABLE(code = 416),
EXPECTATION_FAILED(code = 417),
IM_A_TEAPOT(code = 418), // Not an error, but an April Fools' joke from RFC 2324
UNPROCESSABLE_ENTITY(code = 422),
LOCKED(code = 423),
FAILED_DEPENDENCY(code = 424),
TOO_EARLY(code = 425),
UPGRADE_REQUIRED(code = 426),
PRECONDITION_REQUIRED(code = 428),
TOO_MANY_REQUESTS(code = 429),
REQUEST_HEADER_FIELDS_TOO_LARGE(code = 431),
UNAVAILABLE_FOR_LEGAL_REASONS(code = 451),
// 5xx Server Errors
INTERNAL_SERVER_ERROR(code = 500),
NOT_IMPLEMENTED(code = 501),
BAD_GATEWAY(code = 502),
SERVICE_UNAVAILABLE(code = 503),
GATEWAY_TIMEOUT(code = 504),
HTTP_VERSION_NOT_SUPPORTED(code = 505),
VARIANT_ALSO_NEGOTIATES(code = 506),
INSUFFICIENT_STORAGE(code = 507),
LOOP_DETECTED(code = 508),
NOT_EXTENDED(code = 510),
NETWORK_AUTHENTICATION_REQUIRED(code = 511),
;
override fun toString(): String = "$code - $name"
companion object {
val values = values()
}
}
// endregion Error Codes
}
/** Represents a network error, typically when there's no connectivity. */
object NetworkException : ApiResponseError()
/**
* Represents an unexpected exception that doesn't fall into one of the other categories.
*
* @property cause The exception that caused this error.
*/
data class UnknownException(override val cause: Throwable) : ApiResponseError()
}

View file

@ -0,0 +1,6 @@
package com.tangem.datasource.api.common.response
fun <T : Any> ApiResponse<T>.getOrThrow(): T = when (this) {
is ApiResponse.Error -> throw cause
is ApiResponse.Success -> data
}

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.api.common.response
import retrofit2.Response
import java.net.ConnectException
import java.net.UnknownHostException
import javax.net.ssl.SSLHandshakeException
internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
val body = body()
return if (isSuccessful && body != null) {
apiSuccess(body)
} else {
val code = ApiResponseError.HttpException.Code.values
.firstOrNull { it.code == code() }
val e = if (code == null) {
ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}"))
} else {
ApiResponseError.HttpException(code, message())
}
apiError(e)
}
}
internal fun Throwable.isNetworkException(): Boolean = when (this) {
is ConnectException,
is UnknownHostException,
is SSLHandshakeException,
-> true
else -> false
}

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.api.tangemTech
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.models.*
import retrofit2.http.*
@ -25,13 +26,13 @@ interface TangemTechApi {
suspend fun getRates(@Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String): RatesResponse
@GET("currencies")
suspend fun getCurrencyList(): CurrenciesResponse
suspend fun getCurrencyList(): ApiResponse<CurrenciesResponse>
@GET("geo")
suspend fun getUserCountryCode(): GeoResponse
@GET("user-tokens/{user-id}")
suspend fun getUserTokens(@Path(value = "user-id") userId: String): UserTokensResponse
suspend fun getUserTokens(@Path(value = "user-id") userId: String): ApiResponse<UserTokensResponse>
@PUT("user-tokens/{user-id}")
suspend fun saveUserTokens(@Path(value = "user-id") userId: String, @Body userTokens: UserTokensResponse)
@ -66,5 +67,5 @@ interface TangemTechApi {
@Query("currencyId") currencyId: String,
@Query("coinIds") coinIds: String,
@Query("fields") fields: String = "price,priceChange24h,lastUpdatedAt",
): QuotesResponse
): ApiResponse<QuotesResponse>
}

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.api.tangemTech
import com.tangem.datasource.api.common.MoshiConverter
import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory
import com.tangem.datasource.utils.RequestHeader
import com.tangem.datasource.utils.RequestHeader.AuthenticationHeader
import com.tangem.datasource.utils.RequestHeader.CacheControlHeader
@ -29,6 +30,7 @@ object TangemTechService {
val headers = mutableListOf<RequestHeader>(CacheControlHeader).apply { header?.let(::add) }
return Retrofit.Builder()
.addConverterFactory(MoshiConverter.networkMoshiConverter)
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
.baseUrl(TANGEM_TECH_BASE_URL)
.client(
OkHttpClient.Builder()

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory
import com.tangem.datasource.api.paymentology.PaymentologyApi
import com.tangem.datasource.api.promotion.PromotionApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
@ -27,6 +28,7 @@ class NetworkModule {
fun provideTangemTechApi(@NetworkMoshi moshi: Moshi): TangemTechApi {
return Retrofit.Builder()
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
.baseUrl(PROD_TANGEM_TECH_BASE_URL)
.client(
OkHttpClient.Builder()

View file

@ -10,5 +10,7 @@ interface CacheKeysStore {
suspend fun remove(key: String)
suspend fun remove(keys: Collection<String>)
suspend fun clear()
}

View file

@ -65,6 +65,13 @@ internal class FileDataStore<Value : Any>(
writeTrigger.trigger()
}
override suspend fun remove(keys: Collection<String>) {
val e = NotImplementedError("`remove(keys)` function not implemented for `FileDataStore`")
Timber.e(e)
throw e
}
override suspend fun clear() {
val e = NotImplementedError("`clear()` function not implemented for `FileDataStore`")
Timber.e(e)

View file

@ -12,11 +12,9 @@ internal class RuntimeDataStore<Data : Any> : StringKeyDataStore<Data> {
}
override fun get(key: String): Flow<Data> {
return store
.map { value ->
value?.get(key)
}
.filterNotNull()
return store.mapNotNull { value ->
value?.get(key)
}
}
override fun getAll(): Flow<List<Data>> {
@ -42,20 +40,24 @@ internal class RuntimeDataStore<Data : Any> : StringKeyDataStore<Data> {
}
override suspend fun store(values: Map<String, Data>) {
updateValue { value ->
values.forEach { (key, item) ->
value[key] = item
}
updateValue { storedValue ->
storedValue.putAll(values)
value
storedValue
}
}
override suspend fun remove(key: String) {
updateValue { value ->
value.remove(key)
updateValue { storedValue ->
storedValue.remove(key)
value
storedValue
}
}
override suspend fun remove(keys: Collection<String>) {
updateValue { value ->
HashMap(value.filterKeys { it !in keys })
}
}

View file

@ -62,6 +62,16 @@ internal abstract class SharedPreferencesDataStore<Value : Any>(
writeTrigger.trigger()
}
override suspend fun remove(keys: Collection<String>) {
sharedPreferences.edit {
keys.forEach { key ->
remove(key)
}
}
writeTrigger.trigger()
}
override suspend fun clear() {
sharedPreferences.edit { clear() }
writeTrigger.trigger()

View file

@ -18,5 +18,7 @@ internal interface DataStore<Key : Any, Value : Any> {
suspend fun remove(key: Key)
suspend fun remove(keys: Collection<Key>)
suspend fun clear()
}

View file

@ -38,6 +38,10 @@ internal abstract class StringKeyDataStoreDecorator<Key : Any, Value : Any>(
wrappedDataStore.remove(provideStringKey(key))
}
override suspend fun remove(keys: Collection<Key>) {
wrappedDataStore.remove(keys.map(::provideStringKey))
}
override suspend fun clear() {
wrappedDataStore.clear()
}