Map auth errors to internal representation

This commit is contained in:
Kirill Kamakin
2021-11-21 17:27:22 +03:00
parent e8089c6684
commit 808e1ce359
6 changed files with 92 additions and 9 deletions

View File

@@ -1,6 +1,7 @@
package gq.kirmanak.mealient.data.auth package gq.kirmanak.mealient.data.auth
import gq.kirmanak.mealient.data.auth.impl.GetTokenResponse import gq.kirmanak.mealient.data.auth.impl.GetTokenResponse
import retrofit2.Response
import retrofit2.http.Field import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST import retrofit2.http.POST
@@ -15,5 +16,5 @@ interface AuthService {
@Field("scope") scope: String? = null, @Field("scope") scope: String? = null,
@Field("client_id") clientId: String? = null, @Field("client_id") clientId: String? = null,
@Field("client_secret") clientSecret: String? = null @Field("client_secret") clientSecret: String? = null
): GetTokenResponse ): Response<GetTokenResponse>
} }

View File

@@ -2,15 +2,25 @@ package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthService import gq.kirmanak.mealient.data.auth.AuthService
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.*
import gq.kirmanak.mealient.data.impl.ErrorDetail
import gq.kirmanak.mealient.data.impl.RetrofitBuilder import gq.kirmanak.mealient.data.impl.RetrofitBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import retrofit2.HttpException
import retrofit2.Response
import retrofit2.create import retrofit2.create
import timber.log.Timber import timber.log.Timber
import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
@ExperimentalSerializationApi @ExperimentalSerializationApi
class AuthDataSourceImpl @Inject constructor( class AuthDataSourceImpl @Inject constructor(
private val retrofitBuilder: RetrofitBuilder private val retrofitBuilder: RetrofitBuilder,
private val json: Json,
) : AuthDataSource { ) : AuthDataSource {
override suspend fun authenticate( override suspend fun authenticate(
username: String, username: String,
@@ -19,8 +29,37 @@ class AuthDataSourceImpl @Inject constructor(
): String { ): String {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val authService = retrofitBuilder.buildRetrofit(baseUrl).create<AuthService>() val authService = retrofitBuilder.buildRetrofit(baseUrl).create<AuthService>()
val response = authService.getToken(username, password)
Timber.d("authenticate() response is $response") val accessToken = runCatching {
return response.accessToken authService.getToken(username, password)
}.mapCatching {
Timber.d("authenticate() response is $it")
if (!it.isSuccessful) {
val cause = HttpException(it)
throw when (it.decodeErrorBodyOrNull<GetTokenResponse, ErrorDetail>()?.detail) {
"Unauthorized" -> Unauthorized(cause)
else -> NotMealie(cause)
} }
} }
checkNotNull(it.body()).accessToken // Can't be null here, would throw SerializationException
}.onFailure {
Timber.e(it, "authenticate: getToken failed")
throw when (it) {
is CancellationException, is AuthenticationError -> it
is SerializationException, is IllegalStateException -> NotMealie(it)
else -> NoServerConnection(it)
}
}.getOrThrow()
Timber.v("authenticate() returned: $accessToken")
return accessToken
}
private inline fun <reified T, reified R> Response<T>.decodeErrorBodyOrNull(): R? =
errorBody()?.byteStream()?.let { json.decodeFromStreamOrNull<R>(it) }
private inline fun <reified T> Json.decodeFromStreamOrNull(stream: InputStream): T? =
runCatching { decodeFromStream<T>(stream) }
.onFailure { Timber.e(it, "decodeFromStreamOrNull: can't decode") }
.getOrNull()
}

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealient.data.auth.impl
sealed class AuthenticationError(cause: Throwable) : RuntimeException(cause) {
class Unauthorized(cause: Throwable) : AuthenticationError(cause)
class NoServerConnection(cause: Throwable) : AuthenticationError(cause)
class NotMealie(cause: Throwable) : AuthenticationError(cause)
}

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealient.data.impl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ErrorDetail(@SerialName("detail") val detail: String? = null)

View File

@@ -2,6 +2,7 @@ package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.*
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
@@ -12,8 +13,8 @@ import gq.kirmanak.mealient.test.MockServerTest
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import okhttp3.mockwebserver.MockResponse
import org.junit.Test import org.junit.Test
import retrofit2.HttpException
import javax.inject.Inject import javax.inject.Inject
@ExperimentalSerializationApi @ExperimentalSerializationApi
@@ -30,7 +31,7 @@ class AuthDataSourceImplTest : MockServerTest() {
assertThat(token).isEqualTo(TEST_TOKEN) assertThat(token).isEqualTo(TEST_TOKEN)
} }
@Test(expected = HttpException::class) @Test(expected = Unauthorized::class)
fun `when authentication isn't successful then throws`(): Unit = runBlocking { fun `when authentication isn't successful then throws`(): Unit = runBlocking {
mockServer.enqueueUnsuccessfulAuthResponse() mockServer.enqueueUnsuccessfulAuthResponse()
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
@@ -52,4 +53,32 @@ class AuthDataSourceImplTest : MockServerTest() {
assertThat(path).isEqualTo("/api/auth/token") assertThat(path).isEqualTo("/api/auth/token")
} }
@Test(expected = NotMealie::class)
fun `when authenticate but response empty then NotMealie`(): Unit = runBlocking {
val response = MockResponse().setResponseCode(200)
mockServer.enqueue(response)
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
}
@Test(expected = NotMealie::class)
fun `when authenticate but response invalid then NotMealie`(): Unit = runBlocking {
val response = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("{\"test\": \"test\"")
mockServer.enqueue(response)
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
}
@Test(expected = NotMealie::class)
fun `when authenticate but response not found then NotMealie`(): Unit = runBlocking {
val response = MockResponse().setResponseCode(404)
mockServer.enqueue(response)
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
}
@Test(expected = NoServerConnection::class)
fun `when authenticate but host not found then NoServerConnection`(): Unit = runBlocking {
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, "http://test")
}
} }

View File

@@ -2,6 +2,7 @@ package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.Unauthorized
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
@@ -11,7 +12,6 @@ import gq.kirmanak.mealient.test.MockServerTest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Test import org.junit.Test
import retrofit2.HttpException
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidTest @HiltAndroidTest
@@ -31,7 +31,7 @@ class AuthRepoImplTest : MockServerTest() {
assertThat(subject.authenticationStatuses().first()).isTrue() assertThat(subject.authenticationStatuses().first()).isTrue()
} }
@Test(expected = HttpException::class) @Test(expected = Unauthorized::class)
fun `when authentication fails then authenticate throws`() = runBlocking { fun `when authentication fails then authenticate throws`() = runBlocking {
mockServer.enqueueUnsuccessfulAuthResponse() mockServer.enqueueUnsuccessfulAuthResponse()
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)