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)
|
||||
@@ -9,11 +9,12 @@ import retrofit2.Retrofit
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
class RetrofitBuilder @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val json: Json
|
||||
) {
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun buildRetrofit(baseUrl: String): Retrofit {
|
||||
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl")
|
||||
val contentType = "application/json".toMediaType()
|
||||
|
||||
@@ -7,11 +7,10 @@ import retrofit2.Response
|
||||
import timber.log.Timber
|
||||
import java.io.InputStream
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
inline fun <T, reified R> Response<T>.decodeErrorBodyOrNull(json: Json): R? =
|
||||
errorBody()?.byteStream()?.let { json.decodeFromStreamOrNull<R>(it) }
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
inline fun <reified T> Json.decodeFromStreamOrNull(stream: InputStream): T? =
|
||||
runCatching { decodeFromStream<T>(stream) }
|
||||
.onFailure { Timber.e(it, "decodeFromStreamOrNull: can't decode") }
|
||||
|
||||
@@ -17,7 +17,7 @@ suspend fun SharedPreferences.getStringOrNull(key: String) =
|
||||
suspend fun SharedPreferences.getBooleanOrFalse(key: String) =
|
||||
withContext(Dispatchers.IO) { getBoolean(key, false) }
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun SharedPreferences.changesFlow(): Flow<Pair<SharedPreferences, String?>> = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key ->
|
||||
Timber.v("watchChanges: listener called with key $key")
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package gq.kirmanak.mealient.data.network
|
||||
|
||||
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
|
||||
import timber.log.Timber
|
||||
|
||||
inline fun <reified T> RetrofitBuilder.createServiceFactory() =
|
||||
RetrofitServiceFactory(T::class.java, this)
|
||||
|
||||
class RetrofitServiceFactory<T>(
|
||||
private val serviceClass: Class<T>,
|
||||
private val retrofitBuilder: RetrofitBuilder,
|
||||
) : ServiceFactory<T> {
|
||||
|
||||
private val cache: MutableMap<String, T> = mutableMapOf()
|
||||
|
||||
@Synchronized
|
||||
override fun provideService(baseUrl: String): T {
|
||||
Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}")
|
||||
val cached = cache[baseUrl]
|
||||
return if (cached == null) {
|
||||
Timber.d("provideService: cache is empty, creating new")
|
||||
val new = retrofitBuilder.buildRetrofit(baseUrl).create(serviceClass)
|
||||
cache[baseUrl] = new
|
||||
new
|
||||
} else {
|
||||
cached
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package gq.kirmanak.mealient.data.network
|
||||
|
||||
interface ServiceFactory<T> {
|
||||
|
||||
fun provideService(baseUrl: String): T
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@ExperimentalPagingApi
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class RecipeRepoImpl @Inject constructor(
|
||||
private val mediator: RecipesRemoteMediator,
|
||||
private val storage: RecipeStorage,
|
||||
|
||||
@@ -11,7 +11,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@ExperimentalPagingApi
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class RecipesRemoteMediator @Inject constructor(
|
||||
private val storage: RecipeStorage,
|
||||
private val network: RecipeDataSource,
|
||||
|
||||
@@ -1,50 +1,35 @@
|
||||
package gq.kirmanak.mealient.data.recipes.network
|
||||
|
||||
import gq.kirmanak.mealient.data.auth.AuthRepo
|
||||
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
|
||||
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
|
||||
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
class RecipeDataSourceImpl @Inject constructor(
|
||||
private val authRepo: AuthRepo,
|
||||
private val retrofitBuilder: RetrofitBuilder
|
||||
private val recipeServiceFactory: ServiceFactory<RecipeService>,
|
||||
) : RecipeDataSource {
|
||||
private var _recipeService: RecipeService? = null
|
||||
|
||||
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
|
||||
Timber.v("requestRecipes() called with: start = $start, limit = $limit")
|
||||
val service: RecipeService = getRecipeService()
|
||||
val recipeSummary = service.getRecipeSummary(start, limit)
|
||||
val recipeSummary = getRecipeService().getRecipeSummary(start, limit, getToken())
|
||||
Timber.v("requestRecipes() returned: $recipeSummary")
|
||||
return recipeSummary
|
||||
}
|
||||
|
||||
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
|
||||
Timber.v("requestRecipeInfo() called with: slug = $slug")
|
||||
val service: RecipeService = getRecipeService()
|
||||
val recipeInfo = service.getRecipe(slug)
|
||||
val recipeInfo = getRecipeService().getRecipe(slug, getToken())
|
||||
Timber.v("requestRecipeInfo() returned: $recipeInfo")
|
||||
return recipeInfo
|
||||
}
|
||||
|
||||
private suspend fun getRecipeService(): RecipeService {
|
||||
Timber.v("getRecipeService() called")
|
||||
val cachedService: RecipeService? = _recipeService
|
||||
val service: RecipeService = if (cachedService == null) {
|
||||
val baseUrl = checkNotNull(authRepo.getBaseUrl()) { "Base url is null" }
|
||||
val token = checkNotNull(authRepo.getToken()) { "Token is null" }
|
||||
Timber.d("requestRecipes: baseUrl = $baseUrl, token = $token")
|
||||
val retrofit = retrofitBuilder.buildRetrofit(baseUrl)
|
||||
val createdService = retrofit.create(RecipeService::class.java)
|
||||
_recipeService = createdService
|
||||
createdService
|
||||
} else {
|
||||
cachedService
|
||||
}
|
||||
return service
|
||||
return recipeServiceFactory.provideService(authRepo.requireBaseUrl())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getToken(): String = authRepo.requireAuthHeader()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.data.recipes.network
|
||||
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
|
||||
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
@@ -10,11 +11,13 @@ interface RecipeService {
|
||||
@GET("/api/recipes/summary")
|
||||
suspend fun getRecipeSummary(
|
||||
@Query("start") start: Int,
|
||||
@Query("limit") limit: Int
|
||||
@Query("limit") limit: Int,
|
||||
@Header("Authorization") authHeader: String?,
|
||||
): List<GetRecipeSummaryResponse>
|
||||
|
||||
@GET("/api/recipes/{recipe_slug}")
|
||||
suspend fun getRecipe(
|
||||
@Path("recipe_slug") recipeSlug: String
|
||||
@Path("recipe_slug") recipeSlug: String,
|
||||
@Header("Authorization") authHeader: String?,
|
||||
): GetRecipeResponse
|
||||
}
|
||||
Reference in New Issue
Block a user