Updated on 2026-08-14
This commit is contained in:
parent
f3f29813e0
commit
b74e2b9168
25 changed files with 405 additions and 72 deletions
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -10,5 +10,7 @@ interface CacheKeysStore {
|
|||
|
||||
suspend fun remove(key: String)
|
||||
|
||||
suspend fun remove(keys: Collection<String>)
|
||||
|
||||
suspend fun clear()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue