Merge pull request #115 from kirmanak/authorization

Use API tokens to authenticate application
This commit is contained in:
Kirill Kamakin
2022-12-11 18:40:05 +01:00
committed by GitHub
49 changed files with 698 additions and 331 deletions

View File

@@ -15,8 +15,8 @@ plugins {
android { android {
defaultConfig { defaultConfig {
applicationId = "gq.kirmanak.mealient" applicationId = "gq.kirmanak.mealient"
versionCode = 24 versionCode = 25
versionName = "0.3.9" versionName = "0.3.10"
} }
signingConfigs { signingConfigs {

View File

@@ -4,7 +4,12 @@ import android.app.Application
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration
import gq.kirmanak.mealient.data.migration.MigrationDetector
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
@@ -16,9 +21,15 @@ class App : Application() {
@Inject @Inject
lateinit var buildConfiguration: BuildConfiguration lateinit var buildConfiguration: BuildConfiguration
@Inject
lateinit var migrationDetector: MigrationDetector
private val appCoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
logger.v { "onCreate() called" } logger.v { "onCreate() called" }
DynamicColors.applyToActivitiesIfAvailable(this) DynamicColors.applyToActivitiesIfAvailable(this)
appCoroutineScope.launch { migrationDetector.executeMigrations() }
} }
} }

View File

@@ -5,4 +5,6 @@ interface AuthDataSource {
* Tries to acquire authentication token using the provided credentials * Tries to acquire authentication token using the provided credentials
*/ */
suspend fun authenticate(username: String, password: String): String suspend fun authenticate(username: String, password: String): String
suspend fun createApiToken(name: String): String
} }

View File

@@ -10,9 +10,5 @@ interface AuthRepo {
suspend fun getAuthHeader(): String? suspend fun getAuthHeader(): String?
suspend fun requireAuthHeader(): String
suspend fun logout() suspend fun logout()
suspend fun invalidateAuthHeader()
} }

View File

@@ -9,12 +9,4 @@ interface AuthStorage {
suspend fun setAuthHeader(authHeader: String?) suspend fun setAuthHeader(authHeader: String?)
suspend fun getAuthHeader(): String? suspend fun getAuthHeader(): String?
suspend fun setEmail(email: String?)
suspend fun getEmail(): String?
suspend fun setPassword(password: String?)
suspend fun getPassword(): String?
} }

View File

@@ -4,7 +4,9 @@ import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0 import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1 import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenRequestV1
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -15,14 +17,20 @@ class AuthDataSourceImpl @Inject constructor(
private val v1Source: MealieDataSourceV1, private val v1Source: MealieDataSourceV1,
) : AuthDataSource { ) : AuthDataSource {
private suspend fun getVersion(): ServerVersion = serverInfoRepo.getVersion()
private suspend fun getUrl(): String = serverInfoRepo.requireUrl()
override suspend fun authenticate( override suspend fun authenticate(
username: String, username: String,
password: String, password: String,
): String { ): String = when (getVersion()) {
val baseUrl = serverInfoRepo.requireUrl() ServerVersion.V0 -> v0Source.authenticate(getUrl(), username, password)
return when (serverInfoRepo.getVersion()) { ServerVersion.V1 -> v1Source.authenticate(getUrl(), username, password)
ServerVersion.V0 -> v0Source.authenticate(baseUrl, username, password) }
ServerVersion.V1 -> v1Source.authenticate(baseUrl, username, password)
} override suspend fun createApiToken(name: String): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.createApiToken(getUrl(), CreateApiTokenRequestV0(name))
ServerVersion.V1 -> v1Source.createApiToken(getUrl(), CreateApiTokenRequestV1(name)).token
} }
} }

View File

@@ -3,7 +3,7 @@ package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -15,7 +15,7 @@ class AuthRepoImpl @Inject constructor(
private val authStorage: AuthStorage, private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource, private val authDataSource: AuthDataSource,
private val logger: Logger, private val logger: Logger,
) : AuthRepo { ) : AuthRepo, AuthenticationProvider {
override val isAuthorizedFlow: Flow<Boolean> override val isAuthorizedFlow: Flow<Boolean>
get() = authStorage.authHeaderFlow.map { it != null } get() = authStorage.authHeaderFlow.map { it != null }
@@ -23,34 +23,20 @@ class AuthRepoImpl @Inject constructor(
override suspend fun authenticate(email: String, password: String) { override suspend fun authenticate(email: String, password: String) {
logger.v { "authenticate() called with: email = $email, password = $password" } logger.v { "authenticate() called with: email = $email, password = $password" }
val token = authDataSource.authenticate(email, password) val token = authDataSource.authenticate(email, password)
val header = AUTH_HEADER_FORMAT.format(token) authStorage.setAuthHeader(AUTH_HEADER_FORMAT.format(token))
authStorage.setAuthHeader(header) val apiToken = authDataSource.createApiToken(API_TOKEN_NAME)
authStorage.setEmail(email) authStorage.setAuthHeader(AUTH_HEADER_FORMAT.format(apiToken))
authStorage.setPassword(password)
} }
override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader() override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader()
override suspend fun requireAuthHeader(): String = checkNotNull(getAuthHeader()) {
"Auth header is null when it was required"
}
override suspend fun logout() { override suspend fun logout() {
logger.v { "logout() called" } logger.v { "logout() called" }
authStorage.setEmail(null)
authStorage.setPassword(null)
authStorage.setAuthHeader(null) authStorage.setAuthHeader(null)
} }
override suspend fun invalidateAuthHeader() {
logger.v { "invalidateAuthHeader() called" }
val email = authStorage.getEmail() ?: return
val password = authStorage.getPassword() ?: return
runCatchingExceptCancel { authenticate(email, password) }
.onFailure { logout() } // Clear all known values to avoid reusing them
}
companion object { companion object {
private const val AUTH_HEADER_FORMAT = "Bearer %s" private const val AUTH_HEADER_FORMAT = "Bearer %s"
private const val API_TOKEN_NAME = "Mealient"
} }
} }

View File

@@ -32,14 +32,6 @@ class AuthStorageImpl @Inject constructor(
override suspend fun getAuthHeader(): String? = getString(AUTH_HEADER_KEY) override suspend fun getAuthHeader(): String? = getString(AUTH_HEADER_KEY)
override suspend fun setEmail(email: String?) = putString(EMAIL_KEY, email)
override suspend fun getEmail(): String? = getString(EMAIL_KEY)
override suspend fun setPassword(password: String?) = putString(PASSWORD_KEY, password)
override suspend fun getPassword(): String? = getString(PASSWORD_KEY)
private suspend fun putString( private suspend fun putString(
key: String, key: String,
value: String? value: String?
@@ -57,11 +49,5 @@ class AuthStorageImpl @Inject constructor(
companion object { companion object {
@VisibleForTesting @VisibleForTesting
const val AUTH_HEADER_KEY = "authHeader" const val AUTH_HEADER_KEY = "authHeader"
@VisibleForTesting
const val EMAIL_KEY = "email"
@VisibleForTesting
const val PASSWORD_KEY = "password"
} }
} }

View File

@@ -0,0 +1,19 @@
package gq.kirmanak.mealient.data.configuration
import gq.kirmanak.mealient.BuildConfig
import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BuildConfigurationImpl @Inject constructor() : BuildConfiguration {
@get:JvmName("_isDebug")
private val isDebug by lazy { BuildConfig.DEBUG }
private val versionCode by lazy { BuildConfig.VERSION_CODE }
override fun isDebug(): Boolean = isDebug
override fun versionCode(): Int = versionCode
}

View File

@@ -0,0 +1,41 @@
package gq.kirmanak.mealient.data.migration
import android.content.SharedPreferences
import androidx.core.content.edit
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.datastore.DataStoreModule.Companion.ENCRYPTED
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class From24AuthMigrationExecutor @Inject constructor(
@Named(ENCRYPTED) private val sharedPreferences: SharedPreferences,
private val authRepo: AuthRepo,
private val logger: Logger,
) : MigrationExecutor {
override val migratingFrom: Int = 24
override suspend fun executeMigration() {
logger.v { "executeMigration() was called" }
val email = sharedPreferences.getString(EMAIL_KEY, null)
val password = sharedPreferences.getString(PASSWORD_KEY, null)
if (email != null && password != null) {
runCatchingExceptCancel { authRepo.authenticate(email, password) }
.onFailure { logger.e(it) { "API token creation failed" } }
.onSuccess { logger.i { "Created API token during migration" } }
}
sharedPreferences.edit {
remove(EMAIL_KEY)
remove(PASSWORD_KEY)
}
}
companion object {
private const val EMAIL_KEY = "email"
private const val PASSWORD_KEY = "password"
}
}

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.data.migration
interface MigrationDetector {
suspend fun executeMigrations()
}

View File

@@ -0,0 +1,43 @@
package gq.kirmanak.mealient.data.migration
import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MigrationDetectorImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage,
private val migrationExecutors: Set<@JvmSuppressWildcards MigrationExecutor>,
private val buildConfiguration: BuildConfiguration,
private val logger: Logger,
) : MigrationDetector {
override suspend fun executeMigrations() {
val key = preferencesStorage.lastExecutedMigrationVersionKey
val lastVersion = preferencesStorage.getValue(key) ?: VERSION_BEFORE_MIGRATION_IMPLEMENTED
val currentVersion = buildConfiguration.versionCode()
logger.i { "Last migration version is $lastVersion, current is $currentVersion" }
if (lastVersion != currentVersion) {
migrationExecutors
.filter { it.migratingFrom >= lastVersion }
.forEach { executor ->
runCatchingExceptCancel { executor.executeMigration() }
.onFailure { logger.e(it) { "Migration executor failed: $executor" } }
.onSuccess { logger.i { "Migration executor succeeded: $executor" } }
}
}
preferencesStorage.storeValues(Pair(key, currentVersion))
}
companion object {
private const val VERSION_BEFORE_MIGRATION_IMPLEMENTED = 24
}
}

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.data.migration
interface MigrationExecutor {
val migratingFrom: Int
suspend fun executeMigration()
}

View File

@@ -2,7 +2,6 @@ package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.add.AddRecipeDataSource import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.AddRecipeInfo import gq.kirmanak.mealient.data.add.AddRecipeInfo
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo
@@ -10,8 +9,6 @@ import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo
import gq.kirmanak.mealient.data.share.ParseRecipeDataSource import gq.kirmanak.mealient.data.share.ParseRecipeDataSource
import gq.kirmanak.mealient.data.share.ParseRecipeURLInfo import gq.kirmanak.mealient.data.share.ParseRecipeURLInfo
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0 import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1 import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.extensions.toFullRecipeInfo import gq.kirmanak.mealient.extensions.toFullRecipeInfo
@@ -20,85 +17,53 @@ import gq.kirmanak.mealient.extensions.toV0Request
import gq.kirmanak.mealient.extensions.toV1CreateRequest import gq.kirmanak.mealient.extensions.toV1CreateRequest
import gq.kirmanak.mealient.extensions.toV1Request import gq.kirmanak.mealient.extensions.toV1Request
import gq.kirmanak.mealient.extensions.toV1UpdateRequest import gq.kirmanak.mealient.extensions.toV1UpdateRequest
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class MealieDataSourceWrapper @Inject constructor( class MealieDataSourceWrapper @Inject constructor(
private val serverInfoRepo: ServerInfoRepo, private val serverInfoRepo: ServerInfoRepo,
private val authRepo: AuthRepo,
private val v0Source: MealieDataSourceV0, private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1, private val v1Source: MealieDataSourceV1,
private val logger: Logger,
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource { ) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
override suspend fun addRecipe( private suspend fun getVersion(): ServerVersion = serverInfoRepo.getVersion()
recipe: AddRecipeInfo,
): String = makeCall { token, url, version -> private suspend fun getUrl(): String = serverInfoRepo.requireUrl()
when (version) {
ServerVersion.V0 -> v0Source.addRecipe(url, token, recipe.toV0Request()) override suspend fun addRecipe(recipe: AddRecipeInfo): String = when (getVersion()) {
ServerVersion.V1 -> { ServerVersion.V0 -> v0Source.addRecipe(getUrl(), recipe.toV0Request())
val slug = v1Source.createRecipe(url, token, recipe.toV1CreateRequest()) ServerVersion.V1 -> {
v1Source.updateRecipe(url, token, slug, recipe.toV1UpdateRequest()) val slug = v1Source.createRecipe(getUrl(), recipe.toV1CreateRequest())
slug v1Source.updateRecipe(getUrl(), slug, recipe.toV1UpdateRequest())
} slug
} }
} }
override suspend fun requestRecipes( override suspend fun requestRecipes(
start: Int, start: Int,
limit: Int, limit: Int,
): List<RecipeSummaryInfo> = makeCall { token, url, version -> ): List<RecipeSummaryInfo> = when (getVersion()) {
when (version) { ServerVersion.V0 -> {
ServerVersion.V0 -> { v0Source.requestRecipes(getUrl(), start, limit).map { it.toRecipeSummaryInfo() }
v0Source.requestRecipes(url, token, start, limit).map { it.toRecipeSummaryInfo() } }
} ServerVersion.V1 -> {
ServerVersion.V1 -> { // Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we need page 3
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we need page 3 val page = start / limit + 1
val page = start / limit + 1 v1Source.requestRecipes(getUrl(), page, limit).map { it.toRecipeSummaryInfo() }
v1Source.requestRecipes(url, token, page, limit).map { it.toRecipeSummaryInfo() }
}
} }
} }
override suspend fun requestRecipeInfo( override suspend fun requestRecipeInfo(slug: String): FullRecipeInfo = when (getVersion()) {
slug: String, ServerVersion.V0 -> v0Source.requestRecipeInfo(getUrl(), slug).toFullRecipeInfo()
): FullRecipeInfo = makeCall { token, url, version -> ServerVersion.V1 -> v1Source.requestRecipeInfo(getUrl(), slug).toFullRecipeInfo()
when (version) {
ServerVersion.V0 -> v0Source.requestRecipeInfo(url, token, slug).toFullRecipeInfo()
ServerVersion.V1 -> v1Source.requestRecipeInfo(url, token, slug).toFullRecipeInfo()
}
} }
override suspend fun parseRecipeFromURL( override suspend fun parseRecipeFromURL(
parseRecipeURLInfo: ParseRecipeURLInfo, parseRecipeURLInfo: ParseRecipeURLInfo,
): String = makeCall { token, url, version -> ): String = when (getVersion()) {
when (version) { ServerVersion.V0 -> v0Source.parseRecipeFromURL(getUrl(), parseRecipeURLInfo.toV0Request())
ServerVersion.V0 -> { ServerVersion.V1 -> v1Source.parseRecipeFromURL(getUrl(), parseRecipeURLInfo.toV1Request())
v0Source.parseRecipeFromURL(url, token, parseRecipeURLInfo.toV0Request())
}
ServerVersion.V1 -> {
v1Source.parseRecipeFromURL(url, token, parseRecipeURLInfo.toV1Request())
}
}
} }
private suspend inline fun <T> makeCall(block: (String?, String, ServerVersion) -> T): T {
val authHeader = authRepo.getAuthHeader()
val url = serverInfoRepo.requireUrl()
val version = serverInfoRepo.getVersion()
return runCatchingExceptCancel { block(authHeader, url, version) }.getOrElse {
if (it is NetworkError.Unauthorized) {
logger.e { "Unauthorized, trying to invalidate token" }
authRepo.invalidateAuthHeader()
// Trying again with new authentication header
val newHeader = authRepo.getAuthHeader()
logger.e { "New token ${if (newHeader == authHeader) "matches" else "doesn't match"} old token" }
if (newHeader == authHeader) throw it else block(newHeader, url, version)
} else {
throw it
}
}
}
} }

View File

@@ -11,6 +11,8 @@ interface PreferencesStorage {
val isDisclaimerAcceptedKey: Preferences.Key<Boolean> val isDisclaimerAcceptedKey: Preferences.Key<Boolean>
val lastExecutedMigrationVersionKey: Preferences.Key<Int>
suspend fun <T> getValue(key: Preferences.Key<T>): T? suspend fun <T> getValue(key: Preferences.Key<T>): T?
suspend fun <T> requireValue(key: Preferences.Key<T>): T suspend fun <T> requireValue(key: Preferences.Key<T>): T

View File

@@ -4,9 +4,15 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -22,6 +28,9 @@ class PreferencesStorageImpl @Inject constructor(
override val isDisclaimerAcceptedKey = booleanPreferencesKey("isDisclaimedAccepted") override val isDisclaimerAcceptedKey = booleanPreferencesKey("isDisclaimedAccepted")
override val lastExecutedMigrationVersionKey: Preferences.Key<Int> =
intPreferencesKey("lastExecutedMigrationVersion")
override suspend fun <T> getValue(key: Preferences.Key<T>): T? { override suspend fun <T> getValue(key: Preferences.Key<T>): T? {
val value = dataStore.data.first()[key] val value = dataStore.data.first()[key]
logger.v { "getValue() returned: $value for $key" } logger.v { "getValue() returned: $value for $key" }

View File

@@ -1,11 +1,11 @@
package gq.kirmanak.mealient.architecture package gq.kirmanak.mealient.di
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration
import gq.kirmanak.mealient.architecture.configuration.BuildConfigurationImpl import gq.kirmanak.mealient.data.configuration.BuildConfigurationImpl
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module

View File

@@ -14,6 +14,7 @@ import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -37,6 +38,10 @@ interface AuthModule {
@Singleton @Singleton
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
@Binds
@Singleton
fun bindAuthProvider(authRepo: AuthRepoImpl): AuthenticationProvider
@Binds @Binds
@Singleton @Singleton
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage

View File

@@ -0,0 +1,26 @@
package gq.kirmanak.mealient.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.migration.From24AuthMigrationExecutor
import gq.kirmanak.mealient.data.migration.MigrationDetector
import gq.kirmanak.mealient.data.migration.MigrationDetectorImpl
import gq.kirmanak.mealient.data.migration.MigrationExecutor
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface MigrationModule {
@Binds
@Singleton
@IntoSet
fun bindFrom24AuthMigrationExecutor(from24AuthMigrationExecutor: From24AuthMigrationExecutor): MigrationExecutor
@Binds
@Singleton
fun bindMigrationDetector(migrationDetectorImpl: MigrationDetectorImpl): MigrationDetector
}

View File

@@ -1,12 +1,12 @@
package gq.kirmanak.mealient.ui.recipes.images package gq.kirmanak.mealient.ui.recipes.images
import com.bumptech.glide.load.Options import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.* import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.ModelCache
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.stream.BaseGlideUrlLoader import com.bumptech.glide.load.model.stream.BaseGlideUrlLoader
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.DataSourceModule.Companion.AUTHORIZATION_HEADER_NAME
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.io.InputStream import java.io.InputStream
@@ -16,7 +16,6 @@ import javax.inject.Singleton
class RecipeModelLoader private constructor( class RecipeModelLoader private constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider, private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger, private val logger: Logger,
private val authRepo: AuthRepo,
concreteLoader: ModelLoader<GlideUrl, InputStream>, concreteLoader: ModelLoader<GlideUrl, InputStream>,
cache: ModelCache<RecipeSummaryEntity, GlideUrl>, cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
) : BaseGlideUrlLoader<RecipeSummaryEntity>(concreteLoader, cache) { ) : BaseGlideUrlLoader<RecipeSummaryEntity>(concreteLoader, cache) {
@@ -25,13 +24,12 @@ class RecipeModelLoader private constructor(
class Factory @Inject constructor( class Factory @Inject constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider, private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger, private val logger: Logger,
private val authRepo: AuthRepo,
) { ) {
fun build( fun build(
concreteLoader: ModelLoader<GlideUrl, InputStream>, concreteLoader: ModelLoader<GlideUrl, InputStream>,
cache: ModelCache<RecipeSummaryEntity, GlideUrl>, cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
) = RecipeModelLoader(recipeImageUrlProvider, logger, authRepo, concreteLoader, cache) ) = RecipeModelLoader(recipeImageUrlProvider, logger, concreteLoader, cache)
} }
@@ -46,20 +44,4 @@ class RecipeModelLoader private constructor(
logger.v { "getUrl() called with: model = $model, width = $width, height = $height, options = $options" } logger.v { "getUrl() called with: model = $model, width = $width, height = $height, options = $options" }
return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.imageId) } return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.imageId) }
} }
override fun getHeaders(
model: RecipeSummaryEntity?,
width: Int,
height: Int,
options: Options?
): Headers? {
val authorization = runBlocking { authRepo.getAuthHeader() }
return if (authorization.isNullOrBlank()) {
super.getHeaders(model, width, height, options)
} else {
LazyHeaders.Builder()
.setHeader(AUTHORIZATION_HEADER_NAME, authorization)
.build()
}
}
} }

View File

@@ -6,6 +6,8 @@ import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_API_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_API_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
@@ -51,13 +53,15 @@ class AuthRepoImplTest : BaseUnitTest() {
@Test @Test
fun `when authenticate successfully then saves to storage`() = runTest { fun `when authenticate successfully then saves to storage`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0 coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN coEvery { dataSource.authenticate(any(), any()) } returns TEST_TOKEN
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { dataSource.createApiToken(any()) } returns TEST_API_TOKEN
subject.authenticate(TEST_USERNAME, TEST_PASSWORD) subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
coVerifyAll { coVerify {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD))
storage.setAuthHeader(TEST_AUTH_HEADER) storage.setAuthHeader(TEST_AUTH_HEADER)
storage.setEmail(TEST_USERNAME) dataSource.createApiToken(eq("Mealient"))
storage.setPassword(TEST_PASSWORD) storage.setAuthHeader(TEST_API_AUTH_HEADER)
} }
confirmVerified(storage) confirmVerified(storage)
} }
@@ -71,50 +75,9 @@ class AuthRepoImplTest : BaseUnitTest() {
} }
@Test @Test
fun `when logout then removes email, password and header`() = runTest { fun `when logout expect header removal`() = runTest {
subject.logout() subject.logout()
coVerifyAll { coVerify { storage.setAuthHeader(null) }
storage.setEmail(null)
storage.setPassword(null)
storage.setAuthHeader(null)
}
confirmVerified(storage) confirmVerified(storage)
} }
@Test
fun `when invalidate then does not authenticate without email`() = runTest {
coEvery { storage.getEmail() } returns null
coEvery { storage.getPassword() } returns TEST_PASSWORD
subject.invalidateAuthHeader()
confirmVerified(dataSource)
}
@Test
fun `when invalidate then does not authenticate without password`() = runTest {
coEvery { storage.getEmail() } returns TEST_USERNAME
coEvery { storage.getPassword() } returns null
subject.invalidateAuthHeader()
confirmVerified(dataSource)
}
@Test
fun `when invalidate with credentials then calls authenticate`() = runTest {
coEvery { storage.getEmail() } returns TEST_USERNAME
coEvery { storage.getPassword() } returns TEST_PASSWORD
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN
subject.invalidateAuthHeader()
coVerifyAll { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) }
}
@Test
fun `when invalidate with credentials and auth fails then clears email`() = runTest {
coEvery { storage.getEmail() } returns "invalid"
coEvery { storage.getPassword() } returns ""
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { dataSource.authenticate(any(), any()) } throws RuntimeException()
subject.invalidateAuthHeader()
coVerify { storage.setEmail(null) }
}
} }

View File

@@ -8,13 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.AUTH_HEADER_KEY import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.AUTH_HEADER_KEY
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.EMAIL_KEY
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.PASSWORD_KEY
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.FakeLogger
import gq.kirmanak.mealient.test.HiltRobolectricTest import gq.kirmanak.mealient.test.HiltRobolectricTest
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -32,8 +26,6 @@ class AuthStorageImplTest : HiltRobolectricTest() {
@ApplicationContext @ApplicationContext
lateinit var context: Context lateinit var context: Context
private val logger: Logger = FakeLogger()
lateinit var subject: AuthStorage lateinit var subject: AuthStorage
lateinit var sharedPreferences: SharedPreferences lateinit var sharedPreferences: SharedPreferences
@@ -55,16 +47,4 @@ class AuthStorageImplTest : HiltRobolectricTest() {
fun `when authHeader is observed then sends null if nothing saved`() = runTest { fun `when authHeader is observed then sends null if nothing saved`() = runTest {
assertThat(subject.authHeaderFlow.first()).isEqualTo(null) assertThat(subject.authHeaderFlow.first()).isEqualTo(null)
} }
@Test
fun `when setEmail then edits shared preferences`() = runTest {
subject.setEmail(TEST_USERNAME)
assertThat(sharedPreferences.getString(EMAIL_KEY, null)).isEqualTo(TEST_USERNAME)
}
@Test
fun `when getPassword then reads shared preferences`() = runTest {
sharedPreferences.edit(commit = true) { putString(PASSWORD_KEY, TEST_PASSWORD) }
assertThat(subject.getPassword()).isEqualTo(TEST_PASSWORD)
}
} }

View File

@@ -11,6 +11,7 @@ import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest @HiltAndroidTest
class DisclaimerStorageImplTest : HiltRobolectricTest() { class DisclaimerStorageImplTest : HiltRobolectricTest() {
@Inject @Inject
lateinit var subject: DisclaimerStorageImpl lateinit var subject: DisclaimerStorageImpl

View File

@@ -0,0 +1,81 @@
package gq.kirmanak.mealient.data.migration
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.test.HiltRobolectricTest
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class From24AuthMigrationExecutorTest : HiltRobolectricTest() {
@Inject
@ApplicationContext
lateinit var context: Context
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
private lateinit var subject: MigrationExecutor
private lateinit var sharedPreferences: SharedPreferences
@Before
fun setUp() {
MockKAnnotations.init(this)
sharedPreferences = context.getSharedPreferences("test", Context.MODE_PRIVATE)
subject = From24AuthMigrationExecutor(sharedPreferences, authRepo, logger)
}
@Test
fun `when there were email and password expect authentication`() = runTest {
sharedPreferences.edit(commit = true) {
putString("email", "email_value")
putString("password", "pass_value")
}
subject.executeMigration()
coVerify { authRepo.authenticate(eq("email_value"), eq("pass_value")) }
}
@Test
fun `when there were email and password expect them gone`() = runTest {
sharedPreferences.edit(commit = true) {
putString("email", "email_value")
putString("password", "pass_value")
}
subject.executeMigration()
assertThat(sharedPreferences.getString("email", null)).isNull()
assertThat(sharedPreferences.getString("password", null)).isNull()
}
@Test
fun `when there is email and password but authenticate fails expect values gone`() = runTest {
sharedPreferences.edit(commit = true) {
putString("email", "email_value")
putString("password", "pass_value")
}
coEvery { authRepo.authenticate(any(), any()) } throws IOException()
subject.executeMigration()
assertThat(sharedPreferences.getString("email", null)).isNull()
assertThat(sharedPreferences.getString("password", null)).isNull()
}
@Test
fun `when there was no email and password expect no authentication`() = runTest {
subject.executeMigration()
coVerify(inverse = true) { authRepo.authenticate(any(), any()) }
}
}

View File

@@ -0,0 +1,88 @@
package gq.kirmanak.mealient.data.migration
import androidx.datastore.preferences.core.intPreferencesKey
import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MigrationDetectorImplTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var buildConfiguration: BuildConfiguration
@MockK(relaxUnitFun = true)
lateinit var preferencesStorage: PreferencesStorage
@Test
fun `when last version matches current expect no executors to be called`() = runTest {
val executors = setOf<MigrationExecutor>(mockk(), mockk())
val key = intPreferencesKey("key")
every { preferencesStorage.lastExecutedMigrationVersionKey } returns intPreferencesKey("key")
coEvery { preferencesStorage.getValue(key) } returns 25
coEvery { buildConfiguration.versionCode() } returns 25
buildSubject(executors).executeMigrations()
executors.forEach {
coVerify(inverse = true) { it.migratingFrom }
coVerify(inverse = true) { it.executeMigration() }
}
}
@Test
fun `when last version is 24 and current is 25 expect all executors to be checked`() = runTest {
val firstExecutor = mockk<MigrationExecutor>()
every { firstExecutor.migratingFrom } returns 3
val secondExecutor = mockk<MigrationExecutor>()
every { secondExecutor.migratingFrom } returns 5
val executors = setOf(firstExecutor, secondExecutor)
val key = intPreferencesKey("key")
every { preferencesStorage.lastExecutedMigrationVersionKey } returns intPreferencesKey("key")
coEvery { preferencesStorage.getValue(key) } returns 24
coEvery { buildConfiguration.versionCode() } returns 25
buildSubject(executors).executeMigrations()
executors.forEach {
coVerify { it.migratingFrom }
coVerify(inverse = true) { it.executeMigration() }
}
}
@Test
fun `when last version is 24 and current is 25 expect the executor to be called`() = runTest {
val firstExecutor = mockk<MigrationExecutor>(relaxUnitFun = true)
every { firstExecutor.migratingFrom } returns 24
val executors = setOf(firstExecutor)
val key = intPreferencesKey("key")
every { preferencesStorage.lastExecutedMigrationVersionKey } returns intPreferencesKey("key")
coEvery { preferencesStorage.getValue(key) } returns 24
coEvery { buildConfiguration.versionCode() } returns 25
buildSubject(executors).executeMigrations()
executors.forEach {
coVerify { it.migratingFrom }
coVerify { it.executeMigration() }
}
}
private fun buildSubject(executors: Set<MigrationExecutor>) = MigrationDetectorImpl(
preferencesStorage = preferencesStorage,
migrationExecutors = executors,
buildConfiguration = buildConfiguration,
logger = logger,
)
}

View File

@@ -3,7 +3,6 @@ package gq.kirmanak.mealient.data.network
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0 import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1 import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
@@ -15,7 +14,6 @@ import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_INFO
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_REQUEST_V0 import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_REQUEST_V0
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_CREATE_RECIPE_REQUEST_V1 import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_CREATE_RECIPE_REQUEST_V1
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_FULL_RECIPE_INFO import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_FULL_RECIPE_INFO
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_RESPONSE_V0
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_RESPONSE_V1 import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_RESPONSE_V1
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_RESPONSE_V0 import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_RESPONSE_V0
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_RESPONSE_V1 import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_RESPONSE_V1
@@ -50,36 +48,14 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
@Before @Before
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
subject = MealieDataSourceWrapper(serverInfoRepo, authRepo, v0Source, v1Source, logger) subject = MealieDataSourceWrapper(serverInfoRepo, v0Source, v1Source)
}
@Test
fun `when makeCall fails with Unauthorized expect it to invalidate token`() = runTest {
val slug = "porridge"
coEvery {
v0Source.requestRecipeInfo(any(), isNull(), any())
} throws NetworkError.Unauthorized(IOException())
coEvery {
v0Source.requestRecipeInfo(any(), eq(TEST_AUTH_HEADER), any())
} returns PORRIDGE_RECIPE_RESPONSE_V0
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { authRepo.getAuthHeader() } returns null andThen TEST_AUTH_HEADER
subject.requestRecipeInfo(slug)
coVerifySequence {
authRepo.getAuthHeader()
authRepo.invalidateAuthHeader()
authRepo.getAuthHeader()
}
} }
@Test @Test
fun `when server version v1 expect requestRecipeInfo to call v1`() = runTest { fun `when server version v1 expect requestRecipeInfo to call v1`() = runTest {
val slug = "porridge" val slug = "porridge"
coEvery { coEvery {
v1Source.requestRecipeInfo(eq(TEST_BASE_URL), eq(TEST_AUTH_HEADER), eq(slug)) v1Source.requestRecipeInfo(eq(TEST_BASE_URL), eq(slug))
} returns PORRIDGE_RECIPE_RESPONSE_V1 } returns PORRIDGE_RECIPE_RESPONSE_V1
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
@@ -87,7 +63,7 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
val actual = subject.requestRecipeInfo(slug) val actual = subject.requestRecipeInfo(slug)
coVerify { v1Source.requestRecipeInfo(eq(TEST_BASE_URL), eq(TEST_AUTH_HEADER), eq(slug)) } coVerify { v1Source.requestRecipeInfo(eq(TEST_BASE_URL), eq(slug)) }
assertThat(actual).isEqualTo(PORRIDGE_FULL_RECIPE_INFO) assertThat(actual).isEqualTo(PORRIDGE_FULL_RECIPE_INFO)
} }
@@ -95,7 +71,7 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
@Test @Test
fun `when server version v1 expect requestRecipes to call v1`() = runTest { fun `when server version v1 expect requestRecipes to call v1`() = runTest {
coEvery { coEvery {
v1Source.requestRecipes(any(), any(), any(), any()) v1Source.requestRecipes(any(), any(), any())
} returns listOf(PORRIDGE_RECIPE_SUMMARY_RESPONSE_V1) } returns listOf(PORRIDGE_RECIPE_SUMMARY_RESPONSE_V1)
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
@@ -106,7 +82,7 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
val page = 5 // 0-9 (1), 10-19 (2), 20-29 (3), 30-39 (4), 40-49 (5) val page = 5 // 0-9 (1), 10-19 (2), 20-29 (3), 30-39 (4), 40-49 (5)
val perPage = 10 val perPage = 10
coVerify { coVerify {
v1Source.requestRecipes(eq(TEST_BASE_URL), eq(TEST_AUTH_HEADER), eq(page), eq(perPage)) v1Source.requestRecipes(eq(TEST_BASE_URL), eq(page), eq(perPage))
} }
assertThat(actual).isEqualTo(listOf(RECIPE_SUMMARY_PORRIDGE_V1)) assertThat(actual).isEqualTo(listOf(RECIPE_SUMMARY_PORRIDGE_V1))
@@ -115,7 +91,7 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
@Test @Test
fun `when server version v0 expect requestRecipes to call v0`() = runTest { fun `when server version v0 expect requestRecipes to call v0`() = runTest {
coEvery { coEvery {
v0Source.requestRecipes(any(), any(), any(), any()) v0Source.requestRecipes(any(), any(), any())
} returns listOf(PORRIDGE_RECIPE_SUMMARY_RESPONSE_V0) } returns listOf(PORRIDGE_RECIPE_SUMMARY_RESPONSE_V0)
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0 coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
@@ -126,7 +102,7 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
val actual = subject.requestRecipes(start, limit) val actual = subject.requestRecipes(start, limit)
coVerify { coVerify {
v0Source.requestRecipes(eq(TEST_BASE_URL), eq(TEST_AUTH_HEADER), eq(start), eq(limit)) v0Source.requestRecipes(eq(TEST_BASE_URL), eq(start), eq(limit))
} }
assertThat(actual).isEqualTo(listOf(RECIPE_SUMMARY_PORRIDGE_V0)) assertThat(actual).isEqualTo(listOf(RECIPE_SUMMARY_PORRIDGE_V0))
@@ -134,7 +110,7 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
@Test(expected = IOException::class) @Test(expected = IOException::class)
fun `when request fails expect addRecipe to rethrow`() = runTest { fun `when request fails expect addRecipe to rethrow`() = runTest {
coEvery { v0Source.addRecipe(any(), any(), any()) } throws IOException() coEvery { v0Source.addRecipe(any(), any()) } throws IOException()
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0 coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
@@ -145,7 +121,7 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
fun `when server version v0 expect addRecipe to call v0`() = runTest { fun `when server version v0 expect addRecipe to call v0`() = runTest {
val slug = "porridge" val slug = "porridge"
coEvery { v0Source.addRecipe(any(), any(), any()) } returns slug coEvery { v0Source.addRecipe(any(), any()) } returns slug
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0 coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
@@ -155,7 +131,6 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
coVerify { coVerify {
v0Source.addRecipe( v0Source.addRecipe(
eq(TEST_BASE_URL), eq(TEST_BASE_URL),
eq(TEST_AUTH_HEADER),
eq(PORRIDGE_ADD_RECIPE_REQUEST_V0), eq(PORRIDGE_ADD_RECIPE_REQUEST_V0),
) )
} }
@@ -167,9 +142,9 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
fun `when server version v1 expect addRecipe to call v1`() = runTest { fun `when server version v1 expect addRecipe to call v1`() = runTest {
val slug = "porridge" val slug = "porridge"
coEvery { v1Source.createRecipe(any(), any(), any()) } returns slug coEvery { v1Source.createRecipe(any(), any()) } returns slug
coEvery { coEvery {
v1Source.updateRecipe(any(), any(), any(), any()) v1Source.updateRecipe(any(), any(), any())
} returns PORRIDGE_RECIPE_RESPONSE_V1 } returns PORRIDGE_RECIPE_RESPONSE_V1
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL coEvery { serverInfoRepo.requireUrl() } returns TEST_BASE_URL
@@ -180,13 +155,11 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
coVerifySequence { coVerifySequence {
v1Source.createRecipe( v1Source.createRecipe(
eq(TEST_BASE_URL), eq(TEST_BASE_URL),
eq(TEST_AUTH_HEADER),
eq(PORRIDGE_CREATE_RECIPE_REQUEST_V1), eq(PORRIDGE_CREATE_RECIPE_REQUEST_V1),
) )
v1Source.updateRecipe( v1Source.updateRecipe(
eq(TEST_BASE_URL), eq(TEST_BASE_URL),
eq(TEST_AUTH_HEADER),
eq(slug), eq(slug),
eq(PORRIDGE_UPDATE_RECIPE_REQUEST_V1), eq(PORRIDGE_UPDATE_RECIPE_REQUEST_V1),
) )

View File

@@ -8,6 +8,8 @@ object AuthImplTestData {
const val TEST_BASE_URL = "https://example.com/" const val TEST_BASE_URL = "https://example.com/"
const val TEST_TOKEN = "TEST_TOKEN" const val TEST_TOKEN = "TEST_TOKEN"
const val TEST_AUTH_HEADER = "Bearer TEST_TOKEN" const val TEST_AUTH_HEADER = "Bearer TEST_TOKEN"
const val TEST_API_TOKEN = "TEST_API_TOKEN"
const val TEST_API_AUTH_HEADER = "Bearer TEST_API_TOKEN"
const val TEST_VERSION = "v0.5.6" const val TEST_VERSION = "v0.5.6"
val TEST_SERVER_VERSION_V0 = ServerVersion.V0 val TEST_SERVER_VERSION_V0 = ServerVersion.V0
val TEST_SERVER_VERSION_V1 = ServerVersion.V1 val TEST_SERVER_VERSION_V1 = ServerVersion.V1

View File

@@ -3,4 +3,6 @@ package gq.kirmanak.mealient.architecture.configuration
interface BuildConfiguration { interface BuildConfiguration {
fun isDebug(): Boolean fun isDebug(): Boolean
fun versionCode(): Int
} }

View File

@@ -1,14 +0,0 @@
package gq.kirmanak.mealient.architecture.configuration
import gq.kirmanak.mealient.architecture.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BuildConfigurationImpl @Inject constructor() : BuildConfiguration {
@get:JvmName("_isDebug")
private val isDebug by lazy { BuildConfig.DEBUG }
override fun isDebug(): Boolean = isDebug
}

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.datasource
interface AuthenticationProvider {
suspend fun getAuthHeader(): String?
suspend fun logout()
}

View File

@@ -6,6 +6,12 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.datasource.impl.AuthInterceptor
import gq.kirmanak.mealient.datasource.impl.CacheBuilderImpl
import gq.kirmanak.mealient.datasource.impl.NetworkRequestWrapperImpl
import gq.kirmanak.mealient.datasource.impl.OkHttpBuilderImpl
import gq.kirmanak.mealient.datasource.impl.RetrofitBuilder
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0 import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0Impl import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0Impl
import gq.kirmanak.mealient.datasource.v0.MealieServiceV0 import gq.kirmanak.mealient.datasource.v0.MealieServiceV0
@@ -14,6 +20,7 @@ import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1Impl
import gq.kirmanak.mealient.datasource.v1.MealieServiceV1 import gq.kirmanak.mealient.datasource.v1.MealieServiceV1
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Converter import retrofit2.Converter
@@ -27,8 +34,6 @@ interface DataSourceModule {
companion object { companion object {
const val AUTHORIZATION_HEADER_NAME = "Authorization"
@Provides @Provides
@Singleton @Singleton
fun provideJson(): Json = Json { fun provideJson(): Json = Json {
@@ -83,4 +88,9 @@ interface DataSourceModule {
@Binds @Binds
@Singleton @Singleton
fun bindNetworkRequestWrapper(networkRequestWrapperImpl: NetworkRequestWrapperImpl): NetworkRequestWrapper fun bindNetworkRequestWrapper(networkRequestWrapperImpl: NetworkRequestWrapperImpl): NetworkRequestWrapper
@Binds
@Singleton
@IntoSet
fun bindAuthInterceptor(authInterceptor: AuthInterceptor): Interceptor
} }

View File

@@ -0,0 +1,43 @@
package gq.kirmanak.mealient.datasource.impl
import androidx.annotation.VisibleForTesting
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@Singleton
class AuthInterceptor @Inject constructor(
private val logger: Logger,
private val authenticationProviderProvider: Provider<AuthenticationProvider>,
) : Interceptor {
private val authenticationProvider: AuthenticationProvider
get() = authenticationProviderProvider.get()
override fun intercept(chain: Interceptor.Chain): Response {
logger.v { "intercept() was called" }
val header = getAuthHeader()
val request = chain.request().let {
if (header == null) it else it.newBuilder().header(HEADER_NAME, header).build()
}
logger.d { "Sending header $HEADER_NAME=${request.header(HEADER_NAME)}" }
return chain.proceed(request).also {
logger.v { "Response code is ${it.code}" }
if (it.code == 401 && header != null) logout()
}
}
private fun getAuthHeader() = runBlocking { authenticationProvider.getAuthHeader() }
private fun logout() = runBlocking { authenticationProvider.logout() }
companion object {
@VisibleForTesting
const val HEADER_NAME = "Authorization"
}
}

View File

@@ -1,8 +1,9 @@
package gq.kirmanak.mealient.datasource package gq.kirmanak.mealient.datasource.impl
import android.content.Context import android.content.Context
import android.os.StatFs import android.os.StatFs
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.datasource.CacheBuilder
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import okhttp3.Cache import okhttp3.Cache
import java.io.File import java.io.File

View File

@@ -1,5 +1,8 @@
package gq.kirmanak.mealient.datasource package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.NetworkRequestWrapper
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import retrofit2.HttpException import retrofit2.HttpException
import javax.inject.Inject import javax.inject.Inject

View File

@@ -1,5 +1,7 @@
package gq.kirmanak.mealient.datasource package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.datasource.CacheBuilder
import gq.kirmanak.mealient.datasource.OkHttpBuilder
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import javax.inject.Inject import javax.inject.Inject

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.datasource package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import okhttp3.OkHttpClient import okhttp3.OkHttpClient

View File

@@ -1,6 +1,7 @@
package gq.kirmanak.mealient.datasource.v0 package gq.kirmanak.mealient.datasource.v0
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeRequestV0 import gq.kirmanak.mealient.datasource.v0.models.AddRecipeRequestV0
import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0 import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0 import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0
import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0 import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0
@@ -10,7 +11,6 @@ interface MealieDataSourceV0 {
suspend fun addRecipe( suspend fun addRecipe(
baseUrl: String, baseUrl: String,
token: String?,
recipe: AddRecipeRequestV0, recipe: AddRecipeRequestV0,
): String ): String
@@ -29,20 +29,22 @@ interface MealieDataSourceV0 {
suspend fun requestRecipes( suspend fun requestRecipes(
baseUrl: String, baseUrl: String,
token: String?,
start: Int, start: Int,
limit: Int, limit: Int,
): List<GetRecipeSummaryResponseV0> ): List<GetRecipeSummaryResponseV0>
suspend fun requestRecipeInfo( suspend fun requestRecipeInfo(
baseUrl: String, baseUrl: String,
token: String?,
slug: String, slug: String,
): GetRecipeResponseV0 ): GetRecipeResponseV0
suspend fun parseRecipeFromURL( suspend fun parseRecipeFromURL(
baseUrl: String, baseUrl: String,
token: String?,
request: ParseRecipeURLRequestV0, request: ParseRecipeURLRequestV0,
): String ): String
suspend fun createApiToken(
baseUrl: String,
request: CreateApiTokenRequestV0,
): String
} }

View File

@@ -4,6 +4,7 @@ import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.NetworkRequestWrapper import gq.kirmanak.mealient.datasource.NetworkRequestWrapper
import gq.kirmanak.mealient.datasource.decode import gq.kirmanak.mealient.datasource.decode
import gq.kirmanak.mealient.datasource.v0.models.AddRecipeRequestV0 import gq.kirmanak.mealient.datasource.v0.models.AddRecipeRequestV0
import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0
import gq.kirmanak.mealient.datasource.v0.models.ErrorDetailV0 import gq.kirmanak.mealient.datasource.v0.models.ErrorDetailV0
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0 import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0
import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0 import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0
@@ -26,12 +27,11 @@ class MealieDataSourceV0Impl @Inject constructor(
override suspend fun addRecipe( override suspend fun addRecipe(
baseUrl: String, baseUrl: String,
token: String?,
recipe: AddRecipeRequestV0, recipe: AddRecipeRequestV0,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized( ): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.addRecipe("$baseUrl/api/recipes/create", token, recipe) }, block = { service.addRecipe("$baseUrl/api/recipes/create", recipe) },
logMethod = { "addRecipe" }, logMethod = { "addRecipe" },
logParameters = { "baseUrl = $baseUrl, token = $token, recipe = $recipe" } logParameters = { "baseUrl = $baseUrl, recipe = $recipe" }
) )
override suspend fun authenticate( override suspend fun authenticate(
@@ -64,33 +64,38 @@ class MealieDataSourceV0Impl @Inject constructor(
override suspend fun requestRecipes( override suspend fun requestRecipes(
baseUrl: String, baseUrl: String,
token: String?,
start: Int, start: Int,
limit: Int, limit: Int,
): List<GetRecipeSummaryResponseV0> = networkRequestWrapper.makeCallAndHandleUnauthorized( ): List<GetRecipeSummaryResponseV0> = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary("$baseUrl/api/recipes/summary", token, start, limit) }, block = { service.getRecipeSummary("$baseUrl/api/recipes/summary", start, limit) },
logMethod = { "requestRecipes" }, logMethod = { "requestRecipes" },
logParameters = { "baseUrl = $baseUrl, token = $token, start = $start, limit = $limit" } logParameters = { "baseUrl = $baseUrl, start = $start, limit = $limit" }
) )
override suspend fun requestRecipeInfo( override suspend fun requestRecipeInfo(
baseUrl: String, baseUrl: String,
token: String?,
slug: String, slug: String,
): GetRecipeResponseV0 = networkRequestWrapper.makeCallAndHandleUnauthorized( ): GetRecipeResponseV0 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe("$baseUrl/api/recipes/$slug", token) }, block = { service.getRecipe("$baseUrl/api/recipes/$slug") },
logMethod = { "requestRecipeInfo" }, logMethod = { "requestRecipeInfo" },
logParameters = { "baseUrl = $baseUrl, token = $token, slug = $slug" } logParameters = { "baseUrl = $baseUrl, slug = $slug" }
) )
override suspend fun parseRecipeFromURL( override suspend fun parseRecipeFromURL(
baseUrl: String, baseUrl: String,
token: String?,
request: ParseRecipeURLRequestV0 request: ParseRecipeURLRequestV0
): String = networkRequestWrapper.makeCallAndHandleUnauthorized( ): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL("$baseUrl/api/recipes/create-url", token, request) }, block = { service.createRecipeFromURL("$baseUrl/api/recipes/create-url", request) },
logMethod = { "parseRecipeFromURL" }, logMethod = { "parseRecipeFromURL" },
logParameters = { "baseUrl = $baseUrl, token = $token, request = $request" } logParameters = { "baseUrl = $baseUrl, request = $request" },
)
override suspend fun createApiToken(
baseUrl: String,
request: CreateApiTokenRequestV0,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken("$baseUrl/api/users/api-tokens", request) },
logMethod = { "createApiToken" },
logParameters = { "baseUrl = $baseUrl, request = $request" }
) )
} }

View File

@@ -1,6 +1,5 @@
package gq.kirmanak.mealient.datasource.v0 package gq.kirmanak.mealient.datasource.v0
import gq.kirmanak.mealient.datasource.DataSourceModule.Companion.AUTHORIZATION_HEADER_NAME
import gq.kirmanak.mealient.datasource.v0.models.* import gq.kirmanak.mealient.datasource.v0.models.*
import retrofit2.http.* import retrofit2.http.*
@@ -17,7 +16,6 @@ interface MealieServiceV0 {
@POST @POST
suspend fun addRecipe( suspend fun addRecipe(
@Url url: String, @Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Body addRecipeRequestV0: AddRecipeRequestV0, @Body addRecipeRequestV0: AddRecipeRequestV0,
): String ): String
@@ -29,7 +27,6 @@ interface MealieServiceV0 {
@GET @GET
suspend fun getRecipeSummary( suspend fun getRecipeSummary(
@Url url: String, @Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Query("start") start: Int, @Query("start") start: Int,
@Query("limit") limit: Int, @Query("limit") limit: Int,
): List<GetRecipeSummaryResponseV0> ): List<GetRecipeSummaryResponseV0>
@@ -37,13 +34,17 @@ interface MealieServiceV0 {
@GET @GET
suspend fun getRecipe( suspend fun getRecipe(
@Url url: String, @Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
): GetRecipeResponseV0 ): GetRecipeResponseV0
@POST @POST
suspend fun createRecipeFromURL( suspend fun createRecipeFromURL(
@Url url: String, @Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Body request: ParseRecipeURLRequestV0, @Body request: ParseRecipeURLRequestV0,
): String ): String
@POST
suspend fun createApiToken(
@Url url: String,
@Body request: CreateApiTokenRequestV0,
): String
} }

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.datasource.v0.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CreateApiTokenRequestV0(
@SerialName("name") val name: String,
)

View File

@@ -1,5 +1,7 @@
package gq.kirmanak.mealient.datasource.v1 package gq.kirmanak.mealient.datasource.v1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1
@@ -11,13 +13,11 @@ interface MealieDataSourceV1 {
suspend fun createRecipe( suspend fun createRecipe(
baseUrl: String, baseUrl: String,
token: String?,
recipe: CreateRecipeRequestV1, recipe: CreateRecipeRequestV1,
): String ): String
suspend fun updateRecipe( suspend fun updateRecipe(
baseUrl: String, baseUrl: String,
token: String?,
slug: String, slug: String,
recipe: UpdateRecipeRequestV1, recipe: UpdateRecipeRequestV1,
): GetRecipeResponseV1 ): GetRecipeResponseV1
@@ -37,20 +37,22 @@ interface MealieDataSourceV1 {
suspend fun requestRecipes( suspend fun requestRecipes(
baseUrl: String, baseUrl: String,
token: String?,
page: Int, page: Int,
perPage: Int, perPage: Int,
): List<GetRecipeSummaryResponseV1> ): List<GetRecipeSummaryResponseV1>
suspend fun requestRecipeInfo( suspend fun requestRecipeInfo(
baseUrl: String, baseUrl: String,
token: String?,
slug: String, slug: String,
): GetRecipeResponseV1 ): GetRecipeResponseV1
suspend fun parseRecipeFromURL( suspend fun parseRecipeFromURL(
baseUrl: String, baseUrl: String,
token: String?,
request: ParseRecipeURLRequestV1, request: ParseRecipeURLRequestV1,
): String ): String
suspend fun createApiToken(
baseUrl: String,
request: CreateApiTokenRequestV1,
): CreateApiTokenResponseV1
} }

View File

@@ -3,6 +3,8 @@ package gq.kirmanak.mealient.datasource.v1
import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.NetworkRequestWrapper import gq.kirmanak.mealient.datasource.NetworkRequestWrapper
import gq.kirmanak.mealient.datasource.decode import gq.kirmanak.mealient.datasource.decode
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenRequestV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1
import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1
import gq.kirmanak.mealient.datasource.v1.models.ErrorDetailV1 import gq.kirmanak.mealient.datasource.v1.models.ErrorDetailV1
import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1
@@ -27,23 +29,21 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun createRecipe( override suspend fun createRecipe(
baseUrl: String, baseUrl: String,
token: String?,
recipe: CreateRecipeRequestV1 recipe: CreateRecipeRequestV1
): String = networkRequestWrapper.makeCallAndHandleUnauthorized( ): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipe("$baseUrl/api/recipes", token, recipe) }, block = { service.createRecipe("$baseUrl/api/recipes", recipe) },
logMethod = { "createRecipe" }, logMethod = { "createRecipe" },
logParameters = { "baseUrl = $baseUrl, token = $token, recipe = $recipe" } logParameters = { "baseUrl = $baseUrl, recipe = $recipe" }
) )
override suspend fun updateRecipe( override suspend fun updateRecipe(
baseUrl: String, baseUrl: String,
token: String?,
slug: String, slug: String,
recipe: UpdateRecipeRequestV1 recipe: UpdateRecipeRequestV1
): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized( ): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateRecipe("$baseUrl/api/recipes/$slug", token, recipe) }, block = { service.updateRecipe("$baseUrl/api/recipes/$slug", recipe) },
logMethod = { "updateRecipe" }, logMethod = { "updateRecipe" },
logParameters = { "baseUrl = $baseUrl, token = $token, slug = $slug, recipe = $recipe" } logParameters = { "baseUrl = $baseUrl, slug = $slug, recipe = $recipe" }
) )
override suspend fun authenticate( override suspend fun authenticate(
@@ -76,35 +76,39 @@ class MealieDataSourceV1Impl @Inject constructor(
override suspend fun requestRecipes( override suspend fun requestRecipes(
baseUrl: String, baseUrl: String,
token: String?,
page: Int, page: Int,
perPage: Int perPage: Int
): List<GetRecipeSummaryResponseV1> = networkRequestWrapper.makeCallAndHandleUnauthorized( ): List<GetRecipeSummaryResponseV1> = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary("$baseUrl/api/recipes", token, page, perPage) }, block = { service.getRecipeSummary("$baseUrl/api/recipes", page, perPage) },
logMethod = { "requestRecipes" }, logMethod = { "requestRecipes" },
logParameters = { "baseUrl = $baseUrl, token = $token, page = $page, perPage = $perPage" } logParameters = { "baseUrl = $baseUrl, page = $page, perPage = $perPage" }
).items ).items
override suspend fun requestRecipeInfo( override suspend fun requestRecipeInfo(
baseUrl: String, baseUrl: String,
token: String?,
slug: String slug: String
): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized( ): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe("$baseUrl/api/recipes/$slug", token) }, block = { service.getRecipe("$baseUrl/api/recipes/$slug") },
logMethod = { "requestRecipeInfo" }, logMethod = { "requestRecipeInfo" },
logParameters = { "baseUrl = $baseUrl, token = $token, slug = $slug" } logParameters = { "baseUrl = $baseUrl, slug = $slug" }
) )
override suspend fun parseRecipeFromURL( override suspend fun parseRecipeFromURL(
baseUrl: String, baseUrl: String,
token: String?,
request: ParseRecipeURLRequestV1 request: ParseRecipeURLRequestV1
): String = networkRequestWrapper.makeCallAndHandleUnauthorized( ): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL("$baseUrl/api/recipes/create-url", token, request) }, block = { service.createRecipeFromURL("$baseUrl/api/recipes/create-url", request) },
logMethod = { "parseRecipeFromURL" }, logMethod = { "parseRecipeFromURL" },
logParameters = { "baseUrl = $baseUrl, token = $token, request = $request" } logParameters = { "baseUrl = $baseUrl, request = $request" }
) )
override suspend fun createApiToken(
baseUrl: String,
request: CreateApiTokenRequestV1
): CreateApiTokenResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken("$baseUrl/api/users/api-tokens", request) },
logMethod = { "createApiToken" },
logParameters = { "baseUrl = $baseUrl, request = $request" }
)
} }

View File

@@ -1,6 +1,5 @@
package gq.kirmanak.mealient.datasource.v1 package gq.kirmanak.mealient.datasource.v1
import gq.kirmanak.mealient.datasource.DataSourceModule.Companion.AUTHORIZATION_HEADER_NAME
import gq.kirmanak.mealient.datasource.v1.models.* import gq.kirmanak.mealient.datasource.v1.models.*
import retrofit2.http.* import retrofit2.http.*
@@ -17,14 +16,12 @@ interface MealieServiceV1 {
@POST @POST
suspend fun createRecipe( suspend fun createRecipe(
@Url url: String, @Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Body addRecipeRequest: CreateRecipeRequestV1, @Body addRecipeRequest: CreateRecipeRequestV1,
): String ): String
@PATCH @PATCH
suspend fun updateRecipe( suspend fun updateRecipe(
@Url url: String, @Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Body addRecipeRequest: UpdateRecipeRequestV1, @Body addRecipeRequest: UpdateRecipeRequestV1,
): GetRecipeResponseV1 ): GetRecipeResponseV1
@@ -36,7 +33,6 @@ interface MealieServiceV1 {
@GET @GET
suspend fun getRecipeSummary( suspend fun getRecipeSummary(
@Url url: String, @Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Query("page") page: Int, @Query("page") page: Int,
@Query("perPage") perPage: Int, @Query("perPage") perPage: Int,
): GetRecipesResponseV1 ): GetRecipesResponseV1
@@ -44,13 +40,17 @@ interface MealieServiceV1 {
@GET @GET
suspend fun getRecipe( suspend fun getRecipe(
@Url url: String, @Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
): GetRecipeResponseV1 ): GetRecipeResponseV1
@POST @POST
suspend fun createRecipeFromURL( suspend fun createRecipeFromURL(
@Url url: String, @Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Body request: ParseRecipeURLRequestV1, @Body request: ParseRecipeURLRequestV1,
): String ): String
@POST
suspend fun createApiToken(
@Url url: String,
@Body request: CreateApiTokenRequestV1,
): CreateApiTokenResponseV1
} }

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.datasource.v1.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CreateApiTokenRequestV1(
@SerialName("name") val name: String,
)

View File

@@ -0,0 +1,9 @@
package gq.kirmanak.mealient.datasource.v1.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CreateApiTokenResponseV1(
@SerialName("token") val token: String,
)

View File

@@ -1,20 +0,0 @@
package gq.kirmanak.mealient
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.Interceptor
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ReleaseModule {
// Release version of the application doesn't have any interceptors but this Set
// is required by Dagger, so an empty Set is provided here
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
@Provides
@Singleton
fun provideInterceptors(): Set<@JvmSuppressWildcards Interceptor> = emptySet()
}

View File

@@ -1,6 +1,7 @@
package gq.kirmanak.mealient.datasource package gq.kirmanak.mealient.datasource
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.datasource.impl.NetworkRequestWrapperImpl
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0Impl import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0Impl
import gq.kirmanak.mealient.datasource.v0.MealieServiceV0 import gq.kirmanak.mealient.datasource.v0.MealieServiceV0
import gq.kirmanak.mealient.datasource.v0.models.GetTokenResponseV0 import gq.kirmanak.mealient.datasource.v0.models.GetTokenResponseV0

View File

@@ -0,0 +1,111 @@
package gq.kirmanak.mealient.datasource.impl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.datasource.impl.AuthInterceptor.Companion.HEADER_NAME
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import io.mockk.slot
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.junit.Before
import org.junit.Test
class AuthInterceptorTest : BaseUnitTest() {
private lateinit var subject: Interceptor
@MockK(relaxed = true)
lateinit var authenticationProvider: AuthenticationProvider
@MockK(relaxed = true)
lateinit var chain: Interceptor.Chain
@Before
override fun setUp() {
super.setUp()
subject = AuthInterceptor(logger) { authenticationProvider }
}
@Test
fun `when intercept is called expect header to be retrieved`() {
subject.intercept(chain)
coVerify { authenticationProvider.getAuthHeader() }
}
@Test
fun `when intercept is called and no header expect no header`() {
coEvery { authenticationProvider.getAuthHeader() } returns null
coEvery { chain.request() } returns buildRequest()
val requestSlot = slot<Request>()
coEvery { chain.proceed(capture(requestSlot)) } returns buildResponse()
subject.intercept(chain)
assertThat(requestSlot.captured.header(HEADER_NAME)).isNull()
}
@Test
fun `when intercept is called and no header expect no logout`() {
coEvery { authenticationProvider.getAuthHeader() } returns null
coEvery { chain.request() } returns buildRequest()
coEvery { chain.proceed(any()) } returns buildResponse(code = 200)
subject.intercept(chain)
coVerify(inverse = true) { authenticationProvider.logout() }
}
@Test
fun `when intercept is called with no header and auth fails expect no logout`() {
coEvery { authenticationProvider.getAuthHeader() } returns null
coEvery { chain.request() } returns buildRequest()
coEvery { chain.proceed(any()) } returns buildResponse(code = 401)
subject.intercept(chain)
coVerify(inverse = true) { authenticationProvider.logout() }
}
@Test
fun `when intercept is called and there is a header expect a header`() {
coEvery { authenticationProvider.getAuthHeader() } returns "header"
coEvery { chain.request() } returns buildRequest()
val requestSlot = slot<Request>()
coEvery { chain.proceed(capture(requestSlot)) } returns buildResponse()
subject.intercept(chain)
assertThat(requestSlot.captured.header(HEADER_NAME)).isEqualTo("header")
}
@Test
fun `when intercept is called and there is a header that authenticates expect no logout`() {
coEvery { authenticationProvider.getAuthHeader() } returns "header"
coEvery { chain.request() } returns buildRequest()
coEvery { chain.proceed(any()) } returns buildResponse(code = 200)
subject.intercept(chain)
coVerify(inverse = true) { authenticationProvider.logout() }
}
@Test
fun `when intercept is called and there was a header but still 401 expect logout`() {
coEvery { authenticationProvider.getAuthHeader() } returns "header"
coEvery { chain.request() } returns buildRequest()
coEvery { chain.proceed(any()) } returns buildResponse(code = 401)
subject.intercept(chain)
coVerify { authenticationProvider.logout() }
}
private fun buildResponse(
url: String = "http://localhost",
code: Int = 200,
message: String = if (code == 200) "OK" else "Unauthorized",
protocol: Protocol = Protocol.HTTP_2,
) = Response.Builder().apply {
request(buildRequest(url))
code(code)
message(message)
protocol(protocol)
}.build()
private fun buildRequest(
url: String = "http://localhost",
) = Request.Builder().url(url).build()
}

View File

@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.test
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltTestApplication import dagger.hilt.android.testing.HiltTestApplication
import gq.kirmanak.mealient.logging.Logger
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -15,6 +16,8 @@ abstract class HiltRobolectricTest {
@get:Rule @get:Rule
var hiltRule = HiltAndroidRule(this) var hiltRule = HiltAndroidRule(this)
protected val logger: Logger = FakeLogger()
@Before @Before
fun inject() { fun inject() {
hiltRule.inject() hiltRule.inject()