Merge pull request #70 from kirmanak/network-module
Extract datasource to separate module
This commit is contained in:
24
.run/Run tests.run.xml
Normal file
24
.run/Run tests.run.xml
Normal 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>
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package gq.kirmanak.mealient.data.network
|
|
||||||
|
|
||||||
interface ServiceFactory<T> {
|
|
||||||
|
|
||||||
suspend fun provideService(baseUrl: String? = null): T
|
|
||||||
}
|
|
||||||
@@ -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>)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
1
datasource/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
45
datasource/build.gradle.kts
Normal file
45
datasource/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package gq.kirmanak.mealient.datasource
|
||||||
|
|
||||||
|
import okhttp3.Cache
|
||||||
|
|
||||||
|
interface CacheBuilder {
|
||||||
|
|
||||||
|
fun buildCache(): Cache
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package gq.kirmanak.mealient.datasource
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
interface OkHttpBuilder {
|
||||||
|
|
||||||
|
fun buildOkHttp(): OkHttpClient
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,3 +23,4 @@ include(":app")
|
|||||||
include(":database")
|
include(":database")
|
||||||
include(":datastore")
|
include(":datastore")
|
||||||
include(":logging")
|
include(":logging")
|
||||||
|
include(":datasource")
|
||||||
|
|||||||
Reference in New Issue
Block a user