Merge pull request #70 from kirmanak/network-module

Extract datasource to separate module
This commit is contained in:
Kirill Kamakin
2022-08-07 13:29:07 +02:00
committed by GitHub
73 changed files with 772 additions and 1006 deletions

24
.run/Run tests.run.xml Normal file
View File

@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration name="Run tests" default="false" factoryName="Gradle"
type="GradleRunConfiguration">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="testDebugUnitTest" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View File

@@ -8,7 +8,6 @@ plugins {
id("kotlin-kapt") id("kotlin-kapt")
id("androidx.navigation.safeargs.kotlin") id("androidx.navigation.safeargs.kotlin")
id("dagger.hilt.android.plugin") id("dagger.hilt.android.plugin")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.gms.google-services") id("com.google.gms.google-services")
id("com.google.firebase.crashlytics") id("com.google.firebase.crashlytics")
alias(libs.plugins.appsweep) alias(libs.plugins.appsweep)
@@ -19,8 +18,6 @@ android {
applicationId = "gq.kirmanak.mealient" applicationId = "gq.kirmanak.mealient"
versionCode = 13 versionCode = 13
versionName = "0.2.4" versionName = "0.2.4"
buildConfigField("Boolean", "LOG_NETWORK", "false")
} }
signingConfigs { signingConfigs {
@@ -68,6 +65,7 @@ dependencies {
implementation(project(":database")) implementation(project(":database"))
implementation(project(":datastore")) implementation(project(":datastore"))
implementation(project(":datasource"))
implementation(project(":logging")) implementation(project(":logging"))
implementation(libs.android.material.material) implementation(libs.android.material.material)
@@ -92,16 +90,6 @@ dependencies {
kaptTest(libs.google.dagger.hiltAndroidCompiler) kaptTest(libs.google.dagger.hiltAndroidCompiler)
testImplementation(libs.google.dagger.hiltAndroidTesting) testImplementation(libs.google.dagger.hiltAndroidTesting)
implementation(libs.squareup.retrofit)
implementation(libs.jakewharton.retrofitSerialization)
implementation(platform(libs.okhttp3.bom))
implementation(libs.okhttp3.okhttp)
debugImplementation(libs.okhttp3.loggingInterceptor)
implementation(libs.jetbrains.kotlinx.serialization)
implementation(libs.androidx.paging.runtimeKtx) implementation(libs.androidx.paging.runtimeKtx)
testImplementation(libs.androidx.paging.commonKtx) testImplementation(libs.androidx.paging.commonKtx)
@@ -137,6 +125,4 @@ dependencies {
testImplementation(libs.io.mockk) testImplementation(libs.io.mockk)
debugImplementation(libs.squareup.leakcanary) debugImplementation(libs.squareup.leakcanary)
debugImplementation(libs.chuckerteam.chucker)
} }

View File

@@ -1,8 +1,7 @@
package gq.kirmanak.mealient.data.add package gq.kirmanak.mealient.data.add
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
interface AddRecipeDataSource { interface AddRecipeDataSource {
suspend fun addRecipe(recipe: AddRecipeRequest): String suspend fun addRecipe(recipe: AddRecipeRequest): String
} }

View File

@@ -1,6 +1,6 @@
package gq.kirmanak.mealient.data.add package gq.kirmanak.mealient.data.add
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AddRecipeRepo { interface AddRecipeRepo {

View File

@@ -1,27 +0,0 @@
package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AddRecipeDataSourceImpl @Inject constructor(
private val addRecipeServiceFactory: ServiceFactory<AddRecipeService>,
private val logger: Logger,
) : AddRecipeDataSource {
override suspend fun addRecipe(recipe: AddRecipeRequest): String {
logger.v { "addRecipe() called with: recipe = $recipe" }
val service = addRecipeServiceFactory.provideService()
val response = logger.logAndMapErrors(
block = { service.addRecipe(recipe) },
logProvider = { "addRecipe: can't add recipe" }
)
logger.v { "addRecipe() response = $response" }
return response
}
}

View File

@@ -2,8 +2,10 @@ package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.AddRecipeDataSource import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.AddRecipeRepo import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage
import gq.kirmanak.mealient.extensions.toAddRecipeRequest
import gq.kirmanak.mealient.extensions.toDraft
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.first import kotlinx.coroutines.flow.first
@@ -19,7 +21,7 @@ class AddRecipeRepoImpl @Inject constructor(
) : AddRecipeRepo { ) : AddRecipeRepo {
override val addRecipeRequestFlow: Flow<AddRecipeRequest> override val addRecipeRequestFlow: Flow<AddRecipeRequest>
get() = addRecipeStorage.updates.map { AddRecipeRequest(it) } get() = addRecipeStorage.updates.map { it.toAddRecipeRequest() }
override suspend fun preserve(recipe: AddRecipeRequest) { override suspend fun preserve(recipe: AddRecipeRequest) {
logger.v { "preserveRecipe() called with: recipe = $recipe" } logger.v { "preserveRecipe() called with: recipe = $recipe" }

View File

@@ -1,12 +0,0 @@
package gq.kirmanak.mealient.data.add.impl
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import retrofit2.http.Body
import retrofit2.http.POST
interface AddRecipeService {
@POST("/api/recipes/create")
suspend fun addRecipe(@Body addRecipeRequest: AddRecipeRequest): String
}

View File

@@ -4,5 +4,5 @@ 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, baseUrl: String): String
} }

View File

@@ -1,56 +1,15 @@
package gq.kirmanak.mealient.data.auth.impl 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.network.ErrorDetail import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.data.network.NetworkError.NotMealie
import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.decodeErrorBody
import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class AuthDataSourceImpl @Inject constructor( class AuthDataSourceImpl @Inject constructor(
private val authServiceFactory: ServiceFactory<AuthService>, private val mealieDataSource: MealieDataSource,
private val json: Json,
private val logger: Logger,
) : AuthDataSource { ) : AuthDataSource {
override suspend fun authenticate(username: String, password: String): String { override suspend fun authenticate(username: String, password: String, baseUrl: String): String =
logger.v { "authenticate() called with: username = $username, password = $password" } mealieDataSource.authenticate(baseUrl, username, password)
val authService = authServiceFactory.provideService()
val response = sendRequest(authService, username, password)
val accessToken = parseToken(response)
logger.v { "authenticate() returned: $accessToken" }
return accessToken
}
private suspend fun sendRequest(
authService: AuthService,
username: String,
password: String
): Response<GetTokenResponse> = logger.logAndMapErrors(
block = { authService.getToken(username = username, password = password) },
logProvider = { "sendRequest: can't get token" },
)
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 = json.runCatching<Json, ErrorDetail> { decodeErrorBody(response) }
.onFailure { logger.e(it) { "Can't decode error body" } }
.getOrNull()
throw when (errorDetail?.detail) {
"Unauthorized" -> Unauthorized(cause)
else -> NotMealie(cause)
}
}
} }

View File

@@ -3,6 +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.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -14,6 +15,7 @@ import javax.inject.Singleton
class AuthRepoImpl @Inject constructor( class AuthRepoImpl @Inject constructor(
private val authStorage: AuthStorage, private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource, private val authDataSource: AuthDataSource,
private val baseURLStorage: BaseURLStorage,
private val logger: Logger, private val logger: Logger,
) : AuthRepo { ) : AuthRepo {
@@ -22,7 +24,7 @@ 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" }
authDataSource.authenticate(email, password) authDataSource.authenticate(email, password, baseURLStorage.requireBaseURL())
.let { AUTH_HEADER_FORMAT.format(it) } .let { AUTH_HEADER_FORMAT.format(it) }
.let { authStorage.setAuthHeader(it) } .let { authStorage.setAuthHeader(it) }
authStorage.setEmail(email) authStorage.setEmail(email)

View File

@@ -1,15 +0,0 @@
package gq.kirmanak.mealient.data.auth.impl
import retrofit2.Response
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface AuthService {
@FormUrlEncoded
@POST("/api/auth/token")
suspend fun getToken(
@Field("username") username: String,
@Field("password") password: String,
): Response<GetTokenResponse>
}

View File

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

View File

@@ -1,29 +0,0 @@
package gq.kirmanak.mealient.data.baseurl.impl
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.extensions.versionInfo
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VersionDataSourceImpl @Inject constructor(
private val serviceFactory: ServiceFactory<VersionService>,
private val logger: Logger,
) : VersionDataSource {
override suspend fun getVersionInfo(baseUrl: String): VersionInfo {
logger.v { "getVersionInfo() called with: baseUrl = $baseUrl" }
val service = serviceFactory.provideService(baseUrl)
val response = logger.logAndMapErrors(
block = { service.getVersion() },
logProvider = { "getVersionInfo: can't request version" }
)
return response.versionInfo()
}
}

View File

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

View File

@@ -1,43 +0,0 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.auth.AuthRepo
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthenticationInterceptor @Inject constructor(
private val authRepo: AuthRepo,
) : Interceptor {
private val authHeader: String?
get() = runBlocking { authRepo.getAuthHeader() }
override fun intercept(chain: Interceptor.Chain): Response {
val currentHeader = authHeader ?: return chain.proceed(chain.request())
val response = proceedWithAuthHeader(chain, currentHeader)
return if (listOf(401, 403).contains(response.code)) {
runBlocking { authRepo.invalidateAuthHeader() }
// Try again with new auth header (if any) or return previous response
authHeader?.let { proceedWithAuthHeader(chain, it) } ?: response
} else {
response
}
}
private fun proceedWithAuthHeader(
chain: Interceptor.Chain,
authHeader: String,
) = chain.proceed(
chain.request()
.newBuilder()
.header(HEADER_NAME, authHeader)
.build()
)
companion object {
private const val HEADER_NAME = "Authorization"
}
}

View File

@@ -0,0 +1,49 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.extensions.toVersionInfo
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MealieDataSourceWrapper @Inject constructor(
private val baseURLStorage: BaseURLStorage,
private val authRepo: AuthRepo,
private val mealieDataSource: MealieDataSource,
) : AddRecipeDataSource, RecipeDataSource, VersionDataSource {
override suspend fun addRecipe(recipe: AddRecipeRequest): String =
withAuthHeader { token -> addRecipe(getUrl(), token, recipe) }
override suspend fun getVersionInfo(baseUrl: String): VersionInfo =
mealieDataSource.getVersionInfo(baseUrl).toVersionInfo()
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> =
withAuthHeader { token -> requestRecipes(getUrl(), token, start, limit) }
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse =
withAuthHeader { token -> requestRecipeInfo(getUrl(), token, slug) }
private suspend fun getUrl() = baseURLStorage.requireBaseURL()
private suspend inline fun <T> withAuthHeader(block: MealieDataSource.(String?) -> T): T =
mealieDataSource.runCatching { block(authRepo.getAuthHeader()) }.getOrElse {
if (it is NetworkError.Unauthorized) {
authRepo.invalidateAuthHeader()
// Trying again with new authentication header
mealieDataSource.block(authRepo.getAuthHeader())
} else {
throw it
}
}
}

View File

@@ -1,28 +0,0 @@
package gq.kirmanak.mealient.data.network
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
class RetrofitBuilder(
private val okHttpClient: OkHttpClient,
private val json: Json,
private val logger: Logger,
) {
@OptIn(ExperimentalSerializationApi::class)
fun buildRetrofit(baseUrl: String): Retrofit {
logger.v { "buildRetrofit() called with: baseUrl = $baseUrl" }
val contentType = "application/json".toMediaType()
val converterFactory = json.asConverterFactory(contentType)
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(converterFactory)
.build()
}
}

View File

@@ -1,37 +0,0 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
inline fun <reified T> RetrofitBuilder.createServiceFactory(
baseURLStorage: BaseURLStorage,
logger: Logger
) =
RetrofitServiceFactory(T::class.java, this, baseURLStorage, logger)
class RetrofitServiceFactory<T>(
private val serviceClass: Class<T>,
private val retrofitBuilder: RetrofitBuilder,
private val baseURLStorage: BaseURLStorage,
private val logger: Logger,
) : ServiceFactory<T> {
private val cache: MutableMap<String, T> = mutableMapOf()
override suspend fun provideService(baseUrl: String?): T = runCatchingExceptCancel {
logger.v { "provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}" }
val url = baseUrl ?: baseURLStorage.requireBaseURL()
synchronized(cache) { cache[url] ?: createService(url, serviceClass) }
}.getOrElse {
logger.e(it) { "provideService: can't provide service for $baseUrl" }
throw NetworkError.MalformedUrl(it)
}
private fun createService(url: String, serviceClass: Class<T>): T {
logger.v { "createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}" }
val service = retrofitBuilder.buildRetrofit(url).create(serviceClass)
cache[url] = service
return service
}
}

View File

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

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.data.recipes.db package gq.kirmanak.mealient.data.recipes.db
import androidx.paging.PagingSource import androidx.paging.PagingSource
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeInfo import gq.kirmanak.mealient.database.recipe.entity.FullRecipeInfo
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
interface RecipeStorage { interface RecipeStorage {
suspend fun saveRecipes(recipes: List<GetRecipeSummaryResponse>) suspend fun saveRecipes(recipes: List<GetRecipeSummaryResponse>)

View File

@@ -2,11 +2,11 @@ package gq.kirmanak.mealient.data.recipes.db
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room.withTransaction import androidx.room.withTransaction
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import gq.kirmanak.mealient.database.AppDb import gq.kirmanak.mealient.database.AppDb
import gq.kirmanak.mealient.database.recipe.RecipeDao import gq.kirmanak.mealient.database.recipe.RecipeDao
import gq.kirmanak.mealient.database.recipe.entity.* import gq.kirmanak.mealient.database.recipe.entity.*
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.extensions.recipeEntity import gq.kirmanak.mealient.extensions.recipeEntity
import gq.kirmanak.mealient.extensions.toRecipeEntity import gq.kirmanak.mealient.extensions.toRecipeEntity
import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.data.recipes.network package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
interface RecipeDataSource { interface RecipeDataSource {
suspend fun requestRecipes(start: Int = 0, limit: Int = 9999): List<GetRecipeSummaryResponse> suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse>
suspend fun requestRecipeInfo(slug: String): GetRecipeResponse suspend fun requestRecipeInfo(slug: String): GetRecipeResponse
} }

View File

@@ -1,34 +0,0 @@
package gq.kirmanak.mealient.data.recipes.network
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 gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeDataSourceImpl @Inject constructor(
private val recipeServiceFactory: ServiceFactory<RecipeService>,
private val logger: Logger,
) : RecipeDataSource {
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
logger.v { "requestRecipes() called with: start = $start, limit = $limit" }
val recipeSummary = getRecipeService().getRecipeSummary(start, limit)
logger.v { "requestRecipes() returned: $recipeSummary" }
return recipeSummary
}
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
logger.v { "requestRecipeInfo() called with: slug = $slug" }
val recipeInfo = getRecipeService().getRecipe(slug)
logger.v { "requestRecipeInfo() returned: $recipeInfo" }
return recipeInfo
}
private suspend fun getRecipeService(): RecipeService {
logger.v { "getRecipeService() called" }
return recipeServiceFactory.provideService()
}
}

View File

@@ -1,20 +0,0 @@
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.Path
import retrofit2.http.Query
interface RecipeService {
@GET("/api/recipes/summary")
suspend fun getRecipeSummary(
@Query("start") start: Int,
@Query("limit") limit: Int,
): List<GetRecipeSummaryResponse>
@GET("/api/recipes/{recipe_slug}")
suspend fun getRecipe(
@Path("recipe_slug") recipeSlug: String,
): GetRecipeResponse
}

View File

@@ -2,46 +2,20 @@ package gq.kirmanak.mealient.di
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.add.AddRecipeDataSource import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.add.AddRecipeRepo import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.impl.AddRecipeDataSourceImpl
import gq.kirmanak.mealient.data.add.impl.AddRecipeRepoImpl import gq.kirmanak.mealient.data.add.impl.AddRecipeRepoImpl
import gq.kirmanak.mealient.data.add.impl.AddRecipeService import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage
import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorageImpl import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorageImpl
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface AddRecipeModule { interface AddRecipeModule {
companion object {
@Provides
@Singleton
fun provideAddRecipeServiceFactory(
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<AddRecipeService> {
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
}
@Binds @Binds
@Singleton @Singleton
@@ -49,7 +23,7 @@ interface AddRecipeModule {
@Binds @Binds
@Singleton @Singleton
fun bindAddRecipeDataSource(addRecipeDataSourceImpl: AddRecipeDataSourceImpl): AddRecipeDataSource fun bindAddRecipeDataSource(mealieDataSourceWrapper: MealieDataSourceWrapper): AddRecipeDataSource
@Binds @Binds
@Singleton @Singleton

View File

@@ -13,16 +13,7 @@ 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.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.AuthService
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -31,20 +22,6 @@ interface AuthModule {
companion object { companion object {
@Provides
@Singleton
fun provideAuthServiceFactory(
@Named(NO_AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<AuthService> {
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
@Provides @Provides
@Singleton @Singleton
fun provideAccountManager(@ApplicationContext context: Context): AccountManager { fun provideAccountManager(@ApplicationContext context: Context): AccountManager {

View File

@@ -2,47 +2,21 @@ package gq.kirmanak.mealient.di
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.impl.BaseURLStorageImpl import gq.kirmanak.mealient.data.baseurl.impl.BaseURLStorageImpl
import gq.kirmanak.mealient.data.baseurl.impl.VersionDataSourceImpl import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.data.baseurl.impl.VersionService
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface BaseURLModule { interface BaseURLModule {
companion object {
@Provides
@Singleton
fun provideVersionServiceFactory(
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<VersionService> {
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
}
@Binds @Binds
@Singleton @Singleton
fun bindVersionDataSource(versionDataSourceImpl: VersionDataSourceImpl): VersionDataSource fun bindVersionDataSource(mealieDataSourceWrapper: MealieDataSourceWrapper): VersionDataSource
@Binds @Binds
@Singleton @Singleton

View File

@@ -8,7 +8,6 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.io.InputStream import java.io.InputStream
import javax.inject.Named
@EntryPoint @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -16,7 +15,6 @@ interface GlideModuleEntryPoint {
fun provideLogger(): Logger fun provideLogger(): Logger
@Named(AUTH_OK_HTTP)
fun provideOkHttp(): OkHttpClient fun provideOkHttp(): OkHttpClient
fun provideRecipeLoaderFactory(): ModelLoaderFactory<RecipeSummaryEntity, InputStream> fun provideRecipeLoaderFactory(): ModelLoaderFactory<RecipeSummaryEntity, InputStream>

View File

@@ -1,50 +0,0 @@
package gq.kirmanak.mealient.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.network.AuthenticationInterceptor
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
const val AUTH_OK_HTTP = "auth"
const val NO_AUTH_OK_HTTP = "noauth"
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
@Named(AUTH_OK_HTTP)
fun createAuthOkHttp(
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
interceptors: Set<@JvmSuppressWildcards Interceptor>,
authenticationInterceptor: AuthenticationInterceptor,
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(authenticationInterceptor)
.apply { for (interceptor in interceptors) addNetworkInterceptor(interceptor) }
.build()
@Provides
@Singleton
@Named(NO_AUTH_OK_HTTP)
fun createNoAuthOkHttp(
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
interceptors: Set<@JvmSuppressWildcards Interceptor>,
): OkHttpClient = OkHttpClient.Builder()
.apply { for (interceptor in interceptors) addNetworkInterceptor(interceptor) }
.build()
@Provides
@Singleton
fun createJson(): Json = Json {
coerceInputValues = true
ignoreUnknownKeys = true
encodeDefaults = true
}
}

View File

@@ -9,10 +9,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage import gq.kirmanak.mealient.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl
@@ -20,15 +17,9 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProviderImpl import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProviderImpl
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeService
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.io.InputStream import java.io.InputStream
import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -37,7 +28,7 @@ interface RecipeModule {
@Binds @Binds
@Singleton @Singleton
fun provideRecipeDataSource(recipeDataSourceImpl: RecipeDataSourceImpl): RecipeDataSource fun provideRecipeDataSource(mealieDataSourceWrapper: MealieDataSourceWrapper): RecipeDataSource
@Binds @Binds
@Singleton @Singleton
@@ -57,20 +48,6 @@ interface RecipeModule {
companion object { companion object {
@Provides
@Singleton
fun provideRecipeServiceFactory(
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<RecipeService> {
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
@Provides @Provides
@Singleton @Singleton
fun provideRecipePagingSourceFactory( fun provideRecipePagingSourceFactory(

View File

@@ -1,30 +0,0 @@
package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalSerializationApi::class)
inline fun <T, reified R> Json.decodeErrorBody(response: Response<T>): R =
checkNotNull(response.errorBody()) { "Can't decode absent error body" }
.byteStream()
.let(::decodeFromStream)
fun Throwable.mapToNetworkError(): NetworkError = when (this) {
is HttpException, is SerializationException -> NetworkError.NotMealie(this)
else -> NetworkError.NoServerConnection(this)
}
inline fun <T> Logger.logAndMapErrors(
block: () -> T,
noinline logProvider: () -> String
): T = runCatchingExceptCancel(block).getOrElse {
e(it, messageSupplier = logProvider)
throw it.mapToNetworkError()
}

View File

@@ -1,15 +1,12 @@
package gq.kirmanak.mealient.extensions package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.baseurl.VersionInfo import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.baseurl.impl.VersionResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeIngredientResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeInstructionResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.models.*
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
fun GetRecipeResponse.toRecipeEntity() = RecipeEntity( fun GetRecipeResponse.toRecipeEntity() = RecipeEntity(
remoteId = remoteId, remoteId = remoteId,
@@ -45,4 +42,26 @@ fun GetRecipeSummaryResponse.recipeEntity() = RecipeSummaryEntity(
dateUpdated = dateUpdated, dateUpdated = dateUpdated,
) )
fun VersionResponse.versionInfo() = VersionInfo(production, version, demoStatus) fun VersionResponse.toVersionInfo() = VersionInfo(production, version, demoStatus)
fun AddRecipeDraft.toAddRecipeRequest() = AddRecipeRequest(
name = recipeName,
description = recipeDescription,
recipeYield = recipeYield,
recipeIngredient = recipeIngredients.map { AddRecipeIngredient(note = it) },
recipeInstructions = recipeInstructions.map { AddRecipeInstruction(text = it) },
settings = AddRecipeSettings(
public = isRecipePublic,
disableComments = areCommentsDisabled,
)
)
fun AddRecipeRequest.toDraft(): AddRecipeDraft = AddRecipeDraft(
recipeName = name,
recipeDescription = description,
recipeYield = recipeYield,
recipeInstructions = recipeInstructions.map { it.text },
recipeIngredients = recipeIngredient.map { it.note },
isRecipePublic = settings.public,
areCommentsDisabled = settings.disableComments,
)

View File

@@ -12,12 +12,12 @@ import androidx.fragment.app.viewModels
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.add.models.AddRecipeIngredient
import gq.kirmanak.mealient.data.add.models.AddRecipeInstruction
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.data.add.models.AddRecipeSettings
import gq.kirmanak.mealient.databinding.FragmentAddRecipeBinding import gq.kirmanak.mealient.databinding.FragmentAddRecipeBinding
import gq.kirmanak.mealient.databinding.ViewSingleInputBinding import gq.kirmanak.mealient.databinding.ViewSingleInputBinding
import gq.kirmanak.mealient.datasource.models.AddRecipeIngredient
import gq.kirmanak.mealient.datasource.models.AddRecipeInstruction
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.AddRecipeSettings
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.collectWhenViewResumed import gq.kirmanak.mealient.extensions.collectWhenViewResumed
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger

View File

@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.add.AddRecipeRepo import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel

View File

@@ -9,8 +9,8 @@ import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState import gq.kirmanak.mealient.ui.OperationUiState

View File

@@ -9,8 +9,8 @@ import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState import gq.kirmanak.mealient.ui.OperationUiState

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.GlideUrl import com.bumptech.glide.load.model.*
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,6 +16,7 @@ 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) {
@@ -24,12 +25,13 @@ 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, concreteLoader, cache) ) = RecipeModelLoader(recipeImageUrlProvider, logger, authRepo, concreteLoader, cache)
} }
@@ -44,4 +46,20 @@ 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?.slug) } return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.slug) }
} }
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

@@ -1,50 +0,0 @@
package gq.kirmanak.mealient.data.add.impl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.logging.Logger
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AddRecipeDataSourceImplTest {
@MockK
lateinit var serviceProvider: ServiceFactory<AddRecipeService>
@MockK
lateinit var service: AddRecipeService
@MockK(relaxUnitFun = true)
lateinit var logger: Logger
lateinit var subject: AddRecipeDataSourceImpl
@Before
fun setUp() {
MockKAnnotations.init(this)
coEvery { serviceProvider.provideService(any()) } returns service
subject = AddRecipeDataSourceImpl(serviceProvider, logger)
}
@Test(expected = NetworkError.NotMealie::class)
fun `when addRecipe fails then maps error`() = runTest {
coEvery { service.addRecipe(any()) } throws SerializationException()
subject.addRecipe(AddRecipeRequest())
}
@Test
fun `when addRecipe succeeds then returns response`() = runTest {
coEvery { service.addRecipe(any()) } returns "response"
assertThat(subject.addRecipe(AddRecipeRequest())).isEqualTo("response")
}
}

View File

@@ -1,91 +0,0 @@
package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.network.NetworkError.*
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.di.NetworkModule
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.toJsonResponseBody
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import retrofit2.Response
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class AuthDataSourceImplTest {
@MockK
lateinit var authService: AuthService
@MockK
lateinit var authServiceFactory: ServiceFactory<AuthService>
@MockK(relaxUnitFun = true)
lateinit var logger: Logger
lateinit var subject: AuthDataSourceImpl
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson(), logger)
coEvery { authServiceFactory.provideService(any()) } returns authService
}
@Test
fun `when authentication is successful then token is correct`() = runTest {
val token = authenticate(Response.success(GetTokenResponse(TEST_TOKEN)))
assertThat(token).isEqualTo(TEST_TOKEN)
}
@Test(expected = Unauthorized::class)
fun `when authenticate receives 401 and Unauthorized then throws Unauthorized`() = runTest {
val body = "{\"detail\":\"Unauthorized\"}".toJsonResponseBody()
authenticate(Response.error(401, body))
}
@Test(expected = NotMealie::class)
fun `when authenticate receives 401 but not Unauthorized then throws NotMealie`() = runTest {
val body = "{\"detail\":\"Something\"}".toJsonResponseBody()
authenticate(Response.error(401, body))
}
@Test(expected = NotMealie::class)
fun `when authenticate receives 404 and empty body then throws NotMealie`() = runTest {
authenticate(Response.error(401, "".toJsonResponseBody()))
}
@Test(expected = NotMealie::class)
fun `when authenticate receives 200 and null then throws NotMealie`() = runTest {
authenticate(Response.success<GetTokenResponse>(200, null))
}
@Test(expected = NoServerConnection::class)
fun `when authenticate and getToken throws then throws NoServerConnection`() = runTest {
coEvery { authService.getToken(any(), any()) } throws IOException("Server not found")
callAuthenticate()
}
@Test(expected = MalformedUrl::class)
fun `when authenticate and provideService throws then MalformedUrl`() = runTest {
coEvery {
authServiceFactory.provideService(any())
} throws MalformedUrl(RuntimeException())
callAuthenticate()
}
private suspend fun authenticate(response: Response<GetTokenResponse>): String {
coEvery { authService.getToken(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns response
return callAuthenticate()
}
private suspend fun callAuthenticate() = subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
}

View File

@@ -4,8 +4,10 @@ import com.google.common.truth.Truth.assertThat
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.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.logging.Logger 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_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
@@ -24,6 +26,9 @@ class AuthRepoImplTest {
@MockK @MockK
lateinit var dataSource: AuthDataSource lateinit var dataSource: AuthDataSource
@MockK
lateinit var baseURLStorage: BaseURLStorage
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var storage: AuthStorage lateinit var storage: AuthStorage
@@ -35,7 +40,7 @@ class AuthRepoImplTest {
@Before @Before
fun setUp() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
subject = AuthRepoImpl(storage, dataSource, logger) subject = AuthRepoImpl(storage, dataSource, baseURLStorage, logger)
} }
@Test @Test
@@ -46,7 +51,14 @@ class AuthRepoImplTest {
@Test @Test
fun `when authenticate successfully then saves to storage`() = runTest { fun `when authenticate successfully then saves to storage`() = runTest {
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN coEvery {
dataSource.authenticate(
eq(TEST_USERNAME),
eq(TEST_PASSWORD),
eq(TEST_BASE_URL)
)
} returns TEST_TOKEN
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
subject.authenticate(TEST_USERNAME, TEST_PASSWORD) subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
coVerifyAll { coVerifyAll {
storage.setAuthHeader(TEST_AUTH_HEADER) storage.setAuthHeader(TEST_AUTH_HEADER)
@@ -58,7 +70,8 @@ class AuthRepoImplTest {
@Test @Test
fun `when authenticate fails then does not change storage`() = runTest { fun `when authenticate fails then does not change storage`() = runTest {
coEvery { dataSource.authenticate(any(), any()) } throws RuntimeException() coEvery { dataSource.authenticate(any(), any(), any()) } throws RuntimeException()
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
runCatching { subject.authenticate("invalid", "") } runCatching { subject.authenticate("invalid", "") }
confirmVerified(storage) confirmVerified(storage)
} }
@@ -94,10 +107,13 @@ class AuthRepoImplTest {
fun `when invalidate with credentials then calls authenticate`() = runTest { fun `when invalidate with credentials then calls authenticate`() = runTest {
coEvery { storage.getEmail() } returns TEST_USERNAME coEvery { storage.getEmail() } returns TEST_USERNAME
coEvery { storage.getPassword() } returns TEST_PASSWORD coEvery { storage.getPassword() } returns TEST_PASSWORD
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
coEvery {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL))
} returns TEST_TOKEN
subject.invalidateAuthHeader() subject.invalidateAuthHeader()
coVerifyAll { coVerifyAll {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL))
} }
} }
@@ -105,7 +121,8 @@ class AuthRepoImplTest {
fun `when invalidate with credentials and auth fails then clears email`() = runTest { fun `when invalidate with credentials and auth fails then clears email`() = runTest {
coEvery { storage.getEmail() } returns "invalid" coEvery { storage.getEmail() } returns "invalid"
coEvery { storage.getPassword() } returns "" coEvery { storage.getPassword() } returns ""
coEvery { dataSource.authenticate(any(), any()) } throws RuntimeException() coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.authenticate(any(), any(), any()) } throws RuntimeException()
subject.invalidateAuthHeader() subject.invalidateAuthHeader()
coVerify { storage.setEmail(null) } coVerify { storage.setEmail(null) }
} }

View File

@@ -1,80 +0,0 @@
package gq.kirmanak.mealient.data.baseurl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.impl.VersionDataSourceImpl
import gq.kirmanak.mealient.data.baseurl.impl.VersionResponse
import gq.kirmanak.mealient.data.baseurl.impl.VersionService
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.toJsonResponseBody
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import okio.IOException
import org.junit.Before
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class VersionDataSourceImplTest {
@MockK
lateinit var versionService: VersionService
@MockK
lateinit var versionServiceFactory: ServiceFactory<VersionService>
@MockK(relaxUnitFun = true)
lateinit var logger: Logger
lateinit var subject: VersionDataSource
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = VersionDataSourceImpl(versionServiceFactory, logger)
coEvery { versionServiceFactory.provideService(eq(TEST_BASE_URL)) } returns versionService
}
@Test(expected = NetworkError.MalformedUrl::class)
fun `when getVersionInfo and provideService throws then MalformedUrl`() = runTest {
coEvery {
versionServiceFactory.provideService(eq(TEST_BASE_URL))
} throws NetworkError.MalformedUrl(RuntimeException())
subject.getVersionInfo(TEST_BASE_URL)
}
@Test(expected = NetworkError.NotMealie::class)
fun `when getVersionInfo and getVersion throws HttpException then NotMealie`() = runTest {
val error = HttpException(Response.error<VersionResponse>(404, "".toJsonResponseBody()))
coEvery { versionService.getVersion() } throws error
subject.getVersionInfo(TEST_BASE_URL)
}
@Test(expected = NetworkError.NotMealie::class)
fun `when getVersionInfo and getVersion throws SerializationException then NotMealie`() =
runTest {
coEvery { versionService.getVersion() } throws SerializationException()
subject.getVersionInfo(TEST_BASE_URL)
}
@Test(expected = NetworkError.NoServerConnection::class)
fun `when getVersionInfo and getVersion throws IOException then NoServerConnection`() =
runTest {
coEvery { versionService.getVersion() } throws IOException()
subject.getVersionInfo(TEST_BASE_URL)
}
@Test
fun `when getVersionInfo and getVersion returns result then result`() = runTest {
coEvery { versionService.getVersion() } returns VersionResponse(true, "v0.5.6", true)
assertThat(subject.getVersionInfo(TEST_BASE_URL)).isEqualTo(
VersionInfo(true, "v0.5.6", true)
)
}
}

View File

@@ -1,121 +0,0 @@
package gq.kirmanak.mealient.data.network
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import io.mockk.*
import io.mockk.impl.annotations.MockK
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.junit.Before
import org.junit.Test
class AuthenticationInterceptorTest {
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK
lateinit var chain: Interceptor.Chain
lateinit var subject: AuthenticationInterceptor
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = AuthenticationInterceptor(authRepo)
}
@Test
fun `when intercept without header then response without header`() {
val request = createRequest()
val response = createResponse(request)
every { chain.request() } returns request
every { chain.proceed(any()) } returns response
coEvery { authRepo.getAuthHeader() } returns null
assertThat(subject.intercept(chain)).isEqualTo(response)
}
@Test
fun `when intercept with header then chain called with header`() {
val request = createRequest()
val response = createResponse(request)
val requestSlot = slot<Request>()
every { chain.request() } returns request
every { chain.proceed(capture(requestSlot)) } returns response
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
subject.intercept(chain)
assertThat(requestSlot.captured.header("Authorization")).isEqualTo(TEST_AUTH_HEADER)
}
@Test
fun `when intercept with stale header then calls invalidate`() {
val request = createRequest()
val response = createResponse(request, code = 403)
every { chain.request() } returns request
every { chain.proceed(any()) } returns response
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
subject.intercept(chain)
coVerifySequence {
authRepo.getAuthHeader()
authRepo.invalidateAuthHeader()
authRepo.getAuthHeader()
}
}
@Test
fun `when intercept with proper header then requests auth header once`() {
val request = createRequest()
val response = createResponse(request)
every { chain.request() } returns request
every { chain.proceed(any()) } returns response
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
subject.intercept(chain)
coVerifySequence { authRepo.getAuthHeader() }
}
@Test
fun `when intercept with stale header then updates header`() {
val request = createRequest()
val response = createResponse(request, code = 403)
val requests = mutableListOf<Request>()
every { chain.request() } returns request
every { chain.proceed(capture(requests)) } returns response
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER andThen "Bearer NEW TOKEN"
subject.intercept(chain)
assertThat(requests.size).isEqualTo(2)
assertThat(requests[0].header("Authorization")).isEqualTo(TEST_AUTH_HEADER)
assertThat(requests[1].header("Authorization")).isEqualTo("Bearer NEW TOKEN")
}
private fun createRequest(
url: String = TEST_BASE_URL,
): Request = Request.Builder()
.url(url)
.build()
private fun createResponse(
request: Request,
code: Int = 200,
): Response = Response.Builder()
.protocol(Protocol.HTTP_2)
.code(code)
.request(request)
.message("Doesn't matter")
.build()
}

View File

@@ -0,0 +1,58 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.RecipeImplTestData.GET_CAKE_RESPONSE
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerifyAll
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
@OptIn(ExperimentalCoroutinesApi::class)
class MealieDataSourceWrapperTest {
@MockK
lateinit var baseURLStorage: BaseURLStorage
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK
lateinit var mealieDataSource: MealieDataSource
lateinit var subject: MealieDataSourceWrapper
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = MealieDataSourceWrapper(baseURLStorage, authRepo, mealieDataSource)
}
@Test
fun `when withAuthHeader fails with Unauthorized then invalidates auth`() = runTest {
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
coEvery { authRepo.getAuthHeader() } returns null andThen TEST_AUTH_HEADER
coEvery {
mealieDataSource.requestRecipeInfo(eq(TEST_BASE_URL), isNull(), eq("cake"))
} throws NetworkError.Unauthorized(IOException())
coEvery {
mealieDataSource.requestRecipeInfo(eq(TEST_BASE_URL), eq(TEST_AUTH_HEADER), eq("cake"))
} returns GET_CAKE_RESPONSE
subject.requestRecipeInfo("cake")
coVerifyAll {
authRepo.getAuthHeader()
authRepo.invalidateAuthHeader()
authRepo.getAuthHeader()
}
}
}

View File

@@ -1,72 +0,0 @@
package gq.kirmanak.mealient.data.network
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.impl.VersionService
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import io.mockk.*
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
@OptIn(ExperimentalCoroutinesApi::class)
class RetrofitServiceFactoryTest {
@MockK
lateinit var retrofitBuilder: RetrofitBuilder
@MockK
lateinit var baseURLStorage: BaseURLStorage
@MockK
lateinit var retrofit: Retrofit
@MockK
lateinit var versionService: VersionService
@MockK(relaxUnitFun = true)
lateinit var logger: Logger
lateinit var subject: ServiceFactory<VersionService>
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = retrofitBuilder.createServiceFactory(baseURLStorage, logger)
coEvery { retrofitBuilder.buildRetrofit(any()) } returns retrofit
every { retrofit.create(eq(VersionService::class.java)) } returns versionService
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
}
@Test
fun `when provideService and url is null then url storage requested`() = runTest {
subject.provideService()
coVerify { baseURLStorage.requireBaseURL() }
}
@Test
fun `when provideService and url is null then service still provided`() = runTest {
assertThat(subject.provideService()).isEqualTo(versionService)
}
@Test
fun `when provideService called twice then builder called once`() = runTest {
subject.provideService()
subject.provideService()
coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL)) }
}
@Test
fun `when provideService called secondly with new url then builder called twice`() = runTest {
subject.provideService()
subject.provideService("new url")
coVerifyAll {
retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL))
retrofitBuilder.buildRetrofit(eq("new url"))
}
}
}

View File

@@ -3,10 +3,10 @@ package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.* import androidx.paging.*
import androidx.paging.LoadType.* import androidx.paging.LoadType.*
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.models.NetworkError.Unauthorized
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations

View File

@@ -1,13 +1,17 @@
package gq.kirmanak.mealient.data.add.models package gq.kirmanak.mealient.extensions
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.datasource.models.AddRecipeIngredient
import gq.kirmanak.mealient.datasource.models.AddRecipeInstruction
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.AddRecipeSettings
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
import org.junit.Test import org.junit.Test
class AddRecipeRequestTest { class RemoteToLocalMappingsTest {
@Test @Test
fun `when construct from input then fills fields correctly`() { fun `when toAddRecipeRequest then fills fields correctly`() {
val input = AddRecipeDraft( val input = AddRecipeDraft(
recipeName = "Recipe name", recipeName = "Recipe name",
recipeDescription = "Recipe description", recipeDescription = "Recipe description",
@@ -36,11 +40,11 @@ class AddRecipeRequestTest {
) )
) )
assertThat(AddRecipeRequest(input)).isEqualTo(expected) assertThat(input.toAddRecipeRequest()).isEqualTo(expected)
} }
@Test @Test
fun `when toInput then fills fields correctly`() { fun `when toDraft then fills fields correctly`() {
val request = AddRecipeRequest( val request = AddRecipeRequest(
name = "Recipe name", name = "Recipe name",
description = "Recipe description", description = "Recipe description",

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.test package gq.kirmanak.mealient.test
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeIngredientResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeInstructionResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import gq.kirmanak.mealient.database.recipe.entity.* import gq.kirmanak.mealient.database.recipe.entity.*
import gq.kirmanak.mealient.datasource.models.GetRecipeIngredientResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeInstructionResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime

View File

@@ -2,7 +2,7 @@ package gq.kirmanak.mealient.ui.add
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.add.AddRecipeRepo import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery import io.mockk.coEvery

1
datasource/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,45 @@
plugins {
id("gq.kirmanak.mealient.library")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
defaultConfig {
buildConfigField("Boolean", "LOG_NETWORK", "false")
}
namespace = "gq.kirmanak.mealient.datasource"
}
dependencies {
implementation(project(":logging"))
implementation(libs.google.dagger.hiltAndroid)
kapt(libs.google.dagger.hiltCompiler)
kaptTest(libs.google.dagger.hiltAndroidCompiler)
testImplementation(libs.google.dagger.hiltAndroidTesting)
implementation(libs.jetbrains.kotlinx.datetime)
implementation(libs.jetbrains.kotlinx.serialization)
implementation(libs.squareup.retrofit)
implementation(libs.jakewharton.retrofitSerialization)
implementation(platform(libs.okhttp3.bom))
implementation(libs.okhttp3.okhttp)
debugImplementation(libs.okhttp3.loggingInterceptor)
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
testImplementation(libs.androidx.test.junit)
testImplementation(libs.google.truth)
testImplementation(libs.io.mockk)
debugImplementation(libs.chuckerteam.chucker)
}

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.di package gq.kirmanak.mealient
import android.content.Context import android.content.Context
import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.api.ChuckerCollector
@@ -10,7 +10,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.BuildConfig import gq.kirmanak.mealient.datasource.BuildConfig
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.datasource
import okhttp3.Cache
interface CacheBuilder {
fun buildCache(): Cache
}

View File

@@ -0,0 +1,41 @@
package gq.kirmanak.mealient.datasource
import android.content.Context
import android.os.StatFs
import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.logging.Logger
import okhttp3.Cache
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CacheBuilderImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val logger: Logger,
) : CacheBuilder {
override fun buildCache(): Cache {
val dir = findCacheDir()
return Cache(dir, calculateDiskCacheSize(dir))
}
private fun findCacheDir(): File = File(context.cacheDir, "okhttp")
private fun calculateDiskCacheSize(dir: File): Long = dir.runCatching {
StatFs(absolutePath).let {
it.blockCountLong * it.blockSizeLong * AVAILABLE_SPACE_PERCENT / 100
}
}
.onFailure { logger.e(it) { "Can't get available space" } }
.getOrNull()
?.coerceAtLeast(MIN_OKHTTP_CACHE_SIZE)
?.coerceAtMost(MAX_OKHTTP_CACHE_SIZE)
?: MIN_OKHTTP_CACHE_SIZE
companion object {
private const val MIN_OKHTTP_CACHE_SIZE = 5 * 1024 * 1024L // 5MB
private const val MAX_OKHTTP_CACHE_SIZE = 50 * 1024 * 1024L // 50MB
private const val AVAILABLE_SPACE_PERCENT = 2
}
}

View File

@@ -0,0 +1,68 @@
package gq.kirmanak.mealient.datasource
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface DataSourceModule {
companion object {
const val AUTHORIZATION_HEADER_NAME = "Authorization"
@Provides
@Singleton
fun provideJson(): Json = Json {
coerceInputValues = true
ignoreUnknownKeys = true
encodeDefaults = true
}
@OptIn(ExperimentalSerializationApi::class)
@Provides
@Singleton
fun provideConverterFactory(json: Json): Converter.Factory =
json.asConverterFactory("application/json".toMediaType())
@Provides
@Singleton
fun provideOkHttp(okHttpBuilder: OkHttpBuilder): OkHttpClient =
okHttpBuilder.buildOkHttp()
@Provides
@Singleton
fun provideRetrofit(retrofitBuilder: RetrofitBuilder): Retrofit =
retrofitBuilder.buildRetrofit("https://beta.mealie.io/")
@Provides
@Singleton
fun provideMealieService(retrofit: Retrofit): MealieService =
retrofit.create()
}
@Binds
@Singleton
fun bindCacheBuilder(cacheBuilderImpl: CacheBuilderImpl): CacheBuilder
@Binds
@Singleton
fun bindOkHttpBuilder(okHttpBuilderImpl: OkHttpBuilderImpl): OkHttpBuilder
@Binds
@Singleton
fun bindMealieDataSource(mealientDataSourceImpl: MealieDataSourceImpl): MealieDataSource
}

View File

@@ -0,0 +1,41 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.datasource.models.VersionResponse
interface MealieDataSource {
suspend fun addRecipe(
baseUrl: String,
token: String?,
recipe: AddRecipeRequest,
): String
/**
* Tries to acquire authentication token using the provided credentials
*/
suspend fun authenticate(
baseUrl: String,
username: String,
password: String,
): String
suspend fun getVersionInfo(
baseUrl: String,
): VersionResponse
suspend fun requestRecipes(
baseUrl: String,
token: String?,
start: Int,
limit: Int,
): List<GetRecipeSummaryResponse>
suspend fun requestRecipeInfo(
baseUrl: String,
token: String?,
slug: String,
): GetRecipeResponse
}

View File

@@ -0,0 +1,92 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.datasource.models.*
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.ResponseBody
import retrofit2.HttpException
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MealieDataSourceImpl @Inject constructor(
private val logger: Logger,
private val mealieService: MealieService,
private val json: Json,
) : MealieDataSource {
override suspend fun addRecipe(
baseUrl: String, token: String?, recipe: AddRecipeRequest
): String = makeCall(
block = { addRecipe("$baseUrl/api/recipes/create", token, recipe) },
logMethod = { "addRecipe" },
logParameters = { "baseUrl = $baseUrl, token = $token, recipe = $recipe" }
).getOrThrowUnauthorized()
override suspend fun authenticate(
baseUrl: String, username: String, password: String
): String = makeCall(
block = { getToken("$baseUrl/api/auth/token", username, password) },
logMethod = { "authenticate" },
logParameters = { "baseUrl = $baseUrl, username = $username, password = $password" }
).map { it.accessToken }.getOrElse {
val errorBody = (it as? HttpException)?.response()?.errorBody() ?: throw it
val errorDetail = errorBody.decode<ErrorDetail>()
throw if (errorDetail.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
}
override suspend fun getVersionInfo(baseUrl: String): VersionResponse = makeCall(
block = { getVersion("$baseUrl/api/debug/version") },
logMethod = { "getVersionInfo" },
logParameters = { "baseUrl = $baseUrl" },
).getOrElse {
throw when (it) {
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
is SocketTimeoutException, is ConnectException -> NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it)
}
}
override suspend fun requestRecipes(
baseUrl: String, token: String?, start: Int, limit: Int
): List<GetRecipeSummaryResponse> = makeCall(
block = { getRecipeSummary("$baseUrl/api/recipes/summary", token, start, limit) },
logMethod = { "requestRecipes" },
logParameters = { "baseUrl = $baseUrl, token = $token, start = $start, limit = $limit" }
).getOrThrowUnauthorized()
override suspend fun requestRecipeInfo(
baseUrl: String, token: String?, slug: String
): GetRecipeResponse = makeCall(
block = { getRecipe("$baseUrl/api/recipes/$slug", token) },
logMethod = { "requestRecipeInfo" },
logParameters = { "baseUrl = $baseUrl, token = $token, slug = $slug" }
).getOrThrowUnauthorized()
private suspend inline fun <T> makeCall(
crossinline block: suspend MealieService.() -> T,
crossinline logMethod: () -> String,
crossinline logParameters: () -> String,
): Result<T> {
logger.v { "${logMethod()} called with: ${logParameters()}" }
return mealieService.runCatching { block() }
.onFailure { logger.e(it) { "${logMethod()} request failed with: ${logParameters()}" } }
.onSuccess { logger.d { "${logMethod()} request succeeded with ${logParameters()}" } }
}
@OptIn(ExperimentalSerializationApi::class)
private inline fun <reified R> ResponseBody.decode(): R = json.decodeFromStream(byteStream())
}
private fun <T> Result<T>.getOrThrowUnauthorized(): T = getOrElse {
throw if (it is HttpException && it.code() in listOf(401, 403)) {
NetworkError.Unauthorized(it)
} else {
it
}
}

View File

@@ -0,0 +1,42 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.datasource.DataSourceModule.Companion.AUTHORIZATION_HEADER_NAME
import gq.kirmanak.mealient.datasource.models.*
import retrofit2.http.*
interface MealieService {
@FormUrlEncoded
@POST
suspend fun getToken(
@Url url: String,
@Field("username") username: String,
@Field("password") password: String,
): GetTokenResponse
@POST
suspend fun addRecipe(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Body addRecipeRequest: AddRecipeRequest,
): String
@GET
suspend fun getVersion(
@Url url: String,
): VersionResponse
@GET
suspend fun getRecipeSummary(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
@Query("start") start: Int,
@Query("limit") limit: Int,
): List<GetRecipeSummaryResponse>
@GET
suspend fun getRecipe(
@Url url: String,
@Header(AUTHORIZATION_HEADER_NAME) token: String?,
): GetRecipeResponse
}

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.datasource
import okhttp3.OkHttpClient
interface OkHttpBuilder {
fun buildOkHttp(): OkHttpClient
}

View File

@@ -0,0 +1,19 @@
package gq.kirmanak.mealient.datasource
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class OkHttpBuilderImpl @Inject constructor(
private val cacheBuilder: CacheBuilder,
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
private val interceptors: Set<@JvmSuppressWildcards Interceptor>,
) : OkHttpBuilder {
override fun buildOkHttp(): OkHttpClient = OkHttpClient.Builder()
.apply { interceptors.forEach(::addNetworkInterceptor) }
.cache(cacheBuilder.buildCache())
.build()
}

View File

@@ -0,0 +1,25 @@
package gq.kirmanak.mealient.datasource
import gq.kirmanak.mealient.logging.Logger
import okhttp3.OkHttpClient
import retrofit2.Converter.Factory
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RetrofitBuilder @Inject constructor(
private val okHttpClient: OkHttpClient,
private val converterFactory: Factory,
private val logger: Logger,
) {
fun buildRetrofit(baseUrl: String): Retrofit {
logger.v { "buildRetrofit() called with: baseUrl = $baseUrl" }
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(converterFactory)
.build()
}
}

View File

@@ -1,6 +1,5 @@
package gq.kirmanak.mealient.data.add.models package gq.kirmanak.mealient.datasource.models
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -20,29 +19,7 @@ data class AddRecipeRequest(
@SerialName("extras") val extras: Map<String, String> = emptyMap(), @SerialName("extras") val extras: Map<String, String> = emptyMap(),
@SerialName("assets") val assets: List<String> = emptyList(), @SerialName("assets") val assets: List<String> = emptyList(),
@SerialName("settings") val settings: AddRecipeSettings = AddRecipeSettings(), @SerialName("settings") val settings: AddRecipeSettings = AddRecipeSettings(),
) {
constructor(input: AddRecipeDraft) : this(
name = input.recipeName,
description = input.recipeDescription,
recipeYield = input.recipeYield,
recipeIngredient = input.recipeIngredients.map { AddRecipeIngredient(note = it) },
recipeInstructions = input.recipeInstructions.map { AddRecipeInstruction(text = it) },
settings = AddRecipeSettings(
public = input.isRecipePublic,
disableComments = input.areCommentsDisabled,
) )
)
fun toDraft(): AddRecipeDraft = AddRecipeDraft(
recipeName = name,
recipeDescription = description,
recipeYield = recipeYield,
recipeInstructions = recipeInstructions.map { it.text },
recipeIngredients = recipeIngredient.map { it.note },
isRecipePublic = settings.public,
areCommentsDisabled = settings.disableComments,
)
}
@Serializable @Serializable
data class AddRecipeSettings( data class AddRecipeSettings(

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.network package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.recipes.network.response package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.recipes.network.response package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.recipes.network.response package gq.kirmanak.mealient.datasource.models
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.recipes.network.response package gq.kirmanak.mealient.datasource.models
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.network package gq.kirmanak.mealient.datasource.models
sealed class NetworkError(cause: Throwable) : RuntimeException(cause) { sealed class NetworkError(cause: Throwable) : RuntimeException(cause) {
class Unauthorized(cause: Throwable) : NetworkError(cause) class Unauthorized(cause: Throwable) : NetworkError(cause)

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.baseurl.impl package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.di package gq.kirmanak.mealient
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides

View File

@@ -0,0 +1,117 @@
package gq.kirmanak.mealient.datasource
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.datasource.models.GetTokenResponse
import gq.kirmanak.mealient.datasource.models.NetworkError
import gq.kirmanak.mealient.datasource.models.VersionResponse
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.toJsonResponseBody
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.junit.Before
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
import java.net.ConnectException
@OptIn(ExperimentalCoroutinesApi::class)
class MealieDataSourceImplTest {
@MockK
lateinit var service: MealieService
@MockK(relaxUnitFun = true)
lateinit var logger: Logger
lateinit var subject: MealieDataSourceImpl
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = MealieDataSourceImpl(logger, service, Json.Default)
}
@Test(expected = NetworkError.NotMealie::class)
fun `when getVersionInfo and getVersion throws HttpException then NotMealie`() = runTest {
val error = HttpException(Response.error<VersionResponse>(404, "".toJsonResponseBody()))
coEvery { service.getVersion(any()) } throws error
subject.getVersionInfo(TEST_BASE_URL)
}
@Test(expected = NetworkError.NotMealie::class)
fun `when getVersionInfo and getVersion throws SerializationException then NotMealie`() =
runTest {
coEvery { service.getVersion(any()) } throws SerializationException()
subject.getVersionInfo(TEST_BASE_URL)
}
@Test(expected = NetworkError.NoServerConnection::class)
fun `when getVersionInfo and getVersion throws IOException then NoServerConnection`() =
runTest {
coEvery { service.getVersion(any()) } throws ConnectException()
subject.getVersionInfo(TEST_BASE_URL)
}
@Test
fun `when getVersionInfo and getVersion returns result then result`() = runTest {
val versionResponse = VersionResponse(true, "v0.5.6", true)
coEvery { service.getVersion(any()) } returns versionResponse
assertThat(subject.getVersionInfo(TEST_BASE_URL)).isSameInstanceAs(versionResponse)
}
@Test
fun `when authentication is successful then token is correct`() = runTest {
coEvery { service.getToken(any(), any(), any()) } returns GetTokenResponse(TEST_TOKEN)
assertThat(callAuthenticate()).isEqualTo(TEST_TOKEN)
}
@Test(expected = NetworkError.Unauthorized::class)
fun `when authenticate receives 401 and Unauthorized then throws Unauthorized`() = runTest {
val body = "{\"detail\":\"Unauthorized\"}".toJsonResponseBody()
coEvery {
service.getToken(any(), any(), any())
} throws HttpException(Response.error<GetTokenResponse>(401, body))
callAuthenticate()
}
@Test(expected = HttpException::class)
fun `when authenticate receives 401 but not Unauthorized then throws NotMealie`() = runTest {
val body = "{\"detail\":\"Something\"}".toJsonResponseBody()
coEvery {
service.getToken(any(), any(), any())
} throws HttpException(Response.error<GetTokenResponse>(401, body))
callAuthenticate()
}
@Test(expected = SerializationException::class)
fun `when authenticate receives 404 and empty body then throws NotMealie`() = runTest {
val body = "".toJsonResponseBody()
coEvery {
service.getToken(any(), any(), any())
} throws HttpException(Response.error<GetTokenResponse>(401, body))
callAuthenticate()
}
@Test(expected = IOException::class)
fun `when authenticate and getToken throws then throws NoServerConnection`() = runTest {
coEvery { service.getToken(any(), any(), any()) } throws IOException("Server not found")
callAuthenticate()
}
private suspend fun callAuthenticate(): String =
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, TEST_BASE_URL)
companion object {
const val TEST_USERNAME = "TEST_USERNAME"
const val TEST_PASSWORD = "TEST_PASSWORD"
const val TEST_BASE_URL = "https://example.com/"
const val TEST_TOKEN = "TEST_TOKEN"
const val TEST_AUTH_HEADER = "Bearer TEST_TOKEN"
}
}

View File

@@ -23,3 +23,4 @@ include(":app")
include(":database") include(":database")
include(":datastore") include(":datastore")
include(":logging") include(":logging")
include(":datasource")