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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user