Create network module

This commit is contained in:
Kirill Kamakin
2022-08-06 18:20:23 +02:00
parent c2c67730d1
commit e0a4442e72
59 changed files with 560 additions and 479 deletions

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
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
interface AddRecipeRepo {

View File

@@ -1,8 +1,8 @@
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.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.datasource.models.AddRecipeRequest
import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@@ -10,15 +10,14 @@ import javax.inject.Singleton
@Singleton
class AddRecipeDataSourceImpl @Inject constructor(
private val addRecipeServiceFactory: ServiceFactory<AddRecipeService>,
private val logger: Logger,
private val mealieDataSourceWrapper: MealieDataSourceWrapper,
) : 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) },
block = { mealieDataSourceWrapper.addRecipe(recipe) },
logProvider = { "addRecipe: can't add recipe" }
)
logger.v { "addRecipe() response = $response" }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ 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.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.extensions.versionInfo
import gq.kirmanak.mealient.logging.Logger
@@ -11,16 +11,15 @@ import javax.inject.Singleton
@Singleton
class VersionDataSourceImpl @Inject constructor(
private val serviceFactory: ServiceFactory<VersionService>,
private val logger: Logger,
private val mealieDataSourceWrapper: MealieDataSourceWrapper,
) : 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() },
block = { mealieDataSourceWrapper.getVersionInfo(baseUrl) },
logProvider = { "getVersionInfo: can't request version" }
)

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.*
import gq.kirmanak.mealient.datasource.models.NetworkError
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,
) {
suspend fun addRecipe(recipe: AddRecipeRequest): String {
val baseUrl = baseURLStorage.requireBaseURL()
return withAuthHeader { token -> addRecipe(baseUrl, token, recipe) }
}
suspend fun getVersionInfo(baseUrl: String): VersionResponse {
return mealieDataSource.getVersionInfo(baseUrl)
}
suspend fun requestRecipes(
start: Int = 0,
limit: Int = 9999,
): List<GetRecipeSummaryResponse> {
val baseUrl = baseURLStorage.requireBaseURL()
return withAuthHeader { token -> requestRecipes(baseUrl, token, start, limit) }
}
suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
val baseUrl = baseURLStorage.requireBaseURL()
return withAuthHeader { token -> requestRecipeInfo(baseUrl, token, slug) }
}
private suspend inline fun <T> withAuthHeader(block: MealieDataSource.(String?) -> T): T =
mealieDataSource.runCatching { block(authRepo.getAuthHeader()) }.getOrElse {
if (it is NetworkError.Unauthorized) {
authRepo.invalidateAuthHeader()
mealieDataSource.block(authRepo.getAuthHeader())
} else {
throw it
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
package gq.kirmanak.mealient.data.recipes.db
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.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
interface RecipeStorage {
suspend fun saveRecipes(recipes: List<GetRecipeSummaryResponse>)

View File

@@ -2,11 +2,11 @@ package gq.kirmanak.mealient.data.recipes.db
import androidx.paging.PagingSource
import androidx.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.recipe.RecipeDao
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.toRecipeEntity
import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity

View File

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

View File

@@ -1,34 +1,30 @@
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.data.network.MealieDataSourceWrapper
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.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,
private val mealieDataSourceWrapper: MealieDataSourceWrapper,
) : 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)
val recipeSummary = mealieDataSourceWrapper.requestRecipes(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)
val recipeInfo = mealieDataSourceWrapper.requestRecipeInfo(slug)
logger.v { "requestRecipeInfo() returned: $recipeInfo" }
return recipeInfo
}
private suspend fun getRecipeService(): RecipeService {
logger.v { "getRecipeService() called" }
return recipeServiceFactory.provideService()
}
}

View File

@@ -1,20 +0,0 @@
package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface RecipeService {
@GET("/api/recipes/summary")
suspend fun getRecipeSummary(
@Query("start") start: Int,
@Query("limit") limit: Int,
): List<GetRecipeSummaryResponse>
@GET("/api/recipes/{recipe_slug}")
suspend fun getRecipe(
@Path("recipe_slug") recipeSlug: String,
): GetRecipeResponse
}

View File

@@ -2,46 +2,20 @@ package gq.kirmanak.mealient.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
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.AddRecipeService
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.AddRecipeStorageImpl
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
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
@Singleton

View File

@@ -13,16 +13,7 @@ import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
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.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
@Module
@@ -31,20 +22,6 @@ interface AuthModule {
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
@Singleton
fun provideAccountManager(@ApplicationContext context: Context): AccountManager {

View File

@@ -2,44 +2,18 @@ package gq.kirmanak.mealient.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.impl.BaseURLStorageImpl
import gq.kirmanak.mealient.data.baseurl.impl.VersionDataSourceImpl
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
@Module
@InstallIn(SingletonComponent::class)
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
@Singleton
fun bindVersionDataSource(versionDataSourceImpl: VersionDataSourceImpl): VersionDataSource

View File

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

View File

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

View File

@@ -9,10 +9,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.R
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.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl
@@ -21,14 +17,9 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProviderImpl
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
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.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.io.InputStream
import javax.inject.Named
import javax.inject.Singleton
@Module
@@ -57,20 +48,6 @@ interface RecipeModule {
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
@Singleton
fun provideRecipePagingSourceFactory(

View File

@@ -1,25 +1,7 @@
package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.datasource.mapToNetworkError
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,

View File

@@ -1,15 +1,12 @@
package gq.kirmanak.mealient.extensions
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.RecipeIngredientEntity
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
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(
remoteId = remoteId,
@@ -45,4 +42,26 @@ fun GetRecipeSummaryResponse.recipeEntity() = RecipeSummaryEntity(
dateUpdated = dateUpdated,
)
fun VersionResponse.versionInfo() = VersionInfo(production, version, demoStatus)
fun VersionResponse.versionInfo() = VersionInfo(production, version, demoStatus)
fun AddRecipeDraft.toAddRecipeRequest() = AddRecipeRequest(
name = recipeName,
description = recipeDescription,
recipeYield = recipeYield,
recipeIngredient = recipeIngredients.map { AddRecipeIngredient(note = it) },
recipeInstructions = recipeInstructions.map { AddRecipeInstruction(text = it) },
settings = AddRecipeSettings(
public = isRecipePublic,
disableComments = areCommentsDisabled,
)
)
fun AddRecipeRequest.toDraft(): AddRecipeDraft = AddRecipeDraft(
recipeName = name,
recipeDescription = description,
recipeYield = recipeYield,
recipeInstructions = recipeInstructions.map { it.text },
recipeIngredients = recipeIngredient.map { it.note },
isRecipePublic = settings.public,
areCommentsDisabled = settings.disableComments,
)

View File

@@ -12,12 +12,12 @@ import androidx.fragment.app.viewModels
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
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.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.collectWhenViewResumed
import gq.kirmanak.mealient.logging.Logger

View File

@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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.logging.Logger
import kotlinx.coroutines.channels.Channel

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
package gq.kirmanak.mealient.ui.recipes.images
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.ModelCache
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.*
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.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.datasource.DataSourceModule.Companion.AUTHORIZATION_HEADER_NAME
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking
import java.io.InputStream
@@ -16,6 +16,7 @@ import javax.inject.Singleton
class RecipeModelLoader private constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger,
private val authRepo: AuthRepo,
concreteLoader: ModelLoader<GlideUrl, InputStream>,
cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
) : BaseGlideUrlLoader<RecipeSummaryEntity>(concreteLoader, cache) {
@@ -24,12 +25,13 @@ class RecipeModelLoader private constructor(
class Factory @Inject constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger,
private val authRepo: AuthRepo,
) {
fun build(
concreteLoader: ModelLoader<GlideUrl, InputStream>,
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" }
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
datasource/.gitignore vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,106 @@
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 retrofit2.HttpException
import retrofit2.Response
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 {
logger.v { "addRecipe() called with: baseUrl = $baseUrl, token = $token, recipe = $recipe" }
return mealieService.runCatching { addRecipe("$baseUrl/api/recipes/create", token, recipe) }
.onFailure { logger.e(it) { "addRecipe() request failed with: baseUrl = $baseUrl, token = $token, recipe = $recipe" } }
.onSuccess { logger.d { "addRecipe() request succeeded with: baseUrl = $baseUrl, token = $token, recipe = $recipe" } }
.getOrThrowUnauthorized()
}
override suspend fun authenticate(
baseUrl: String, username: String, password: String
): String {
logger.v { "authenticate() called with: baseUrl = $baseUrl, username = $username, password = $password" }
return mealieService.runCatching { getToken("$baseUrl/api/auth/token", username, password) }
.onFailure { logger.e(it) { "authenticate() request failed with: baseUrl = $baseUrl, username = $username, password = $password" } }
.onSuccess { logger.d { "authenticate() request succeeded with: baseUrl = $baseUrl, username = $username, password = $password" } }
.mapCatching { parseToken(it) }
.getOrThrowUnauthorized()
}
override suspend fun getVersionInfo(baseUrl: String): VersionResponse {
logger.v { "getVersionInfo() called with: baseUrl = $baseUrl" }
return mealieService.runCatching { getVersion("$baseUrl/api/debug/version") }
.onFailure { logger.e(it) { "getVersionInfo() request failed with: baseUrl = $baseUrl" } }
.onSuccess { logger.d { "getVersionInfo() request succeeded with: baseUrl = $baseUrl" } }
.getOrThrowUnauthorized()
}
override suspend fun requestRecipes(
baseUrl: String, token: String?, start: Int, limit: Int
): List<GetRecipeSummaryResponse> {
logger.v { "requestRecipes() called with: baseUrl = $baseUrl, token = $token, start = $start, limit = $limit" }
return mealieService.runCatching {
getRecipeSummary("$baseUrl/api/recipes/summary", token, start, limit)
}
.onFailure { logger.e(it) { "requestRecipes() request failed with: baseUrl = $baseUrl, token = $token, start = $start, limit = $limit" } }
.onSuccess { logger.d { "requestRecipes() request succeeded with: baseUrl = $baseUrl, token = $token, start = $start, limit = $limit" } }
.getOrThrowUnauthorized()
}
override suspend fun requestRecipeInfo(
baseUrl: String, token: String?, slug: String
): GetRecipeResponse {
logger.v { "requestRecipeInfo() called with: baseUrl = $baseUrl, token = $token, slug = $slug" }
return mealieService.runCatching { getRecipe("$baseUrl/api/recipes/$slug", token) }
.onFailure { logger.e(it) { "requestRecipeInfo() request failed with: baseUrl = $baseUrl, token = $token, slug = $slug" } }
.onSuccess { logger.d { "requestRecipeInfo() request succeeded with: baseUrl = $baseUrl, token = $token, slug = $slug" } }
.getOrThrowUnauthorized()
}
private fun parseToken(
response: Response<GetTokenResponse>
): String = if (response.isSuccessful) {
response.body()?.accessToken
?: throw NetworkError.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" -> NetworkError.Unauthorized(cause)
else -> NetworkError.NotMealie(cause)
}
}
}
private fun <T> Result<T>.getOrThrowUnauthorized(): T = getOrElse {
throw if (it is HttpException && it.code() in listOf(401, 403)) {
NetworkError.Unauthorized(it)
} else {
it
}
}
@OptIn(ExperimentalSerializationApi::class)
private inline fun <T, reified R> Json.decodeErrorBody(response: Response<T>): R {
val responseBody = checkNotNull(response.errorBody()) { "Can't decode absent error body" }
return decodeFromStream(responseBody.byteStream())
}
fun Throwable.mapToNetworkError(): NetworkError = when (this) {
is HttpException, is SerializationException -> NetworkError.NotMealie(this)
else -> NetworkError.NoServerConnection(this)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
package gq.kirmanak.mealient.data.add.models
package gq.kirmanak.mealient.datasource.models
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -20,29 +19,7 @@ data class AddRecipeRequest(
@SerialName("extras") val extras: Map<String, String> = emptyMap(),
@SerialName("assets") val assets: List<String> = emptyList(),
@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
data class AddRecipeSettings(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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