Skip to content

functional domain modeling #63

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 13 commits into from
Nov 12, 2021
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
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ trim_trailing_whitespace=true
insert_final_newline=true

[*.{kt,kts}]
kotlin_imports_layout=ascii
ij_kotlin_imports_layout=*
1 change: 1 addition & 0 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
pull_request:
branches: [ master ]
paths-ignore: [ '**.md', '**.MD' ]
workflow_dispatch:

env:
CI: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.mockk.mockkClass
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import org.junit.Rule
import org.koin.test.AutoCloseKoinTest
import org.koin.test.check.checkKoinModules
import org.koin.test.mock.MockProviderRule
import kotlin.test.Test
Expand All @@ -15,7 +16,7 @@ import kotlin.time.ExperimentalTime
@FlowPreview
@ExperimentalCoroutinesApi
@ExperimentalTime
class ExampleUnitTest {
class CheckModulesTest : AutoCloseKoinTest() {
@get:Rule
val mockProvider = MockProviderRule.create { clazz ->
mockkClass(clazz).also { o ->
Expand Down
26 changes: 24 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import java.util.EnumSet
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.api.tasks.testing.logging.TestLogEvent
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

// Top-level build file where you can add configuration options common to all sub-projects/modules.
Expand Down Expand Up @@ -33,7 +36,9 @@ subprojects {
// TODO this should all come from editorconfig https://github.com/diffplug/spotless/issues/142
mapOf(
"indent_size" to "2",
"kotlin_imports_layout" to "ascii"
"ij_kotlin_imports_layout" to "*",
"end_of_line" to "lf",
"charset" to "utf-8"
)
)

Expand All @@ -56,7 +61,9 @@ subprojects {
ktlint(ktlintVersion).userData(
mapOf(
"indent_size" to "2",
"kotlin_imports_layout" to "ascii"
"ij_kotlin_imports_layout" to "*",
"end_of_line" to "lf",
"charset" to "utf-8"
)
)

Expand All @@ -83,6 +90,21 @@ subprojects {
isIncludeNoLocationClasses = true
excludes = listOf("jdk.internal.*")
}

testLogging {
showExceptions = true
showCauses = true
showStackTraces = true
showStandardStreams = true
events = EnumSet.of(
TestLogEvent.PASSED,
TestLogEvent.FAILED,
TestLogEvent.SKIPPED,
TestLogEvent.STANDARD_OUT,
TestLogEvent.STANDARD_ERROR
)
exceptionFormat = TestExceptionFormat.FULL
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/deps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.gradle.kotlin.dsl.project
import org.gradle.plugin.use.PluginDependenciesSpec
import org.gradle.plugin.use.PluginDependencySpec

const val ktlintVersion = "0.41.0"
const val ktlintVersion = "0.43.0"
const val kotlinVersion = "1.5.31"

object appConfig {
Expand Down
1 change: 1 addition & 0 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,5 @@ dependencies {

addUnitTest()
testImplementation(testUtils)
testImplementation(deps.koin.testJunit4)
}
100 changes: 69 additions & 31 deletions data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
package com.hoc.flowmvi.data

import arrow.core.Either
import arrow.core.ValidatedNel
import arrow.core.computations.either
import arrow.core.left
import arrow.core.leftWiden
import arrow.core.right
import arrow.core.valueOr
import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
import com.hoc.flowmvi.core.retrySuspend
import com.hoc.flowmvi.data.remote.UserApiService
import com.hoc.flowmvi.data.remote.UserBody
import com.hoc.flowmvi.data.remote.UserResponse
import com.hoc.flowmvi.domain.entity.User
import com.hoc.flowmvi.domain.repository.UserError
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
Expand All @@ -27,13 +30,14 @@ import timber.log.Timber
import java.io.IOException
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import arrow.core.Either.Companion.catch as catchEither

@ExperimentalTime
@ExperimentalCoroutinesApi
internal class UserRepositoryImpl(
private val userApiService: UserApiService,
private val dispatchers: CoroutineDispatchers,
private val responseToDomain: Mapper<UserResponse, User>,
private val responseToDomain: Mapper<UserResponse, ValidatedNel<UserValidationError, User>>,
private val domainToBody: Mapper<User, UserBody>,
private val errorMapper: Mapper<Throwable, UserError>,
) : UserRepository {
Expand All @@ -44,18 +48,35 @@ 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 suspend fun getUsersFromRemote(): List<User> {
return withContext(dispatchers.io) {
retrySuspend(
times = 3,
initialDelay = Duration.milliseconds(500),
factor = 2.0,
shouldRetry = { it is IOException }
) {
Timber.d("[USER_REPO] Retry times=$it")
userApiService.getUsers().map(responseToDomain)
) { times ->
Timber.d("[USER_REPO] Retry times=$times")
userApiService
.getUsers()
.map(responseToDomainThrows)
}
}
}
Expand All @@ -77,40 +98,57 @@ internal class UserRepositoryImpl(
}
.map { it.right().leftWiden<UserError, Nothing, List<User>>() }
.catch {
Timber.tag("UserRepositoryImpl").e(it, "getUsers")
logError(it, "getUsers")
emit(errorMapper(it).left())
}

override suspend fun refresh() = Either.catch {
getUsersFromRemote().let { changesFlow.emit(Change.Refreshed(it)) }
}.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "refresh") }
override suspend fun refresh() = catchEither { getUsersFromRemote() }
.tap { sendChange(Change.Refreshed(it)) }
.map { }
.tapLeft { logError(it, "refresh") }
.mapLeft(errorMapper)

override suspend fun remove(user: User) = Either.catch {
override suspend fun remove(user: User) = either<UserError, Unit> {
withContext(dispatchers.io) {
val response = userApiService.remove(user.id)
changesFlow.emit(Change.Removed(responseToDomain(response)))
}
}.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "remove user=$user") }
.mapLeft(errorMapper)
val response = catchEither { userApiService.remove(user.id) }
.tapLeft { logError(it, "remove user=$user") }
.mapLeft(errorMapper)
.bind()

override suspend fun add(user: User) = Either.catch {
withContext(dispatchers.io) {
val body = domainToBody(user)
val response = userApiService.add(body)
changesFlow.emit(Change.Added(responseToDomain(response)))
extraDelay()
val deleted = responseToDomain(response)
.mapLeft { UserError.ValidationFailed(it.toSet()) }
.tapInvalid { logError(it, "remove user=$user") }
.bind()

sendChange(Change.Removed(deleted))
}
}.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "add user=$user") }
.mapLeft(errorMapper)
}

override suspend fun search(query: String) = Either.catch {
override suspend fun add(user: User) = either<UserError, Unit> {
withContext(dispatchers.io) {
extraDelay()
userApiService.search(query).map(responseToDomain)
val response = catchEither { userApiService.add(domainToBody(user)) }
.tapLeft { logError(it, "add user=$user") }
.mapLeft(errorMapper)
.bind()

delay(400) // TODO

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

sendChange(Change.Added(added))
}
}.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "search query=$query") }
.mapLeft(errorMapper)
}

private suspend inline fun extraDelay() = delay(400)
override suspend fun search(query: String) = withContext(dispatchers.io) {
catchEither { userApiService.search(query).map(responseToDomainThrows) }
.tapLeft { logError(it, "search query=$query") }
.mapLeft(errorMapper)
}

private companion object {
private val TAG = UserRepositoryImpl::class.java.simpleName
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ package com.hoc.flowmvi.data.mapper

import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.data.remote.UserBody
import com.hoc.flowmvi.domain.entity.User
import com.hoc.flowmvi.domain.model.User

internal class UserDomainToUserBodyMapper : Mapper<User, UserBody> {
override fun invoke(domain: User): UserBody {
return UserBody(
email = domain.email,
firstName = domain.firstName,
lastName = domain.lastName
email = domain.email.value,
firstName = domain.firstName.value,
lastName = domain.lastName.value
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.hoc.flowmvi.data.mapper
import arrow.core.nonFatalOrThrow
import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.data.remote.ErrorResponse
import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.domain.model.UserError
import com.squareup.moshi.JsonAdapter
import okhttp3.ResponseBody
import retrofit2.HttpException
Expand All @@ -19,6 +19,7 @@ internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter

return runCatching {
when (throwable) {
is UserError -> throwable
is IOException -> when (throwable) {
is UnknownHostException -> UserError.NetworkError
is SocketTimeoutException -> UserError.NetworkError
Expand Down Expand Up @@ -47,7 +48,7 @@ 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
"validation-failed" -> UserError.ValidationFailed(errors = emptySet())
else -> UserError.Unexpected
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.hoc.flowmvi.data.mapper

import arrow.core.ValidatedNel
import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.data.remote.UserResponse
import com.hoc.flowmvi.domain.entity.User
import com.hoc.flowmvi.domain.model.User
import com.hoc.flowmvi.domain.model.UserValidationError

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