Extract Base URL from authentication

This commit is contained in:
Kirill Kamakin
2022-04-04 02:40:32 +05:00
parent 617bcc7eae
commit f44f54522d
47 changed files with 760 additions and 316 deletions

View File

@@ -2,7 +2,7 @@ package gq.kirmanak.mealient.data.auth
interface AuthDataSource {
/**
* Tries to acquire authentication token using the provided credentials on specified server.
* Tries to acquire authentication token using the provided credentials
*/
suspend fun authenticate(username: String, password: String, baseUrl: String): String
suspend fun authenticate(username: String, password: String): String
}

View File

@@ -3,11 +3,8 @@ package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow
interface AuthRepo {
suspend fun authenticate(username: String, password: String, baseUrl: String)
suspend fun getBaseUrl(): String?
suspend fun requireBaseUrl(): String
suspend fun authenticate(username: String, password: String)
suspend fun getAuthHeader(): String?

View File

@@ -3,9 +3,7 @@ package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow
interface AuthStorage {
suspend fun storeAuthData(authHeader: String, baseUrl: String)
suspend fun getBaseUrl(): String?
suspend fun storeAuthData(authHeader: String)
suspend fun getAuthHeader(): String?

View File

@@ -1,8 +1,8 @@
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.network.ErrorDetail
import gq.kirmanak.mealient.data.network.NetworkError.*
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.decodeErrorBodyOrNull
import kotlinx.coroutines.CancellationException
@@ -20,13 +20,14 @@ class AuthDataSourceImpl @Inject constructor(
private val json: Json,
) : AuthDataSource {
override suspend fun authenticate(
username: String,
password: String,
baseUrl: String
): String {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val authService = authServiceFactory.provideService(baseUrl)
override suspend fun authenticate(username: String, password: String): String {
Timber.v("authenticate() called with: username = $username, password = $password")
val authService = try {
authServiceFactory.provideService()
} catch (e: Exception) {
Timber.e(e, "authenticate: can't create Retrofit service")
throw MalformedUrl(e)
}
val response = sendRequest(authService, username, password)
val accessToken = parseToken(response)
Timber.v("authenticate() returned: $accessToken")

View File

@@ -1,14 +1,10 @@
package gq.kirmanak.mealient.data.auth.impl
import android.net.Uri
import androidx.annotation.VisibleForTesting
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.MalformedUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -19,23 +15,13 @@ class AuthRepoImpl @Inject constructor(
private val storage: AuthStorage,
) : AuthRepo {
override suspend fun authenticate(
username: String,
password: String,
baseUrl: String
) {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val url = parseBaseUrl(baseUrl)
val accessToken = dataSource.authenticate(username, password, url)
override suspend fun authenticate(username: String, password: String) {
Timber.v("authenticate() called with: username = $username, password = $password")
val accessToken = dataSource.authenticate(username, password)
Timber.d("authenticate result is \"$accessToken\"")
storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken), url)
storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken))
}
override suspend fun getBaseUrl(): String? = storage.getBaseUrl()
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 =
@@ -51,18 +37,6 @@ class AuthRepoImpl @Inject constructor(
storage.clearAuthData()
}
@VisibleForTesting
fun parseBaseUrl(baseUrl: String): String = try {
val withScheme = Uri.parse(baseUrl).let {
if (it.scheme == null) it.buildUpon().scheme("https").build()
else it
}.toString()
withScheme.toHttpUrl().toString()
} catch (e: Throwable) {
Timber.e(e, "authenticate: can't parse base url $baseUrl")
throw MalformedUrl(e)
}
companion object {
private const val AUTH_HEADER_FORMAT = "Bearer %s"
}

View File

@@ -13,20 +13,10 @@ class AuthStorageImpl @Inject constructor(
) : AuthStorage {
private val authHeaderKey by preferencesStorage::authHeaderKey
private val baseUrlKey by preferencesStorage::baseUrlKey
override suspend fun storeAuthData(authHeader: String, baseUrl: String) {
Timber.v("storeAuthData() called with: authHeader = $authHeader, baseUrl = $baseUrl")
preferencesStorage.storeValues(
Pair(authHeaderKey, authHeader),
Pair(baseUrlKey, baseUrl),
)
}
override suspend fun getBaseUrl(): String? {
val baseUrl = preferencesStorage.getValue(baseUrlKey)
Timber.d("getBaseUrl: base url is $baseUrl")
return baseUrl
override suspend fun storeAuthData(authHeader: String) {
Timber.v("storeAuthData() called with: authHeader = $authHeader")
preferencesStorage.storeValues(Pair(authHeaderKey, authHeader))
}
override suspend fun getAuthHeader(): String? {
@@ -43,6 +33,6 @@ class AuthStorageImpl @Inject constructor(
override suspend fun clearAuthData() {
Timber.v("clearAuthData() called")
preferencesStorage.removeValues(authHeaderKey, baseUrlKey)
preferencesStorage.removeValues(authHeaderKey)
}
}

View File

@@ -1,8 +0,0 @@
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)
class MalformedUrl(cause: Throwable) : AuthenticationError(cause)
}

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealient.data.baseurl
interface BaseURLStorage {
suspend fun getBaseURL(): String?
suspend fun requireBaseURL(): String
suspend fun storeBaseURL(baseURL: String)
}

View File

@@ -0,0 +1,23 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BaseURLStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage,
) : BaseURLStorage {
private val baseUrlKey by preferencesStorage::baseUrlKey
override suspend fun getBaseURL(): String? = preferencesStorage.getValue(baseUrlKey)
override suspend fun requireBaseURL(): String = checkNotNull(getBaseURL()) {
"Base URL was null when it was required"
}
override suspend fun storeBaseURL(baseURL: String) {
preferencesStorage.storeValues(Pair(baseUrlKey, baseURL))
}
}

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.data.network.NetworkError
interface VersionDataSource {
@Throws(NetworkError::class)
suspend fun getVersionInfo(baseUrl: String): VersionInfo
}

View File

@@ -0,0 +1,39 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.versionInfo
import kotlinx.serialization.SerializationException
import retrofit2.HttpException
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VersionDataSourceImpl @Inject constructor(
private val serviceFactory: ServiceFactory<VersionService>,
) : VersionDataSource {
override suspend fun getVersionInfo(baseUrl: String): VersionInfo {
Timber.v("getVersionInfo() called with: baseUrl = $baseUrl")
val service = try {
serviceFactory.provideService(baseUrl)
} catch (e: Exception) {
Timber.e(e, "getVersionInfo: can't create service")
throw NetworkError.MalformedUrl(e)
}
val response = try {
service.getVersion()
} catch (e: Exception) {
Timber.e(e, "getVersionInfo: can't request version")
when (e) {
is HttpException, is SerializationException -> throw NetworkError.NotMealie(e)
else -> throw NetworkError.NoServerConnection(e)
}
}
return response.versionInfo()
}
}

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealient.data.baseurl
data class VersionInfo(
val production: Boolean,
val version: String,
val demoStatus: Boolean,
)

View File

@@ -0,0 +1,14 @@
package gq.kirmanak.mealient.data.baseurl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VersionResponse(
@SerialName("production")
val production: Boolean,
@SerialName("version")
val version: String,
@SerialName("demoStatus")
val demoStatus: Boolean,
)

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.data.baseurl
import retrofit2.http.GET
interface VersionService {
@GET("api/debug/version")
suspend fun getVersion(): VersionResponse
}

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.data.network
sealed class NetworkError(cause: Throwable) : RuntimeException(cause) {
class Unauthorized(cause: Throwable) : NetworkError(cause)
class NoServerConnection(cause: Throwable) : NetworkError(cause)
class NotMealie(cause: Throwable) : NetworkError(cause)
class MalformedUrl(cause: Throwable) : NetworkError(cause)
}

View File

@@ -1,28 +1,29 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import timber.log.Timber
inline fun <reified T> RetrofitBuilder.createServiceFactory() =
RetrofitServiceFactory(T::class.java, this)
inline fun <reified T> RetrofitBuilder.createServiceFactory(baseURLStorage: BaseURLStorage) =
RetrofitServiceFactory(T::class.java, this, baseURLStorage)
class RetrofitServiceFactory<T>(
private val serviceClass: Class<T>,
private val retrofitBuilder: RetrofitBuilder,
private val baseURLStorage: BaseURLStorage,
) : ServiceFactory<T> {
private val cache: MutableMap<String, T> = mutableMapOf()
@Synchronized
override fun provideService(baseUrl: String): T {
override suspend 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
}
val url = baseUrl ?: baseURLStorage.requireBaseURL()
return synchronized(cache) { cache[url] ?: createService(url, serviceClass) }
}
private fun createService(url: String, serviceClass: Class<T>): T {
Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}")
val service = retrofitBuilder.buildRetrofit(url).create(serviceClass)
cache[url] = service
return service
}
}

View File

@@ -2,5 +2,5 @@ package gq.kirmanak.mealient.data.network
interface ServiceFactory<T> {
fun provideService(baseUrl: String): T
suspend fun provideService(baseUrl: String? = null): T
}

View File

@@ -3,7 +3,7 @@ package gq.kirmanak.mealient.data.recipes.impl
import android.widget.ImageView
import androidx.annotation.VisibleForTesting
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.recipes.RecipeImageLoader
import gq.kirmanak.mealient.ui.ImageLoader
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -14,7 +14,7 @@ import javax.inject.Singleton
@Singleton
class RecipeImageLoaderImpl @Inject constructor(
private val imageLoader: ImageLoader,
private val authRepo: AuthRepo
private val baseURLStorage: BaseURLStorage,
): RecipeImageLoader {
override suspend fun loadRecipeImage(view: ImageView, slug: String?) {
@@ -25,7 +25,7 @@ class RecipeImageLoaderImpl @Inject constructor(
@VisibleForTesting
suspend fun generateImageUrl(slug: String?): String? {
Timber.v("generateImageUrl() called with: slug = $slug")
val result = authRepo.getBaseUrl()
val result = baseURLStorage.getBaseURL()
?.takeIf { it.isNotBlank() }
?.takeUnless { slug.isNullOrBlank() }
?.toHttpUrlOrNull()

View File

@@ -30,8 +30,8 @@ class RecipeDataSourceImpl @Inject constructor(
private suspend fun getRecipeService(): RecipeService {
Timber.v("getRecipeService() called")
return recipeServiceFactory.provideService(authRepo.requireBaseUrl())
return recipeServiceFactory.provideService()
}
private suspend fun getToken(): String = authRepo.requireAuthHeader()
private suspend fun getToken(): String? = authRepo.getAuthHeader()
}