Major code refactoring

Main goals are:
1. Ability to use mocks in unit tests instead of
having to setup mock web server as if it was an
integration test.
2. Cache Retrofit services in memory
3. Make it easier to read
4. Use OptIn where possible instead of propagating
Experimental* annotations everywhere
This commit is contained in:
Kirill Kamakin
2022-04-02 19:04:44 +05:00
parent 405d983a90
commit 7fc2887dc7
40 changed files with 533 additions and 676 deletions

View File

@@ -7,7 +7,11 @@ interface AuthRepo {
suspend fun getBaseUrl(): String?
suspend fun getToken(): String?
suspend fun requireBaseUrl(): String
suspend fun getAuthHeader(): String?
suspend fun requireAuthHeader(): String
fun authenticationStatuses(): Flow<Boolean>

View File

@@ -3,13 +3,13 @@ package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow
interface AuthStorage {
fun storeAuthData(token: String, baseUrl: String)
fun storeAuthData(authHeader: String, baseUrl: String)
suspend fun getBaseUrl(): String?
suspend fun getToken(): String?
suspend fun getAuthHeader(): String?
fun tokenObservable(): Flow<String?>
fun authHeaderObservable(): Flow<String?>
fun clearAuthData()
}

View File

@@ -3,20 +3,18 @@ package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource
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.util.decodeErrorBodyOrNull
import gq.kirmanak.mealient.data.network.ServiceFactory
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import retrofit2.create
import retrofit2.Response
import timber.log.Timber
import javax.inject.Inject
@ExperimentalSerializationApi
class AuthDataSourceImpl @Inject constructor(
private val retrofitBuilder: RetrofitBuilder,
private val authServiceFactory: ServiceFactory<AuthService>,
private val json: Json,
) : AuthDataSource {
@@ -26,31 +24,37 @@ class AuthDataSourceImpl @Inject constructor(
baseUrl: String
): String {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val authService = retrofitBuilder.buildRetrofit(baseUrl).create<AuthService>()
val accessToken = runCatching {
val response = authService.getToken(username, password)
Timber.d("authenticate() response is $response")
if (response.isSuccessful) {
checkNotNull(response.body()).accessToken
} else {
val cause = HttpException(response)
val errorDetail: ErrorDetail? = response.decodeErrorBodyOrNull(json)
throw when (errorDetail?.detail) {
"Unauthorized" -> Unauthorized(cause)
else -> NotMealie(cause)
}
}
}.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()
val authService = authServiceFactory.provideService(baseUrl)
val response = sendRequest(authService, username, password)
val accessToken = parseToken(response)
Timber.v("authenticate() returned: $accessToken")
return accessToken
}
private suspend fun sendRequest(
authService: AuthService,
username: String,
password: String
): Response<GetTokenResponse> = try {
authService.getToken(username, password)
} catch (e: Throwable) {
throw when (e) {
is CancellationException -> e
is SerializationException -> NotMealie(e)
else -> NoServerConnection(e)
}
}
private fun parseToken(
response: Response<GetTokenResponse>
): String = if (response.isSuccessful) {
response.body()?.accessToken ?: throw NotMealie(NullPointerException("Body is null"))
} else {
val cause = HttpException(response)
val errorDetail: ErrorDetail? = response.decodeErrorBodyOrNull(json)
throw when (errorDetail?.detail) {
"Unauthorized" -> Unauthorized(cause)
else -> NotMealie(cause)
}
}
}

View File

@@ -1,29 +0,0 @@
package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthStorage
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import timber.log.Timber
import javax.inject.Inject
const val AUTHORIZATION_HEADER = "Authorization"
class AuthOkHttpInterceptor @Inject constructor(
private val authStorage: AuthStorage
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
Timber.v("intercept() called with: chain = $chain")
val token = runBlocking { authStorage.getToken() }
Timber.d("intercept: token = $token")
val request = if (token.isNullOrBlank()) {
chain.request()
} else {
chain.request()
.newBuilder()
.addHeader(AUTHORIZATION_HEADER, "Bearer $token")
.build()
}
return chain.proceed(request)
}
}

View File

@@ -14,8 +14,9 @@ import javax.inject.Inject
class AuthRepoImpl @Inject constructor(
private val dataSource: AuthDataSource,
private val storage: AuthStorage
private val storage: AuthStorage,
) : AuthRepo {
override suspend fun authenticate(
username: String,
password: String,
@@ -24,20 +25,23 @@ class AuthRepoImpl @Inject constructor(
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val url = parseBaseUrl(baseUrl)
val accessToken = dataSource.authenticate(username, password, url)
Timber.d("authenticate result is $accessToken")
storage.storeAuthData(accessToken, url)
Timber.d("authenticate result is \"$accessToken\"")
storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken), url)
}
override suspend fun getBaseUrl(): String? = storage.getBaseUrl()
override suspend fun getToken(): String? {
Timber.v("getToken() called")
return storage.getToken()
}
override suspend fun requireBaseUrl(): String =
checkNotNull(getBaseUrl()) { "Base URL is null when it was required" }
override suspend fun getAuthHeader(): String? = storage.getAuthHeader()
override suspend fun requireAuthHeader(): String =
checkNotNull(getAuthHeader()) { "Auth header is null when it was required" }
override fun authenticationStatuses(): Flow<Boolean> {
Timber.v("authenticationStatuses() called")
return storage.tokenObservable().map { it != null }
return storage.authHeaderObservable().map { it != null }
}
override fun logout() {
@@ -57,4 +61,7 @@ class AuthRepoImpl @Inject constructor(
throw MalformedUrl(e)
}
companion object {
private const val AUTH_HEADER_FORMAT = "Bearer %s"
}
}

View File

@@ -11,9 +11,5 @@ interface AuthService {
suspend fun getToken(
@Field("username") username: String,
@Field("password") password: String,
@Field("grant_type") grantType: String? = null,
@Field("scope") scope: String? = null,
@Field("client_id") clientId: String? = null,
@Field("client_secret") clientSecret: String? = null
): Response<GetTokenResponse>
}

View File

@@ -1,29 +1,28 @@
package gq.kirmanak.mealient.data.auth.impl
import android.content.SharedPreferences
import androidx.core.content.edit
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.impl.util.changesFlow
import gq.kirmanak.mealient.data.impl.util.getStringOrNull
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
private const val TOKEN_KEY = "AUTH_TOKEN"
private const val AUTH_HEADER_KEY = "AUTH_TOKEN"
private const val BASE_URL_KEY = "BASE_URL"
@ExperimentalCoroutinesApi
class AuthStorageImpl @Inject constructor(
private val sharedPreferences: SharedPreferences
) : AuthStorage {
override fun storeAuthData(token: String, baseUrl: String) {
Timber.v("storeAuthData() called with: token = $token, baseUrl = $baseUrl")
sharedPreferences.edit()
.putString(TOKEN_KEY, token)
.putString(BASE_URL_KEY, baseUrl)
.apply()
override fun storeAuthData(authHeader: String, baseUrl: String) {
Timber.v("storeAuthData() called with: authHeader = $authHeader, baseUrl = $baseUrl")
sharedPreferences.edit {
putString(AUTH_HEADER_KEY, authHeader)
putString(BASE_URL_KEY, baseUrl)
}
}
override suspend fun getBaseUrl(): String? {
@@ -32,23 +31,23 @@ class AuthStorageImpl @Inject constructor(
return baseUrl
}
override suspend fun getToken(): String? {
Timber.v("getToken() called")
val token = sharedPreferences.getStringOrNull(TOKEN_KEY)
Timber.d("getToken: token is $token")
override suspend fun getAuthHeader(): String? {
Timber.v("getAuthHeader() called")
val token = sharedPreferences.getStringOrNull(AUTH_HEADER_KEY)
Timber.d("getAuthHeader: header is \"$token\"")
return token
}
override fun tokenObservable(): Flow<String?> {
Timber.v("tokenObservable() called")
return sharedPreferences.changesFlow().map { it.first.getStringOrNull(TOKEN_KEY) }
override fun authHeaderObservable(): Flow<String?> {
Timber.v("authHeaderObservable() called")
return sharedPreferences.changesFlow().map { it.first.getStringOrNull(AUTH_HEADER_KEY) }
}
override fun clearAuthData() {
Timber.v("clearAuthData() called")
sharedPreferences.edit()
.remove(TOKEN_KEY)
.remove(BASE_URL_KEY)
.apply()
sharedPreferences.edit {
remove(AUTH_HEADER_KEY)
remove(BASE_URL_KEY)
}
}
}
}

View File

@@ -4,7 +4,4 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetTokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("token_type") val tokenType: String
)
data class GetTokenResponse(@SerialName("access_token") val accessToken: String)