Skip to content

Update deps, replace Validateds with Eithers #188

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# MVI-Coroutines-Flow
## MVI-Coroutines-Flow-Clean-Architecture
## MVI-Coroutines-Flow-Clean-Architecture-ArrowKt
## MVI-Coroutines-Flow-Clean-Architecture-ArrowKt-KoinDI
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
Expand All @@ -9,7 +12,7 @@
[![Qodana](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/actions/workflows/qodana.yml/badge.svg)](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/actions/workflows/qodana.yml)
[![Validate Gradle Wrapper](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/actions/workflows/gradle-wrapper-validation.yml/badge.svg)](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/actions/workflows/gradle-wrapper-validation.yml)
[![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=21)
[![Kotlin](https://img.shields.io/badge/kotlin-1.7.20-blue.svg?logo=kotlin)](http://kotlinlang.org)
[![Kotlin](https://img.shields.io/badge/kotlin-1.8.10-blue.svg?logo=kotlin)](http://kotlinlang.org)
[![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2FKotlin-Android-Open-Source%2FMVI-Coroutines-Flow&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com)
[![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg)](https://opensource.org/licenses/MIT)
[![Gitter](https://badges.gitter.im/Kotlin-Android-Open-Source/community.svg)](https://gitter.im/Kotlin-Android-Open-Source/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ buildscript {
classpath("com.android.tools.build:gradle:7.4.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("com.diffplug.spotless:spotless-plugin-gradle:6.15.0")
classpath("dev.ahmedmourad.nocopy:nocopy-gradle-plugin:1.4.0")
classpath("dev.drewhamilton.poko:poko-gradle-plugin:0.11.0")
classpath("org.jacoco:org.jacoco.core:0.8.8")
classpath("com.vanniktech:gradle-android-junit-jacoco-plugin:0.17.0-SNAPSHOT")
classpath("com.github.ben-manes:gradle-versions-plugin:0.45.0")
Expand Down
24 changes: 12 additions & 12 deletions buildSrc/src/main/kotlin/deps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ import org.gradle.plugin.use.PluginDependenciesSpec
import org.gradle.plugin.use.PluginDependencySpec

const val ktlintVersion = "0.46.1"
const val kotlinVersion = "1.7.20"
const val kotlinVersion = "1.8.10"

object appConfig {
const val applicationId = "com.hoc.flowmvi"

const val compileSdkVersion = 32
const val buildToolsVersion = "32.0.0"
const val compileSdkVersion = 33
const val buildToolsVersion = "33.0.1"

const val minSdkVersion = 21
const val targetSdkVersion = 32
const val targetSdkVersion = 33

private const val MAJOR = 2
private const val MINOR = 1
Expand All @@ -28,16 +28,16 @@ object appConfig {
object deps {
object androidx {
const val appCompat = "androidx.appcompat:appcompat:1.4.2"
const val coreKtx = "androidx.core:core-ktx:1.8.0"
const val coreKtx = "androidx.core:core-ktx:1.9.0"
const val constraintLayout = "androidx.constraintlayout:constraintlayout:2.1.4"
const val recyclerView = "androidx.recyclerview:recyclerview:1.2.1"
const val swipeRefreshLayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01"
const val material = "com.google.android.material:material:1.6.1"
const val material = "com.google.android.material:material:1.8.0"
const val startup = "androidx.startup:startup-runtime:1.1.1"
}

object lifecycle {
private const val version = "2.5.0"
private const val version = "2.5.1"

const val viewModelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" // viewModelScope
const val runtimeKtx = "androidx.lifecycle:lifecycle-runtime-ktx:$version" // lifecycleScope
Expand All @@ -49,7 +49,7 @@ object deps {
const val converterMoshi = "com.squareup.retrofit2:converter-moshi:2.9.0"
const val loggingInterceptor = "com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.10"
const val moshiKotlin = "com.squareup.moshi:moshi-kotlin:1.13.0"
const val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.9.1"
const val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.10"
}

object coroutines {
Expand All @@ -61,7 +61,7 @@ object deps {
}

object koin {
private const val version = "3.2.0"
private const val version = "3.3.3"

const val core = "io.insert-koin:koin-core:$version"
const val android = "io.insert-koin:koin-android:$version"
Expand All @@ -75,7 +75,7 @@ object deps {
const val timber = "com.jakewharton.timber:timber:5.0.1"

object arrow {
private const val version = "1.1.3"
private const val version = "1.1.6-alpha.28"
const val core = "io.arrow-kt:arrow-core:$version"
}

Expand All @@ -91,7 +91,7 @@ object deps {
}
}

const val mockk = "io.mockk:mockk:1.12.4"
const val mockk = "io.mockk:mockk:1.13.4"
const val kotlinJUnit = "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion"
}
}
Expand All @@ -105,7 +105,7 @@ inline val PDsS.kotlinAndroid: PDS get() = id("kotlin-android")
inline val PDsS.kotlin: PDS get() = id("kotlin")
inline val PDsS.kotlinKapt: PDS get() = id("kotlin-kapt")
inline val PDsS.kotlinParcelize: PDS get() = id("kotlin-parcelize")
inline val PDsS.nocopyPlugin: PDS get() = id("dev.ahmedmourad.nocopy.nocopy-gradle-plugin")
inline val PDsS.pokoPlugin: PDS get() = id("dev.drewhamilton.poko")

inline val DependencyHandler.domain get() = project(":domain")
inline val DependencyHandler.core get() = project(":core")
Expand Down
56 changes: 56 additions & 0 deletions core/src/main/java/com/hoc/flowmvi/core/EitherNes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.hoc.flowmvi.core

import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.hoc.flowmvi.core.NonEmptySet.Companion.toNonEmptySetOrNull
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract

/**
* A typealias for [Either] with [NonEmptySet] as the left side.
*/
typealias EitherNes<E, A> = Either<NonEmptySet<E>, A>

@Suppress("NOTHING_TO_INLINE")
inline fun <A> A.rightNes(): EitherNes<Nothing, A> = this.right()

@Suppress("NOTHING_TO_INLINE")
inline fun <E> E.leftNes(): EitherNes<E, Nothing> =
NonEmptySet.of(this).left()

@OptIn(ExperimentalContracts::class)
inline fun <E, A, B, C, Z> Either.Companion.zipOrAccumulateNonEmptySet(
a: EitherNes<E, A>,
b: EitherNes<E, B>,
c: EitherNes<E, C>,
transform: (A, B, C) -> Z,
): EitherNes<E, Z> {
contract { callsInPlace(transform, InvocationKind.AT_MOST_ONCE) }

return if (
a is Either.Right &&
b is Either.Right &&
c is Either.Right
) {
Either.Right(
transform(
a.value,
b.value,
c.value,
)
)
} else {
Either.Left(
buildSet(capacity = a.count + b.count + c.count) {
if (a is Either.Left) this.addAll(a.value)
if (b is Either.Left) this.addAll(b.value)
if (c is Either.Left) this.addAll(c.value)
}.toNonEmptySetOrNull()!!
)
}
}

@PublishedApi
internal inline val <L, R> Either<L, R>.count: Int get() = if (isRight()) 1 else 0
22 changes: 0 additions & 22 deletions core/src/main/java/com/hoc/flowmvi/core/ValidatedNes.kt

This file was deleted.

79 changes: 43 additions & 36 deletions data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.hoc.flowmvi.data

import arrow.core.Either.Companion.catch as catchEither
import arrow.core.continuations.either
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.leftWiden
import arrow.core.raise.either
import arrow.core.right
import arrow.core.valueOr
import com.hoc.flowmvi.core.EitherNes
import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.core.ValidatedNes
import com.hoc.flowmvi.core.dispatchers.AppCoroutineDispatchers
import com.hoc.flowmvi.data.remote.UserApiService
import com.hoc.flowmvi.data.remote.UserBody
Expand All @@ -16,6 +16,7 @@ import com.hoc.flowmvi.domain.model.User
import com.hoc.flowmvi.domain.model.UserError
import com.hoc.flowmvi.domain.model.UserValidationError
import com.hoc.flowmvi.domain.repository.UserRepository
import com.hoc081098.flowext.flowFromSuspend
import com.hoc081098.flowext.retryWithExponentialBackoff
import java.io.IOException
import kotlin.time.Duration.Companion.milliseconds
Expand All @@ -24,7 +25,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapConcat
Expand All @@ -40,7 +40,7 @@ import timber.log.Timber
internal class UserRepositoryImpl(
private val userApiService: UserApiService,
private val dispatchers: AppCoroutineDispatchers,
private val responseToDomain: Mapper<UserResponse, ValidatedNes<UserValidationError, User>>,
private val responseToDomain: Mapper<UserResponse, EitherNes<UserValidationError, User>>,
private val domainToBody: Mapper<User, UserBody>,
private val errorMapper: Mapper<Throwable, UserError>,
) : UserRepository {
Expand All @@ -51,29 +51,21 @@ internal class UserRepositoryImpl(
class Added(val user: User) : Change()
}

private val responseToDomainThrows: (UserResponse) -> User = { response ->
responseToDomain(response).let { validated ->
validated.valueOr {
val t = UserError.ValidationFailed(it.toSet())
logError(t, "Map $response to user")
throw t
}
}
}

private val changesFlow = MutableSharedFlow<Change>(extraBufferCapacity = 64)

private suspend inline fun sendChange(change: Change) = changesFlow.emit(change)

@Suppress("NOTHING_TO_INLINE")
private inline fun logError(t: Throwable, message: String) = Timber.tag(TAG).e(t, message)

private fun getUsersFromRemote(): Flow<List<User>> = suspend {
private fun getUsersFromRemote(): Flow<List<User>> = flowFromSuspend {
Timber.d("[USER_REPO] getUsersFromRemote ...")

userApiService
.getUsers()
.map(responseToDomainThrows)
}.asFlow()
.map { response ->
responseToDomain(response)
.mapLeft(UserError::ValidationFailed)
.onLeft { logError(it, "Map $response to user") }
.getOrElse { throw it }
}
}
.retryWithExponentialBackoff(
maxAttempt = 2,
initialDelay = 500.milliseconds,
Expand All @@ -100,50 +92,65 @@ internal class UserRepositoryImpl(
}

override suspend fun refresh() = catchEither { getUsersFromRemote().first() }
.tap { sendChange(Change.Refreshed(it)) }
.onRight { sendChange(Change.Refreshed(it)) }
.map { }
.tapLeft { logError(it, "refresh") }
.onLeft { logError(it, "refresh") }
.mapLeft(errorMapper)

override suspend fun remove(user: User) = either<UserError, Unit> {
override suspend fun remove(user: User) = either {
withContext(dispatchers.io) {
val response = catchEither { userApiService.remove(user.id) }
.tapLeft { logError(it, "remove user=$user") }
.onLeft { logError(it, "remove user=$user") }
.mapLeft(errorMapper)
.bind()

val deleted = responseToDomain(response)
.mapLeft { UserError.ValidationFailed(it.toSet()) }
.tapInvalid { logError(it, "remove user=$user") }
.mapLeft { UserError.ValidationFailed(it) }
.onLeft { logError(it, "remove user=$user") }
.bind()

sendChange(Change.Removed(deleted))
}
}

override suspend fun add(user: User) = either<UserError, Unit> {
override suspend fun add(user: User) = either {
withContext(dispatchers.io) {
val response = catchEither { userApiService.add(domainToBody(user)) }
.tapLeft { logError(it, "add user=$user") }
.onLeft { logError(it, "add user=$user") }
.mapLeft(errorMapper)
.bind()

val added = responseToDomain(response)
.mapLeft { UserError.ValidationFailed(it.toSet()) }
.tapInvalid { logError(it, "add user=$user") }
.mapLeft { UserError.ValidationFailed(it) }
.onLeft { logError(it, "add user=$user") }
.bind()

sendChange(Change.Added(added))
}
}

override suspend fun search(query: String) = withContext(dispatchers.io) {
catchEither { userApiService.search(query).map(responseToDomainThrows) }
.tapLeft { logError(it, "search query=$query") }
.mapLeft(errorMapper)
override suspend fun search(query: String) = either {
withContext(dispatchers.io) {
val userResponses = catchEither { userApiService.search(query) }
.onLeft { logError(it, "search query=$query") }
.mapLeft(errorMapper)
.bind()

val users = userResponses.map { userResponse ->
responseToDomain(userResponse)
.mapLeft(UserError::ValidationFailed)
.onLeft { logError(it, "search query=$query") }
.bind()
}

users
}
}

private companion object {
@Suppress("NOTHING_TO_INLINE")
private inline fun logError(t: Throwable, message: String) = Timber.tag(TAG).e(t, message)

private val TAG = UserRepositoryImpl::class.java.simpleName
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import arrow.core.nonFatalOrThrow
import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.data.remote.ErrorResponse
import com.hoc.flowmvi.domain.model.UserError
import com.hoc.flowmvi.domain.model.UserValidationError
import com.squareup.moshi.JsonAdapter
import java.io.IOException
import java.net.SocketException
Expand Down Expand Up @@ -48,7 +49,9 @@ internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter
"internal-error" -> UserError.ServerError
"invalid-id" -> UserError.InvalidId(id = errorResponse.data as String)
"user-not-found" -> UserError.UserNotFound(id = errorResponse.data as String)
"validation-failed" -> UserError.ValidationFailed(errors = emptySet())
"validation-failed" -> UserError.ValidationFailed(
errors = UserValidationError.VALUES_SET // TODO(hoc081098): Map validation errors from server response
)
else -> UserError.Unexpected
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package com.hoc.flowmvi.data.mapper

import com.hoc.flowmvi.core.EitherNes
import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.core.ValidatedNes
import com.hoc.flowmvi.data.remote.UserResponse
import com.hoc.flowmvi.domain.model.User
import com.hoc.flowmvi.domain.model.UserValidationError

internal class UserResponseToUserDomainMapper :
Mapper<UserResponse, ValidatedNes<UserValidationError, User>> {
override fun invoke(response: UserResponse): ValidatedNes<UserValidationError, User> {
Mapper<UserResponse, EitherNes<UserValidationError, User>> {
override fun invoke(response: UserResponse): EitherNes<UserValidationError, User> {
return User.create(
id = response.id,
avatar = response.avatar,
Expand Down
Loading