diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthService.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthService.kt index 4de62b4..d4b4c97 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthService.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthService.kt @@ -1,6 +1,7 @@ package gq.kirmanak.mealient.data.auth import gq.kirmanak.mealient.data.auth.impl.GetTokenResponse +import retrofit2.Response import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.POST @@ -15,5 +16,5 @@ interface AuthService { @Field("scope") scope: String? = null, @Field("client_id") clientId: String? = null, @Field("client_secret") clientSecret: String? = null - ): GetTokenResponse + ): Response } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt index 1346ad9..3f6f367 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt @@ -2,15 +2,25 @@ package gq.kirmanak.mealient.data.auth.impl import gq.kirmanak.mealient.data.auth.AuthDataSource 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 kotlinx.coroutines.CancellationException 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 timber.log.Timber +import java.io.InputStream import javax.inject.Inject @ExperimentalSerializationApi class AuthDataSourceImpl @Inject constructor( - private val retrofitBuilder: RetrofitBuilder + private val retrofitBuilder: RetrofitBuilder, + private val json: Json, ) : AuthDataSource { override suspend fun authenticate( username: String, @@ -19,8 +29,37 @@ class AuthDataSourceImpl @Inject constructor( ): String { Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") val authService = retrofitBuilder.buildRetrofit(baseUrl).create() - val response = authService.getToken(username, password) - Timber.d("authenticate() response is $response") - return response.accessToken + + val accessToken = runCatching { + authService.getToken(username, password) + }.mapCatching { + Timber.d("authenticate() response is $it") + if (!it.isSuccessful) { + val cause = HttpException(it) + throw when (it.decodeErrorBodyOrNull()?.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 Response.decodeErrorBodyOrNull(): R? = + errorBody()?.byteStream()?.let { json.decodeFromStreamOrNull(it) } + + private inline fun Json.decodeFromStreamOrNull(stream: InputStream): T? = + runCatching { decodeFromStream(stream) } + .onFailure { Timber.e(it, "decodeFromStreamOrNull: can't decode") } + .getOrNull() } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthenticationError.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthenticationError.kt new file mode 100644 index 0000000..bb9c81d --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthenticationError.kt @@ -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) +} diff --git a/app/src/main/java/gq/kirmanak/mealient/data/impl/ErrorDetail.kt b/app/src/main/java/gq/kirmanak/mealient/data/impl/ErrorDetail.kt new file mode 100644 index 0000000..c7a1df0 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/impl/ErrorDetail.kt @@ -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) \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt index 5ff1eaf..7485268 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt @@ -2,6 +2,7 @@ package gq.kirmanak.mealient.data.auth.impl import com.google.common.truth.Truth.assertThat 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_TOKEN 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.runBlocking import kotlinx.serialization.ExperimentalSerializationApi +import okhttp3.mockwebserver.MockResponse import org.junit.Test -import retrofit2.HttpException import javax.inject.Inject @ExperimentalSerializationApi @@ -30,7 +31,7 @@ class AuthDataSourceImplTest : MockServerTest() { assertThat(token).isEqualTo(TEST_TOKEN) } - @Test(expected = HttpException::class) + @Test(expected = Unauthorized::class) fun `when authentication isn't successful then throws`(): Unit = runBlocking { mockServer.enqueueUnsuccessfulAuthResponse() subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) @@ -52,4 +53,32 @@ class AuthDataSourceImplTest : MockServerTest() { 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") + } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt index 026518f..2135f37 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt @@ -2,6 +2,7 @@ package gq.kirmanak.mealient.data.auth.impl import com.google.common.truth.Truth.assertThat 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_TOKEN 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.runBlocking import org.junit.Test -import retrofit2.HttpException import javax.inject.Inject @HiltAndroidTest @@ -31,7 +31,7 @@ class AuthRepoImplTest : MockServerTest() { assertThat(subject.authenticationStatuses().first()).isTrue() } - @Test(expected = HttpException::class) + @Test(expected = Unauthorized::class) fun `when authentication fails then authenticate throws`() = runBlocking { mockServer.enqueueUnsuccessfulAuthResponse() subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)