diff --git a/README.md b/README.md index beb60f3..de0f9d2 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,13 @@ error handling **on steroids**. ## Features -* ApiResult is **lightweight**. The library tries to inline operators and reduce allocations where possible. +* ApiResult is **lightweight**. The library creates no objects, makes no allocations or virtual function resolutions. + Most of the code is inlined. * ApiResult offers 90+ operators covering most of possible use cases to turn your code from imperative and procedural to declarative and functional, which is more readable and extensible. * ApiResult defines a contract that you can use in your code. No one will be able to obtain the result of a computation without being forced to handle errors at compilation time. +* The library has 129 tests for 92% operator coverage. ## Preview diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index f9d8095..8114516 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -18,7 +18,7 @@ object Config { const val majorRelease = 2 const val minorRelease = 0 const val patch = 0 - const val postfix = "-alpha01" + const val postfix = "-beta01" const val versionName = "$majorRelease.$minorRelease.$patch$postfix" const val url = "https://github.com/respawn-app/ApiResult" const val licenseName = "The Apache Software License, Version 2.0" diff --git a/core/build.gradle.kts b/core/build.gradle.kts index cce3048..ae669c9 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -8,4 +8,5 @@ android { dependencies { commonMainApi(libs.kotlin.coroutines.core) + jvmTestImplementation(libs.bundles.unittest) } diff --git a/core/src/commonMain/kotlin/pro/respawn/apiresult/ApiResult.kt b/core/src/commonMain/kotlin/pro/respawn/apiresult/ApiResult.kt index bee2a40..3a466b5 100644 --- a/core/src/commonMain/kotlin/pro/respawn/apiresult/ApiResult.kt +++ b/core/src/commonMain/kotlin/pro/respawn/apiresult/ApiResult.kt @@ -34,7 +34,7 @@ import kotlin.jvm.JvmName * the operators that are invoked are called **immediately** and in-place. */ @JvmInline -public value class ApiResult @PublishedApi internal constructor(@PublishedApi internal val value: Any?) { +public value class ApiResult private constructor(@PublishedApi internal val value: Any?) { /** * Get the [Success] component of this result or null @@ -45,7 +45,7 @@ public value class ApiResult @PublishedApi internal constructor(@Publishe * ``` * @see orNull */ - public operator fun component1(): T? = orNull() + public inline operator fun component1(): T? = orNull() /** * Get the [Error] component of this result or null @@ -56,13 +56,13 @@ public value class ApiResult @PublishedApi internal constructor(@Publishe * ``` * @see exceptionOrNull */ - public operator fun component2(): Exception? = exceptionOrNull() + public inline operator fun component2(): Exception? = exceptionOrNull() /** * Bang operator returns the result or throws if it is an [Error] or [Loading] * This is equivalent to calling [orThrow] */ - public operator fun not(): T = orThrow() + public inline operator fun not(): T = orThrow() /** * The state of [ApiResult] that represents an error. @@ -70,13 +70,21 @@ public value class ApiResult @PublishedApi internal constructor(@Publishe */ @JvmInline @PublishedApi - internal value class Error(@JvmField val e: Exception) { + internal value class Error private constructor(@JvmField val e: Exception) { override fun toString(): String = "ApiResult.Error: message=${e.message} and cause: $e" + + companion object { + + fun create(e: Exception) = Error(e) + } } @PublishedApi - internal data object Loading + internal data object Loading { + + override fun toString(): String = "ApiResult.Loading" + } /** * Whether this is [Success] @@ -91,10 +99,10 @@ public value class ApiResult @PublishedApi internal constructor(@Publishe /** * Whether this is [Loading] */ - public inline val isLoading: Boolean get() = value is Loading + public inline val isLoading: Boolean get() = value === Loading override fun toString(): String = when { - value is Error || value is Loading -> value.toString() + value is Error || value === Loading -> value.toString() else -> "ApiResult.Success: $value" } @@ -103,17 +111,17 @@ public value class ApiResult @PublishedApi internal constructor(@Publishe /** * Create a successful [ApiResult] value */ - public inline fun Success(value: T): ApiResult = ApiResult(value) + public fun Success(value: T): ApiResult = ApiResult(value = value) /** * Create an error [ApiResult] value */ - public inline fun Error(value: Exception): ApiResult = ApiResult(Error(e = value)) + public fun Error(e: Exception): ApiResult = ApiResult(value = Error.create(e = e)) /** * Create a loading [ApiResult] value */ - public inline fun Loading(): ApiResult = ApiResult(value = Loading) + public fun Loading(): ApiResult = ApiResult(value = Loading) /** * Create an [ApiResult] instance using the given value. @@ -123,7 +131,7 @@ public value class ApiResult @PublishedApi internal constructor(@Publishe * If you want to directly create a success value of an [Exception], use [Success] */ public inline operator fun invoke(value: T): ApiResult = when (value) { - is Exception -> Error(value = value) + is Exception -> Error(e = value) else -> Success(value) } @@ -135,11 +143,11 @@ public value class ApiResult @PublishedApi internal constructor(@Publishe * [CancellationException]s are rethrown. */ public inline operator fun invoke(call: () -> T): ApiResult = try { - Success(call()) + Success(value = call()) } catch (e: CancellationException) { throw e } catch (expected: Exception) { - Error(expected) + Error(e = expected) } /** @@ -195,10 +203,13 @@ public inline infix fun ApiResult.orElse(block: (e: Exception) -> } /** - * If [this] is [Error], returns [defaultValue]. + * If [this] is [Error] or [Loading], returns [defaultValue]. * @see orElse */ -public inline infix fun ApiResult.or(defaultValue: R): T = orElse { defaultValue } +public inline infix fun ApiResult.or(defaultValue: R): T = when (value) { + is Error, is Loading -> defaultValue + else -> value as T +} /** * @return null if [this] is an [ApiResult.Error] or [ApiResult.Loading], otherwise return self. @@ -312,7 +323,9 @@ public inline fun ApiResult.errorIf( callsInPlace(predicate, InvocationKind.AT_MOST_ONCE) callsInPlace(exception, InvocationKind.AT_MOST_ONCE) } - return if (isSuccess && predicate(value as T)) Error(value = exception()) else this + if (!isSuccess) return this + if (!predicate(value as T)) return this + return Error(e = exception()) } /** @@ -326,7 +339,7 @@ public inline fun ApiResult.errorOnLoading( } return when (value) { - is Loading -> Error(value = exception()) + is Loading -> Error(e = exception()) else -> this } } @@ -337,10 +350,10 @@ public inline fun ApiResult.errorOnLoading( public inline fun ApiResult?.requireNotNull(): ApiResult = errorOnNull() /** - * Throws if [this] is not [Success] and returns [Success] otherwise. + * Alias for [orThrow] * @see orThrow */ -public inline fun ApiResult.require(): ApiResult = Success(!this) +public inline fun ApiResult.require(): T = orThrow() /** * Change the type of the [Success] to [R] without affecting [Error]/[Loading] results @@ -352,25 +365,19 @@ public inline infix fun ApiResult.map(block: (T) -> R): ApiResult { contract { callsInPlace(block, InvocationKind.AT_MOST_ONCE) } - return fold( - onSuccess = { Success(value = block(it)) }, - onError = { Error(value = it) }, - onLoading = { ApiResult.Loading() } - ) + if (isSuccess) return Success(value = block(value as T)) + return this as ApiResult } /** * Map the [Success] result using [transform], and if the result is not a success, return [default] */ -public inline fun ApiResult.mapOrDefault(default: () -> R, transform: (T) -> R): R { +public inline fun ApiResult.mapOrDefault(default: (e: Exception) -> R, transform: (T) -> R): R { contract { callsInPlace(transform, InvocationKind.AT_MOST_ONCE) callsInPlace(default, InvocationKind.AT_MOST_ONCE) } - return fold( - onSuccess = { transform(it) }, - onError = { default() } - ) + return map(transform).orElse(default) } /** @@ -379,10 +386,7 @@ public inline fun ApiResult.mapOrDefault(default: () -> R, transform: public inline fun ApiResult.mapEither( success: (T) -> R, error: (Exception) -> Exception, -): ApiResult = fold( - onSuccess = { Success(success(it)) }, - onError = { Error(value = error(it)) } -) +): ApiResult = map(success).mapError(error) /** * Maps [Loading] to a [Success], not affecting other states. @@ -414,7 +418,7 @@ public inline infix fun ApiResult.mapError(block: callsInPlace(block, InvocationKind.AT_MOST_ONCE) } return when { - value is Error && value.e is R -> Error(value = block(value.e)) + value is Error && value.e is R -> Error(e = block(value.e)) else -> this } } @@ -427,11 +431,10 @@ public inline fun ApiResult.mapErrorToCause(): ApiResult = mapError { /** * Unwrap an ApiResult> to be ApiResult */ -public inline fun ApiResult>.unwrap(): ApiResult = fold( - onSuccess = { it }, - onError = { Error(value = it) }, - onLoading = { ApiResult.Loading() }, -) +public inline fun ApiResult>.unwrap(): ApiResult = when (value) { + is Error, is Loading -> this + else -> value +} as ApiResult /** * Change the type of successful result to [R], also wrapping [block] @@ -450,17 +453,17 @@ public inline infix fun ApiResult.tryMap( * @see errorIf * @see errorIfEmpty */ -public inline fun ApiResult?.errorOnNull( +public inline fun ApiResult?.errorOnNull( exception: () -> Exception = { ConditionNotSatisfiedException("Value was null") }, -): ApiResult { +): ApiResult { contract { - returns() implies (this@errorOnNull != null) + returnsNotNull() } - return when (this?.value) { - is Error -> Error(value = value.e) + return when (val r = this?.value) { + is Error -> Error(e = r.e) is Loading -> ApiResult.Loading() - null -> Error(value = exception()) - else -> ApiResult(value = value) + null -> Error(e = exception()) + else -> Success(value = r as T) } } @@ -531,10 +534,8 @@ public inline fun ApiResult.recoverIf( callsInPlace(condition, InvocationKind.AT_MOST_ONCE) callsInPlace(block, InvocationKind.AT_MOST_ONCE) } - return when { - value is Error && condition(value.e) -> block(value.e) - else -> ApiResult(value = value) - } + if (value !is Error || !condition(value.e)) return this + return block(value.e) } /** diff --git a/core/src/commonMain/kotlin/pro/respawn/apiresult/SuspendResult.kt b/core/src/commonMain/kotlin/pro/respawn/apiresult/SuspendResult.kt index 9aae9a2..77a08f5 100644 --- a/core/src/commonMain/kotlin/pro/respawn/apiresult/SuspendResult.kt +++ b/core/src/commonMain/kotlin/pro/respawn/apiresult/SuspendResult.kt @@ -80,7 +80,7 @@ public inline fun ApiResult.Companion.flow( public inline fun Flow.asApiResult(): Flow> = this .map { it.asResult } .onStart { emit(ApiResult.Loading()) } - .catchExceptions { emit(ApiResult.Error(value = it)) } + .catchExceptions { emit(ApiResult.Error(e = it)) } /** * Maps each success value of [this] flow using [transform] diff --git a/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/ErrorOperatorTests.kt b/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/ErrorOperatorTests.kt new file mode 100644 index 0000000..fa7a931 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/ErrorOperatorTests.kt @@ -0,0 +1,228 @@ +package pro.respawn.apiresult.test + +import io.kotest.assertions.fail +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.core.spec.style.FreeSpec +import io.kotest.data.forAll +import io.kotest.data.row +import io.kotest.matchers.shouldBe +import pro.respawn.apiresult.ApiResult +import pro.respawn.apiresult.cause +import pro.respawn.apiresult.chain +import pro.respawn.apiresult.errorIf +import pro.respawn.apiresult.errorOnLoading +import pro.respawn.apiresult.errorUnless +import pro.respawn.apiresult.exceptionOrNull +import pro.respawn.apiresult.flatMap +import pro.respawn.apiresult.fold +import pro.respawn.apiresult.map +import pro.respawn.apiresult.mapEither +import pro.respawn.apiresult.mapError +import pro.respawn.apiresult.mapErrorToCause +import pro.respawn.apiresult.mapLoading +import pro.respawn.apiresult.mapOrDefault +import pro.respawn.apiresult.message +import pro.respawn.apiresult.nullOnError +import pro.respawn.apiresult.onError +import pro.respawn.apiresult.onLoading +import pro.respawn.apiresult.onSuccess +import pro.respawn.apiresult.or +import pro.respawn.apiresult.orElse +import pro.respawn.apiresult.orNull +import pro.respawn.apiresult.orThrow +import pro.respawn.apiresult.recover +import pro.respawn.apiresult.recoverIf +import pro.respawn.apiresult.require +import pro.respawn.apiresult.requireIs +import pro.respawn.apiresult.requireNotNull +import pro.respawn.apiresult.rethrow +import pro.respawn.apiresult.stackTrace +import pro.respawn.apiresult.tryChain +import pro.respawn.apiresult.tryMap +import pro.respawn.apiresult.tryRecover +import pro.respawn.apiresult.tryRecoverIf +import pro.respawn.apiresult.unit +import pro.respawn.apiresult.unwrap + +class ErrorOperatorTests : FreeSpec({ + val value = 42 + val cause = Exception("cause") + val exception = RuntimeException("error", cause) + + "given success value" - { + val result = ApiResult.Error(e = exception) + + "then isSuccess should be false" { + result.isSuccess shouldBe false + } + "then isError should be true" { + result.isError shouldBe true + } + "then isLoading should be false" { + result.isLoading shouldBe false + } + "then components should return 1 - null and 2 - exception" { + val (res, err) = result + res shouldBe null + err shouldBe exception + } + "then bang should throw" { + shouldThrowExactly { !result } + } + "then stacktrace is not null" { + result.stackTrace shouldBe exception.stackTraceToString() + } + "then message is not null" { + result.message shouldBe exception.message + } + "then cause is not null" { + result.cause shouldBe cause + } + "then orElse returns default" { + result.orElse { 0 } shouldBe 0 + } + "then rethrow" - { + "throws for matching types" { + shouldThrowExactly { + result.rethrow() + } + } + "does not throw for all other types" { + shouldNotThrowAny { + result.rethrow() + } + } + } + "then or returns default" { + result.or(0) shouldBe 0 + } + "then orNull returns null value" { + result.orNull() shouldBe null + } + "then exceptionOrNull returns exception" { + result.exceptionOrNull() shouldBe exception + } + "then fold returns default" { + val default = 0 + result.fold({ it }, { default }, onLoading = { -1 }) shouldBe default + } + "then onError calls error" { + result.shouldCall { result -> + result.onError { + it shouldBe exception + markCalled() + } + } + } + "then onSuccess does not call block" { + result.onSuccess { fail("Called onSuccess") } + } + "then onLoading does not call loading" { + result.onLoading { fail("Called onLoading") } + } + "then nullOnError returns null" { + result.nullOnError().orThrow() shouldBe null + } + "then errorIf returns true always" { + result.errorIf { false }.isError shouldBe true + result.errorIf { true }.isError shouldBe true + } + "then errorUnless always returns original error" { + result.errorUnless { true }.exceptionOrNull() shouldBe exception + result.errorUnless { false }.exceptionOrNull() shouldBe exception + } + "then errorOnLoading does not produce an error" { + // assert we did not get "ConditionNotSatisfiedException" + result.errorOnLoading().exceptionOrNull() shouldBe exception + } + "then requireNotNull returns error" { + result.requireNotNull() shouldBe result + } + "then map returns error value" { + result.map { it + 1 } shouldBe result + } + "then mapOrDefault returns new value" { + val default = 0 + result.mapOrDefault({ default }) { it + 1 } shouldBe default + } + "then mapEither returns exception value" { + val mappedError = IllegalArgumentException(exception) + result.mapEither({ it + 1 }) { mappedError }.exceptionOrNull() shouldBe mappedError + } + "then mapLoading is not executed" { + result.mapLoading { fail("Called mapLoading") } + } + "then mapError returns new error value" { + val mappedError = IllegalArgumentException(exception) + result.mapError { mappedError }.exceptionOrNull() shouldBe mappedError + } + "then mapErrorToCause returns cause" { + result.mapErrorToCause().exceptionOrNull() shouldBe cause + } + "then unwrap returns error" { + val wrapped = ApiResult(result) + wrapped.unwrap() shouldBe result + wrapped.unwrap().exceptionOrNull() shouldBe exception + } + "then tryMap is not executed" { + result.tryMap { fail("Called tryMap") } + } + "then recover recovers from exception" { + result.recover { ApiResult(value) }.orThrow() shouldBe value + } + "then tryRecover recovers from exception" { + result.tryRecover { value }.orThrow() shouldBe value + } + "then tryRecoverIf catches exceptions" { + val e = IllegalArgumentException() + shouldNotThrowAny { + result + .tryRecoverIf({ true }) { throw e } + .exceptionOrNull() shouldBe e + } + } + "and given require operator" - { + "and condition is false" - { + val required = result.require { value == 0 } + "then value is error" { + required.isError shouldBe true + } + } + "and condition is true" - { + val required = result.require { value == it } + "then value is still false" { + required.isSuccess shouldBe false + } + } + } + "then recoverIf is executed" { + result.recoverIf({ true }) { ApiResult(value) }.orThrow() shouldBe value + } + "then chain is not executed" { + result.chain { fail("chain should not be executed") } + } + "then tryChain is not executed" { + result.tryChain { fail("Called tryChain") } + } + "and a flatMap operator" - { + "then the result is always error" - { + forAll( + row(ApiResult.Error(IllegalArgumentException("another"))), + row(ApiResult.Success(value)), + row(ApiResult.Loading()), + ) { other -> + "for value $other" - { + result.flatMap { other } shouldBe result + } + } + } + } + "then unit does not do anything" { + result.unit() shouldBe result + } + "then requireIs returns error" - { + result.requireIs() shouldBe result + } + } +}) diff --git a/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/LoadingOperatorTests.kt b/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/LoadingOperatorTests.kt new file mode 100644 index 0000000..4a382ad --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/LoadingOperatorTests.kt @@ -0,0 +1,208 @@ +package pro.respawn.apiresult.test + +import io.kotest.assertions.fail +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.core.spec.style.FreeSpec +import io.kotest.data.forAll +import io.kotest.data.row +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import pro.respawn.apiresult.ApiResult +import pro.respawn.apiresult.NotFinishedException +import pro.respawn.apiresult.cause +import pro.respawn.apiresult.chain +import pro.respawn.apiresult.errorIf +import pro.respawn.apiresult.errorOnLoading +import pro.respawn.apiresult.errorUnless +import pro.respawn.apiresult.exceptionOrNull +import pro.respawn.apiresult.flatMap +import pro.respawn.apiresult.fold +import pro.respawn.apiresult.map +import pro.respawn.apiresult.mapEither +import pro.respawn.apiresult.mapError +import pro.respawn.apiresult.mapErrorToCause +import pro.respawn.apiresult.mapLoading +import pro.respawn.apiresult.mapOrDefault +import pro.respawn.apiresult.message +import pro.respawn.apiresult.nullOnError +import pro.respawn.apiresult.onError +import pro.respawn.apiresult.onLoading +import pro.respawn.apiresult.onSuccess +import pro.respawn.apiresult.or +import pro.respawn.apiresult.orElse +import pro.respawn.apiresult.orNull +import pro.respawn.apiresult.orThrow +import pro.respawn.apiresult.recover +import pro.respawn.apiresult.recoverIf +import pro.respawn.apiresult.require +import pro.respawn.apiresult.requireIs +import pro.respawn.apiresult.requireNotNull +import pro.respawn.apiresult.stackTrace +import pro.respawn.apiresult.tryChain +import pro.respawn.apiresult.tryMap +import pro.respawn.apiresult.tryRecover +import pro.respawn.apiresult.unit +import pro.respawn.apiresult.unwrap + +class LoadingOperatorTests : FreeSpec({ + val value = 42 + val cause = Exception("cause") + val exception = RuntimeException("error", cause) + + "given loading value" - { + val result = ApiResult.Loading() + + "then isSuccess should be false" { + result.isSuccess shouldBe false + } + "then isError should be true" { + result.isError shouldBe false + } + "then isLoading should be true" { + result.isLoading shouldBe true + } + "then components should return null" { + val (res, err) = result + res shouldBe null + err shouldBe null + } + "then bang should throw NotFinishedException" { + shouldThrowExactly { !result } + } + "then stacktrace is null" { + result.stackTrace shouldBe null + } + "then message is null" { + result.message shouldBe null + } + "then cause is null" { + result.cause shouldBe null + } + "Then orElse returns default" { + result.orElse { 0 } shouldBe 0 + } + "then or returns default" { + result.or(0) shouldBe 0 + } + "then orNull returns null" { + result.orNull() shouldBe null + } + "then exceptionOrNull returns null" { + result.exceptionOrNull() shouldBe null + } + "then fold returns default" { + val default = 0 + val loading = -1 + result.fold({ it }, { default }, { loading }) shouldBe loading + } + "then onError does not call error" { + result.onError { fail("Called onError") } + } + "then onSuccess does not call block" { + result.onSuccess { fail("Called onSuccess") } + } + "then onLoading calls block" { + result.shouldCall { it.onLoading { markCalled() } } + } + "then nullOnError returns loading" { + result.nullOnError() shouldBe result + } + + "then errorIf returns false always" { + result.errorIf { false }.isError shouldBe false + result.errorIf { true }.isError shouldBe false + } + "then errorUnless always returns false" { + result.errorUnless { true }.isError shouldBe false + result.errorUnless { false }.isError shouldBe false + } + "then errorOnLoading produces an error" { + result + .errorOnLoading() + .exceptionOrNull() + .shouldBeInstanceOf() + } + "then requireNotNull returns loading" { + result.requireNotNull() shouldBe result + } + "then map returns the same value" { + shouldNotCall { + result.map { + it + 1 + markCalled() + } shouldBe result + } + } + "then mapOrDefault returns new value" { + val default = 0 + result.mapOrDefault({ default }) { it + 1 } shouldBe default + } + "then mapEither does nothing" { + val mappedError = IllegalArgumentException(exception) + result.mapEither({ it + 1 }) { mappedError } shouldBe result + } + "then mapLoading is executed" { + shouldCall { + result.mapLoading { + markCalled() + value + }.orThrow() shouldBe value + } + } + "then mapError returns null" { + val mappedError = IllegalArgumentException(exception) + result.mapError { mappedError }.exceptionOrNull() shouldBe null + } + "then mapErrorToCause does nothing" { + result.mapErrorToCause() shouldBe result + } + "then unwrap returns error" { + val wrapped = ApiResult(result) + wrapped.unwrap() shouldBe result + } + "then tryMap is not executed" { + result.tryMap { fail("Called tryMap") } + } + "then recover is not executed" { + result.recover { fail("called recover") } shouldBe result + } + "then tryRecover is not executed" { + result.tryRecover { fail("called tryRecover") } shouldBe result + } + "and given require operator" - { + forAll(row(false), row(true)) { cond -> + "and condition is $cond then value is loading" { + result.require { cond } shouldBe result + } + } + } + "then recoverIf is not executed" { + result.recoverIf({ true }) { fail("called recoverIf") } shouldBe result + } + "then chain is not executed" { + result.chain { fail("chain should not be executed") } + } + "then tryChain is not executed" { + result.tryChain { fail("Called tryChain") } + } + "and a flatMap operator" - { + "then the result is always loading" - { + forAll( + row(ApiResult.Error(IllegalArgumentException("another"))), + row(ApiResult.Success(value)), + row(ApiResult.Loading()), + ) { other -> + "for value $other" - { + result.flatMap { other } shouldBe result + } + } + } + } + "then unit does not do anything" { + result.unit() shouldBe result + } + "then requireIs does nothing" - { + result.requireIs() shouldBe result + } + } +}) diff --git a/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/MiscOperatorTests.kt b/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/MiscOperatorTests.kt new file mode 100644 index 0000000..014ac59 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/MiscOperatorTests.kt @@ -0,0 +1,50 @@ +package pro.respawn.apiresult.test + +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.equals.shouldNotBeEqual +import io.kotest.matchers.shouldBe +import pro.respawn.apiresult.ApiResult +import pro.respawn.apiresult.orThrow +import pro.respawn.apiresult.runResulting +import kotlin.coroutines.cancellation.CancellationException + +class MiscOperatorTests : FreeSpec({ + val value = 42 + val exception = IllegalArgumentException("error") + "Given no-arg result builder" - { + val result = ApiResult() + "then returns success" { + result.orThrow() shouldBe Unit + } + } + "Given single-arg result builder" - { + "then exceptions result in error instances" { + ApiResult(value = exception) shouldBeEqual ApiResult.Error(exception) + } + "then values result in success instances" { + ApiResult(value = value) shouldBeEqual ApiResult.Success(value) + } + } + "Given success result" - { + val result = ApiResult(value) + "then compares exactly to another success value" { + result shouldBeEqual ApiResult(42) + } + "then is not equal to an error value" { + result shouldNotBeEqual ApiResult.Error(exception) + } + "then is not equal to a loading value" { + result shouldNotBeEqual ApiResult.Loading() + } + } + "Given result block that throws cancellation" - { + val block = { throw CancellationException() } + "then does not catch cancellations" { + shouldThrowExactly { + runResulting(block = block) + } + } + } +}) diff --git a/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/ShouldCall.kt b/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/ShouldCall.kt new file mode 100644 index 0000000..b445778 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/ShouldCall.kt @@ -0,0 +1,35 @@ +package pro.respawn.apiresult.test + +import io.kotest.matchers.Matcher +import io.kotest.matchers.MatcherResult +import io.kotest.matchers.should +import io.kotest.matchers.shouldNot + +inline fun haveCalled(crossinline block: CallScope.(value: T) -> Unit) = Matcher { + val scope = CallScope() + scope.block(it) + MatcherResult( + passed = scope.isPass, + failureMessageFn = { "Expected function was not called" }, + negatedFailureMessageFn = { "Expected function should not have been called" }, + ) +} + +class CallScope(private var called: Boolean = false) { + + val isPass get() = called + + fun markCalled() { + called = true + } +} + +inline infix fun T.shouldCall(crossinline block: CallScope.(value: T) -> Unit): T { + this should haveCalled(block) + return this +} + +inline infix fun T.shouldNotCall(crossinline block: CallScope.(value: T) -> Unit): T { + this shouldNot haveCalled(block) + return this +} diff --git a/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/SuccessOperatorTests.kt b/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/SuccessOperatorTests.kt new file mode 100644 index 0000000..1410658 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/apiresult/test/SuccessOperatorTests.kt @@ -0,0 +1,244 @@ +package pro.respawn.apiresult.test + +import io.kotest.assertions.fail +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import pro.respawn.apiresult.ApiResult +import pro.respawn.apiresult.apply +import pro.respawn.apiresult.cause +import pro.respawn.apiresult.chain +import pro.respawn.apiresult.errorIf +import pro.respawn.apiresult.errorOnLoading +import pro.respawn.apiresult.errorOnNull +import pro.respawn.apiresult.errorUnless +import pro.respawn.apiresult.exceptionOrNull +import pro.respawn.apiresult.flatMap +import pro.respawn.apiresult.fold +import pro.respawn.apiresult.mapEither +import pro.respawn.apiresult.mapError +import pro.respawn.apiresult.mapErrorToCause +import pro.respawn.apiresult.mapLoading +import pro.respawn.apiresult.mapOrDefault +import pro.respawn.apiresult.message +import pro.respawn.apiresult.nullOnError +import pro.respawn.apiresult.onError +import pro.respawn.apiresult.onLoading +import pro.respawn.apiresult.onSuccess +import pro.respawn.apiresult.or +import pro.respawn.apiresult.orElse +import pro.respawn.apiresult.orNull +import pro.respawn.apiresult.orThrow +import pro.respawn.apiresult.recover +import pro.respawn.apiresult.recoverIf +import pro.respawn.apiresult.require +import pro.respawn.apiresult.requireIs +import pro.respawn.apiresult.requireNotNull +import pro.respawn.apiresult.stackTrace +import pro.respawn.apiresult.tryChain +import pro.respawn.apiresult.tryMap +import pro.respawn.apiresult.tryRecover +import pro.respawn.apiresult.unit +import pro.respawn.apiresult.unwrap + +class SuccessOperatorTests : FreeSpec({ + val value = 42 + val exception = RuntimeException("error") + + "given success value" - { + val result = ApiResult.Success(value) + + "then isSuccess should be true" { + result.isSuccess shouldBe true + } + "then isFailure should be false" { + result.isError shouldBe false + } + "then isLoading should be false" { + result.isLoading shouldBe false + } + "then components should return result and no exception" { + val (res, err) = result + res shouldBe value + err shouldBe null + } + "then bang should not throw" { + shouldNotThrowAny { + !result + } + } + "then stacktrace is null" { + result.stackTrace shouldBe null + } + "then message is null" { + result.message shouldBe null + } + "then cause is null" { + result.cause shouldBe null + } + "Then orElse returns value" { + result.orElse { 0 } shouldBe value + } + "then or returns value" { + result.or(0) shouldBe value + } + "then orNull returns non-null value" { + result.orNull() shouldBe value + } + "then exceptionOrNull returns null" { + result.exceptionOrNull() shouldBe null + } + "then fold returns value" { + result.fold({ it }, { 0 }, { -1 }) shouldBe value + } + "then onError does not call error" { + result.onError { fail("Called onError") } + } + "then onSuccess calls success" { + shouldCall { + result.onSuccess { + it shouldBe value + markCalled() + } + } + } + "then onLoading does not call loading" { + result.onLoading { fail("Called onLoading") } + } + "then nullOnError returns value" { + result.nullOnError().orThrow() shouldBe value + } + "then errorIf returns same value as received" { + result.errorIf { false }.isError shouldBe false + result.errorIf { true }.isError shouldBe true + } + "then errorUnless returns the opposite value" { + result.errorUnless { true }.isError shouldBe false + result.errorUnless { false }.isError shouldBe true + } + "then errorOnLoading does not produce an error" { + result.errorOnLoading().isError shouldBe false + } + "then requireNotNull returns value" { + result.requireNotNull().orThrow() shouldBe value + } + "then map/apply returns new value" { + result.apply { this + 1 }.orThrow() shouldBe value + 1 + } + "then mapOrDefault returns new value" { + result.mapOrDefault({ 0 }) { it + 1 } shouldBe value + 1 + } + "then mapEither returns new value" { + val mappedError = IllegalArgumentException(exception) + result.mapEither({ it + 1 }) { mappedError }.orThrow() shouldBe value + 1 + } + "then mapLoading is not executed" { + result.mapLoading { fail("Called mapLoading") } + } + "then mapError is not executed" { + result.mapError { fail("Called mapError") } + } + "then mapErrorToCause is not executed" { + result.mapErrorToCause().isError shouldBe false + } + "then unwrap returns value" { + val wrapped = ApiResult(result) + wrapped.unwrap() shouldBe result + wrapped.unwrap().require() shouldBe value + } + "then tryMap catches exceptions" { + val e = IllegalArgumentException() + result.tryMap { throw e }.exceptionOrNull() shouldBe e + } + "then recover does not change value" { + result.recover { fail("recovered") } shouldBe result + } + "then tryRecover does not change value" { + result.tryRecover { fail("recovered") } shouldBe result + } + "and given require operator" - { + "and condition is false" - { + val required = result.require { value == 0 } + "then value is error" { + required.isError shouldBe true + } + } + "and condition is true" - { + val required = result.require { value == it } + "then value is success" { + required.isSuccess shouldBe true + } + } + } + "then recoverIf is not Executed" { + result.recoverIf({ true }) { fail("Called recoverIf") } + } + "and a chain operator" - { + "and chained is an error" - { + val other = ApiResult.Error(exception) + "then the result is an error" { + result.chain { other }.isError shouldBe true + } + } + "and chained is a success" - { + val other = ApiResult.Success(0) + "then the value is unchanged" { + result.chain { other }.orThrow() shouldBe value + } + } + "and chained is loading" - { + val other = ApiResult.Loading() + "then the result is loading" { + result.chain { other }.isLoading shouldBe true + } + } + } + "then tryChain catches exceptions" { + val e = IllegalArgumentException() + result.tryChain { throw e }.exceptionOrNull() shouldBe e + } + "and a flatMap operator" - { + "and then is an error" - { + val other = ApiResult.Error(exception) + "then the result is an error" { + result.flatMap { other }.isError shouldBe true + } + } + "and then is a success" - { + val newValue = 0 + val other = ApiResult.Success(newValue) + "then the value is the new one" { + result.flatMap { other }.orThrow() shouldBe newValue + } + } + "and then is loading" - { + val other = ApiResult.Loading() + "then the result is loading" { + result.flatMap { other }.isLoading shouldBe true + } + } + } + "then unit returns Unit" { + result.unit().orThrow() shouldBe Unit + } + "and requireIs is applied" - { + "and cast succeeds" - { + "then the result is success" { + result.requireIs().orThrow() shouldBe value + } + } + "and cast fails" - { + "then the result is error" { + result.requireIs().isError shouldBe true + } + } + } + } + "given a null success value" - { + val result: ApiResult = ApiResult(null) + "then errorOnNull returns error" { + val e = IllegalArgumentException() + result.errorOnNull { e }.exceptionOrNull() shouldBe e + } + } +}) diff --git a/docs/quickstart.md b/docs/quickstart.md index cd96a14..c103e6d 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -175,13 +175,12 @@ interface Repository { } val subscriptions: ApiResult> = ApiResult { - val verificationResult = repo.verifyDevice() - // bang (!) operator throws Errors, equivalent to binding // if bang does not throw, the device is verified - !verificationResult + val verificationResult = !repo.verifyDevice() - val user: User = !userRepository.getUser() // if bang does not throw, user is logged in + // if bang does not throw, user is logged in + val user: User = !userRepository.getUser() !repo.getSubscriptions(user) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8fe4033..ea37256 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] compose = "1.6.10" compose-activity = "1.9.0" -compose-material3 = "1.3.0-beta02" +compose-material3 = "1.3.0-beta04" composeDetektPlugin = "1.3.0" core-ktx = "1.13.1" coroutines = "1.9.0-RC" @@ -9,13 +9,13 @@ dependencyAnalysisPlugin = "1.32.0" detekt = "1.23.6" detektFormattingPlugin = "1.23.6" dokka = "1.9.20" -gradleAndroid = "8.6.0-alpha03" +gradleAndroid = "8.6.0-alpha08" gradleDoctorPlugin = "0.10.0" -kotest = "5.9.0" +kotest = "5.9.1" # @pin kotlin = "2.0.0" kotlinx-atomicfu = "0.23.1" -lifecycle = "2.8.1" +lifecycle = "2.8.2" turbine = "1.0.0" versionCatalogUpdatePlugin = "0.8.4" @@ -59,10 +59,10 @@ unittest = [ [plugins] atomicfu = { id = "kotlinx-atomicfu", version.ref = "kotlinx-atomicfu" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } dependencyAnalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysisPlugin" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } gradleDoctor = { id = "com.osacky.doctor", version.ref = "gradleDoctorPlugin" } kotest = { id = "io.kotest.multiplatform", version.ref = "kotest" } version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdatePlugin" } -compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }