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

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

@@ -0,0 +1,48 @@
package gq.kirmanak.mealient
import android.content.Context
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.chuckerteam.chucker.api.RetentionManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.datasource.BuildConfig
import gq.kirmanak.mealient.logging.Logger
import okhttp3.Interceptor
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DebugModule {
@Provides
@Singleton
@IntoSet
fun provideLoggingInterceptor(logger: Logger): Interceptor {
val interceptor = HttpLoggingInterceptor { message -> logger.v(tag = "OkHttp") { message } }
interceptor.level = when {
BuildConfig.LOG_NETWORK -> HttpLoggingInterceptor.Level.BODY
else -> HttpLoggingInterceptor.Level.BASIC
}
return interceptor
}
@Provides
@Singleton
@IntoSet
fun provideChuckerInterceptor(@ApplicationContext context: Context): Interceptor {
val collector = ChuckerCollector(
context = context,
showNotification = true,
retentionPeriod = RetentionManager.Period.ONE_HOUR,
)
return ChuckerInterceptor.Builder(context)
.collector(collector)
.alwaysReadResponseBody(true)
.build()
}
}

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

@@ -0,0 +1,54 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AddRecipeRequest(
@SerialName("name") val name: String = "",
@SerialName("description") val description: String = "",
@SerialName("image") val image: String = "",
@SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredient: List<AddRecipeIngredient> = emptyList(),
@SerialName("recipeInstructions") val recipeInstructions: List<AddRecipeInstruction> = emptyList(),
@SerialName("slug") val slug: String = "",
@SerialName("filePath") val filePath: String = "",
@SerialName("tags") val tags: List<String> = emptyList(),
@SerialName("categories") val categories: List<String> = emptyList(),
@SerialName("notes") val notes: List<AddRecipeNote> = emptyList(),
@SerialName("extras") val extras: Map<String, String> = emptyMap(),
@SerialName("assets") val assets: List<String> = emptyList(),
@SerialName("settings") val settings: AddRecipeSettings = AddRecipeSettings(),
)
@Serializable
data class AddRecipeSettings(
@SerialName("disableAmount") val disableAmount: Boolean = true,
@SerialName("disableComments") val disableComments: Boolean = false,
@SerialName("landscapeView") val landscapeView: Boolean = true,
@SerialName("public") val public: Boolean = true,
@SerialName("showAssets") val showAssets: Boolean = true,
@SerialName("showNutrition") val showNutrition: Boolean = true,
)
@Serializable
data class AddRecipeNote(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String = "",
)
@Serializable
data class AddRecipeInstruction(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String = "",
)
@Serializable
data class AddRecipeIngredient(
@SerialName("disableAmount") val disableAmount: Boolean = true,
@SerialName("food") val food: String? = null,
@SerialName("note") val note: String = "",
@SerialName("quantity") val quantity: Int = 1,
@SerialName("title") val title: String? = null,
@SerialName("unit") val unit: String? = null,
)

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ErrorDetail(@SerialName("detail") val detail: String? = null)

View File

@@ -0,0 +1,14 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeIngredientResponse(
@SerialName("title") val title: String = "",
@SerialName("note") val note: String = "",
@SerialName("unit") val unit: String = "",
@SerialName("food") val food: String = "",
@SerialName("disableAmount") val disableAmount: Boolean,
@SerialName("quantity") val quantity: Int,
)

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeInstructionResponse(
@SerialName("title") val title: String = "",
@SerialName("text") val text: String,
)

View File

@@ -0,0 +1,23 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeResponse(
@SerialName("id") val remoteId: Long,
@SerialName("name") val name: String,
@SerialName("slug") val slug: String,
@SerialName("image") val image: String,
@SerialName("description") val description: String = "",
@SerialName("recipeCategory") val recipeCategories: List<String>,
@SerialName("tags") val tags: List<String>,
@SerialName("rating") val rating: Int?,
@SerialName("dateAdded") val dateAdded: LocalDate,
@SerialName("dateUpdated") val dateUpdated: LocalDateTime,
@SerialName("recipeYield") val recipeYield: String = "",
@SerialName("recipeIngredient") val recipeIngredients: List<GetRecipeIngredientResponse>,
@SerialName("recipeInstructions") val recipeInstructions: List<GetRecipeInstructionResponse>,
)

View File

@@ -0,0 +1,24 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetRecipeSummaryResponse(
@SerialName("id") val remoteId: Long,
@SerialName("name") val name: String,
@SerialName("slug") val slug: String,
@SerialName("image") val image: String?,
@SerialName("description") val description: String = "",
@SerialName("recipeCategory") val recipeCategories: List<String>,
@SerialName("tags") val tags: List<String>,
@SerialName("rating") val rating: Int?,
@SerialName("dateAdded") val dateAdded: LocalDate,
@SerialName("dateUpdated") val dateUpdated: LocalDateTime
) {
override fun toString(): String {
return "GetRecipeSummaryResponse(remoteId=$remoteId, name='$name')"
}
}

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetTokenResponse(@SerialName("access_token") val accessToken: String)

View File

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

View File

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

View File

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