Refactor handling of proton error data in json response

- use deserializer from kotlin-utils
- merge Proton and Http ApiResult errors
This commit is contained in:
Mateusz Markowicz 2020-07-01 14:39:58 +02:00
parent 936c4213ab
commit d14e896863
10 changed files with 118 additions and 66 deletions

View File

@ -0,0 +1,65 @@
/*
* Copyright (c) 2020 Proton Technologies AG
* This file is part of Proton Technologies AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.network.data
import kotlinx.serialization.SerializationException
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.NetworkManager
import okhttp3.Response
import retrofit2.HttpException
import java.io.IOException
import java.net.SocketTimeoutException
import java.security.cert.CertificateException
import javax.net.ssl.SSLHandshakeException
internal suspend fun <Api, T> safeApiCall(
networkManager: NetworkManager,
api: Api,
block: suspend (Api) -> T
): ApiResult<T> =
try {
ApiResult.Success(block(api))
} catch (e: ProtonErrorException) {
parseHttpError(e.response, e.protonData)
} catch (e: HttpException) {
parseHttpError(e.response()!!.raw(), null)
} catch (e: SerializationException) {
ApiResult.Error.Parse
} catch (e: CertificateException) {
ApiResult.Error.Certificate
} catch (e: SSLHandshakeException) {
ApiResult.Error.Certificate
} catch (e: SocketTimeoutException) {
ApiResult.Error.Timeout(networkManager.isConnectedToNetwork())
} catch (e: IOException) {
ApiResult.Error.Connection(networkManager.isConnectedToNetwork())
}
private fun <T> parseHttpError(response: Response, protonData: ApiResult.Error.ProtonData?): ApiResult<T> {
val retryAfter = response.headers["Retry-After"]?.toIntOrNull()
return if (response.code == ApiResult.HTTP_TOO_MANY_REQUESTS && retryAfter != null) {
ApiResult.Error.TooManyRequest(retryAfter, protonData)
} else {
ApiResult.Error.Http(response.code, response.message, protonData)
}
}
internal class ProtonErrorException(
val response: Response,
val protonData: ApiResult.Error.ProtonData
) : IOException()

View File

@ -17,9 +17,8 @@
*/
package me.proton.core.network.data
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import me.proton.core.network.data.protonApi.BaseRetrofitApi
import me.proton.core.network.data.protonApi.ProtonErrorData
import me.proton.core.network.data.protonApi.RefreshTokenRequest
import me.proton.core.network.domain.ApiBackend
import me.proton.core.network.domain.ApiClient
@ -28,19 +27,15 @@ import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.NetworkManager
import me.proton.core.network.domain.TimeoutOverride
import me.proton.core.network.domain.UserData
import me.proton.core.util.kotlin.deserializeOrNull
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import retrofit2.Converter
import retrofit2.HttpException
import retrofit2.Retrofit
import java.io.IOException
import java.net.SocketTimeoutException
import java.security.cert.CertificateException
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLHandshakeException
import kotlin.reflect.KClass
/**
@ -51,7 +46,6 @@ import kotlin.reflect.KClass
* @property client [ApiClient] to be used with the backend.
* @property userData [UserData] bound to this backend.
* @property networkManager [NetworkManager] instance.
* @param json kotlin Json parser.
* @param converters Retrofit converters to be used in the backend.
* @param interfaceClass Kotlin class for [Api].
* @param securityStrategy Strategy function introducing to okhttp builder pinning and other
@ -62,15 +56,12 @@ internal class ProtonApiBackend<Api : BaseRetrofitApi>(
private val client: ApiClient,
private val userData: UserData,
baseOkHttpClient: OkHttpClient,
private val json: Json,
converters: List<Converter.Factory>,
interfaceClass: KClass<Api>,
private val networkManager: NetworkManager,
securityStrategy: (OkHttpClient.Builder) -> Unit
) : ApiBackend<Api> {
private class ProtonErrorException(val httpCode: Int, val protonCode: Int, val error: String) : IOException()
private val api: Api
init {
@ -126,21 +117,15 @@ internal class ProtonApiBackend<Api : BaseRetrofitApi>(
return request
}
@Throws(IOException::class)
private fun interceptErrors(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
// TODO: use serialization functions from kotlin-utils
val body = response.peekBody(Long.MAX_VALUE).string()
try {
val bodyJson = json.parseJson(body)
val code = bodyJson.jsonObject["Code"]?.primitive?.int
val error = bodyJson.jsonObject["Error"]?.primitive?.content
if (code != null && error != null)
throw ProtonErrorException(response.code, code, error)
} catch (e: SerializationException) {
// ignore: not a json response
if (!response.isSuccessful) {
val errorBody = response.peekBody(MAX_ERROR_BYTES).string()
val protonError = errorBody.deserializeOrNull(ProtonErrorData.serializer())?.apiResultData
if (protonError != null)
throw ProtonErrorException(response, protonError)
}
return response
@ -149,28 +134,8 @@ internal class ProtonApiBackend<Api : BaseRetrofitApi>(
override suspend fun <T> invoke(call: ApiManager.Call<Api, T>): ApiResult<T> =
invokeInternal(call.block)
private suspend fun <T> invokeInternal(block: suspend Api.() -> T): ApiResult<T> = try {
ApiResult.Success(block(api))
} catch (e: HttpException) {
val retryAfter = e.response()?.headers()?.get("Retry-After")?.toIntOrNull()
if (e.code() == ApiResult.HTTP_TOO_MANY_REQUESTS && retryAfter != null) {
ApiResult.Error.TooManyRequest(retryAfter)
} else {
ApiResult.Error.Http(e.code(), e.message())
}
} catch (e: SerializationException) {
ApiResult.Error.Parse
} catch (e: CertificateException) {
ApiResult.Error.Certificate
} catch (e: SSLHandshakeException) {
ApiResult.Error.Certificate
} catch (e: ProtonErrorException) {
ApiResult.Error.Proton(e.httpCode, e.protonCode, e.error)
} catch (e: SocketTimeoutException) {
ApiResult.Error.Timeout(networkManager.isConnectedToNetwork())
} catch (e: IOException) {
ApiResult.Error.Connection(networkManager.isConnectedToNetwork())
}
private suspend fun <T> invokeInternal(block: suspend Api.() -> T): ApiResult<T> =
safeApiCall(networkManager, api, block)
override suspend fun refreshTokens(): ApiResult<ApiBackend.Tokens> {
val result = invokeInternal {
@ -184,4 +149,8 @@ internal class ProtonApiBackend<Api : BaseRetrofitApi>(
is ApiResult.Error -> result
}
}
companion object {
private const val MAX_ERROR_BYTES = 1_000_000L
}
}

View File

@ -79,7 +79,6 @@ class ApiFactory(private val apiClient: ApiClient) {
apiClient,
userData,
baseOkHttpClient,
ProtonCoreConfig.defaultJsonStringFormat,
listOf(jsonConverter),
interfaceClass,
networkManager,

View File

@ -17,6 +17,9 @@
*/
package me.proton.core.network.data.protonApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import me.proton.core.network.domain.ApiResult
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
@ -29,3 +32,11 @@ interface BaseRetrofitApi {
@GET("test/ping")
suspend fun ping()
}
@Serializable
data class ProtonErrorData(
@SerialName("Code") val code: Int,
@SerialName("Error") val error: String
) {
val apiResultData get() = ApiResult.Error.ProtonData(code, error)
}

View File

@ -218,7 +218,8 @@ internal class ApiManagerTests {
@Test
fun `test force update`() = runBlockingTest {
coEvery { backend.invoke<TestResult>(any()) } returns
ApiResult.Error.Proton(400, ProtonForceUpdateHandler.ERROR_CODE_FORCE_UPDATE, "")
ApiResult.Error.Http(400, "",
ApiResult.Error.ProtonData(ProtonForceUpdateHandler.ERROR_CODE_FORCE_UPDATE, ""))
val result = apiManager.invoke { test() }
assertTrue(result is ApiResult.Error)
assertEquals(true, apiClient.forceUpdated)

View File

@ -30,7 +30,6 @@ import me.proton.core.network.data.util.prepareResponse
import me.proton.core.network.domain.ApiManager
import me.proton.core.network.domain.ApiResult
import me.proton.core.network.domain.NetworkManager
import me.proton.core.util.kotlin.ProtonCoreConfig
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import retrofit2.converter.scalars.ScalarsConverterFactory
@ -56,8 +55,7 @@ internal class ProtonApiBackendTests {
fun before() {
MockKAnnotations.init(this)
val client = MockApiClient()
val apiFactory = ApiFactory(client)
apiFactory = ApiFactory(client)
val user = MockUserData()
every { networkManager.isConnectedToNetwork() } returns isNetworkAvailable
@ -69,7 +67,6 @@ internal class ProtonApiBackendTests {
client,
user,
apiFactory.baseOkHttpClient,
ProtonCoreConfig.defaultJsonStringFormat,
listOf(
ScalarsConverterFactory.create(),
apiFactory.jsonConverter
@ -128,11 +125,11 @@ internal class ProtonApiBackendTests {
"""{ "Code": 10, "Error": "darn!" }""")
val result = backend(ApiManager.Call(0) { test() })
assertTrue(result is ApiResult.Error.Proton)
assertTrue(result is ApiResult.Error.Http)
assertEquals(10, result.protonCode)
assertEquals(10, result.proton?.code)
assertEquals(401, result.httpCode)
assertEquals("darn!", result.error)
assertEquals("darn!", result.proton?.error)
}
@Test

View File

@ -47,17 +47,10 @@ sealed class ApiResult<out T> {
*
* @property httpCode HTTP code.
* @property message HTTP message.
* @property proton Proton-specific HTTP error data.
*/
open class Http(val httpCode: Int, val message: String) : Error()
/**
* Response with Proton error.
*
* @property httpCode Response HTTP code.
* @property protonCode Response Proton code.
* @property error Error string.
*/
class Proton(httpCode: Int, val protonCode: Int, val error: String) : Http(httpCode, error)
open class Http(val httpCode: Int, val message: String, val proton: ProtonData? = null) : Error()
class ProtonData(val code: Int, val error: String)
/**
* Parsing error. Should not normally happen.
@ -96,7 +89,8 @@ sealed class ApiResult<out T> {
* @property retryAfterSeconds Number of seconds to hold all requests (network layer will
* automatically fail requests that don't comply)
*/
class TooManyRequest(val retryAfterSeconds: Int) : Http(HTTP_TOO_MANY_REQUESTS, "Too Many Requests")
class TooManyRequest(val retryAfterSeconds: Int, proton: ProtonData? = null) :
Http(HTTP_TOO_MANY_REQUESTS, "Too Many Requests", proton)
}
/**

View File

@ -30,7 +30,7 @@ class ProtonForceUpdateHandler<Api>(private val apiClient: ApiClient) : ApiError
error: ApiResult.Error,
call: ApiManager.Call<Api, T>
): ApiResult<T> {
if (error is ApiResult.Error.Proton && error.protonCode == ERROR_CODE_FORCE_UPDATE)
if (error is ApiResult.Error.Http && error.proton?.code == ERROR_CODE_FORCE_UPDATE)
apiClient.forceUpdate()
return error
}

View File

@ -6,7 +6,7 @@ plugins {
`kotlin-serialization`
}
libVersion = Version(0, 1, 2)
libVersion = Version(0, 1, 3)
dependencies {

View File

@ -13,6 +13,7 @@ import kotlinx.serialization.KSerializer
import kotlinx.serialization.PrimitiveDescriptor
import kotlinx.serialization.PrimitiveKind
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.Serializer
import kotlinx.serialization.parse
@ -50,6 +51,21 @@ inline fun <reified T : Any> String.deserialize(
deserializer: DeserializationStrategy<T>? = null
): T = deserializer?.let { Serializer.parse(deserializer, this) } ?: Serializer.parse(this)
/**
* @return [T] object from the receiver [String] or null if receiver can't be deserialized to [T].
*
* Note: this function uses reflection as long as explicit [deserializer] [DeserializationStrategy]
* is passed explicitly
*/
@NeedSerializable
inline fun <reified T : Any> String.deserializeOrNull(
deserializer: DeserializationStrategy<T>? = null
): T? = try {
deserialize(deserializer)
} catch (e: SerializationException) {
null
}
/**
* @return [List] of [T] object from the receiver [String]
* This uses reflection: TODO improve for avoid it