From b616c426061a1b6c0ba598a3de0a88fd2e8afa1f Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Sat, 26 Aug 2023 12:40:53 +0300 Subject: [PATCH] write new documentation --- README.md | 272 +++++++++------------------------------------ docs/quickstart.md | 229 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index 83dec4e..12f53e4 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,70 @@ -# FlowMVI 2.0 - -[![CI](/~https://github.com/respawn-app/FlowMVI/actions/workflows/ci.yml/badge.svg)](/~https://github.com/respawn-app/FlowMVI/actions/workflows/ci.yml) -![License](https://img.shields.io/github/license/respawn-app/flowMVI) -![GitHub last commit](https://img.shields.io/github/last-commit/respawn-app/FlowMVI) -![Issues](https://img.shields.io/github/issues/respawn-app/FlowMVI) -![GitHub top language](https://img.shields.io/github/languages/top/respawn-app/flowMVI) -[![CodeFactor](https://www.codefactor.io/repository/github/respawn-app/flowMVI/badge)](https://www.codefactor.io/repository/github/respawn-app/flowMVI) +# ApiResult + +[![CI](/~https://github.com/respawn-app/FlowMVI/actions/workflows/ci.yml/badge.svg)](/~https://github.com/respawn-app/ApiResult/actions/workflows/ci.yml) +![License](https://img.shields.io/github/license/respawn-app/ApiResult) +![GitHub last commit](https://img.shields.io/github/last-commit/respawn-app/ApiResult) +![Issues](https://img.shields.io/github/issues/respawn-app/ApiResult) +![GitHub top language](https://img.shields.io/github/languages/top/respawn-app/ApiResult) +[![CodeFactor](https://www.codefactor.io/repository/github/respawn-app/flowMVI/badge)](https://www.codefactor.io/repository/github/respawn-app/ApiResult) [![AndroidWeekly #556](https://androidweekly.net/issues/issue-556/badge)](https://androidweekly.net/issues/issue-556/) -FlowMVI is a Kotlin Multiplatform MVI library based on coroutines that has a few main goals: +ApiResult is a Kotlin Multiplatform declarative error handling framework that is performant, easy to use and +feature-rich. + +ApiResult is [Railway Programming](https://blog.logrocket.com/what-is-railway-oriented-programming/) and functional +error handling **on steroids**. + +## Features + +* ApiResult is **extremely lightweight**. It is lighter than kotlin.Result. + All instances of it are `value class`es, all operations are `inline`, which means literally 0 overhead. +* ApiResult offers 85+ 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 a result of a computation + without being forced to handle errors at compilation time. + +## Preview -1. Being simple to understand and use while staying powerful and flexible. -2. Featuring a clean and rich DSL. -3. Being thread-safe but asynchronous by design. +```kotlin + +// wrap a result of a computation +suspend fun getSubscriptions(userId: String): ApiResult?> = ApiResult { + api.getSubscriptions(userId) +} + +// use and transform the result +val state: SubscriptionState = repo.getSubscriptions(userId) + .errorOnNull() // map nulls to error states with compile-time safety + .recover { emptyList() } // recover from some or all errors + .require { securityRepository.isDeviceTrusted() } // conditionally fail the chain + .mapValues(::SubscriptionModel) // map list items + .filter { it.isPurchased } // filter values + .mapError { e -> BillingException(cause = e) } // map exceptions + .then { validateSubscriptions(it) } // execute a computation and continue with its result, propagating errors + .chain { updateGracePeriod(it) } // execute another computation, and if it fails, stop the chain + .onError { subscriptionService.disconnect() } // executed on error + .onEmpty { return SubscriptionState.NotSubscribed } // use non-local returns and short-circuit evaluation + .fold( + onSuccess = { SubscriptionState.Subscribed(it) }, + onError = { SubscriptionState.Error(it) }, + ) // unwrap the result to another value +``` -## Quickstart: +## Quickstart * Documentation: - [![Docs](https://img.shields.io/website?down_color=red&down_message=Offline&label=Docs&up_color=green&up_message=Online&url=https%3A%2F%2Fopensource.respawn.pro%2FFlowMVI%2F%23%2F)](https://opensource.respawn.pro/FlowMVI/#/) + [![Docs](https://img.shields.io/website?down_color=red&down_message=Offline&label=Docs&up_color=green&up_message=Online&url=https%3A%2F%2Fopensource.respawn.pro%2FFlowMVI%2F%23%2F)](https://opensource.respawn.pro/ApiResult) * KDoc: - [![Javadoc](https://javadoc.io/badge2/pro.respawn.flowmvi/core/javadoc.svg)](https://opensource.respawn.pro/FlowMVI/javadocs) + [![Javadoc](https://javadoc.io/badge2/pro.respawn.flowmvi/core/javadoc.svg)](https://opensource.respawn.pro/ApiResult/javadocs) * Latest version: - ![Maven Central](https://img.shields.io/maven-central/v/pro.respawn.flowmvi/core?label=Maven%20Central) + ![Maven Central](https://img.shields.io/maven-central/v/pro.respawn.apiresult/core?label=Maven%20Central) ```toml [versions] -flowmvi = "< Badge above 👆🏻 >" +apiresult = "< Badge above 👆🏻 >" [dependencies] -flowmvi-core = { module = "pro.respawn.flowmvi:core", version.ref = "flowmvi" } # multiplatform -flowmvi-android = { module = "pro.respawn.flowmvi:android", version.ref = "flowmvi" } # common android -flowmvi-view = { module = "pro.respawn.flowmvi:android-view", version.ref = "flowmvi" } # view-based android -flowmvi-compose = { module = "pro.respawn.flowmvi:android-compose", version.ref = "flowmvi" } # compose -flowmvi-test = { module = "pro.respawn.flowmvi:test", version.ref = "flowmvi" } # test DSL +apiresult = { module = "pro.respawn.apiresult:core", version.ref = "apiresult" } ``` Supported platforms: @@ -44,202 +76,6 @@ Supported platforms: ### Feature overview: -Rich, plugin-based store DSL: - -```kotlin -sealed interface CounterState : MVIState { - data object Loading : CounterState - data class Error(e: Exception) : CounterState - data class DisplayingCounter( - val timer: Int, - val counter: Int, - ) : CounterState -} - -sealed interface CounterIntent : MVIIntent { - data object ClickedCounter : CounterIntent -} - -sealed interface CounterAction : MVIAction { - data class ShowMessage(val message: String) : CounterAction -} - -class CounterContainer( - private val repo: CounterRepository, -) { - val store = store(Loading) { // set initial state - name = "CounterStore" - parallelIntents = true - actionShareBehavior = ActionShareBehavior.Restrict() // disable, share, distribute or consume side effects - intentCapacity = 64 - - install(platformLoggingPlugin()) // log to console, logcat or NSLog - - install(analyticsPlugin(name)) // install custom plugins - - install(timeTravelPlugin()) // unit test stores and track changes - - saveState { // persist and restore state - get = { repo.restoreStateFromFile() } - set = { repo.saveStateToFile(this) } - } - - val undoRedoPlugin = undoRedo(maxQueueSize = 10) // undo and redo any changes - - val jobManager = manageJobs() // manage named jobs - - init { // run actions when store is launched - repo.startTimer() - } - - whileSubscribed { // run a job while any subscribers are present - repo.timer.onEach { timer: Int -> - updateState { // update state safely between threads and filter by type - copy(timer = timer) - } - }.consume() - } - - recover { e: Exception -> // recover from errors both in jobs and plugins - send(CounterAction.ShowMessage(e.message)) // send side-effects - null - } - - reduce { intent: CounterIntent -> // reduce intents - when (intent) { - is ClickedCounter -> updateState { - copy(counter = counter + 1) - } - } - } - - install { // build and install custom plugins on the fly - - onStop { // hook into various store events - repo.stopTimer() - } - - onState { old, new -> // veto changes, modify states, launch jobs, do literally anything - new.withType { - if (counter >= 100) { - launch { repo.resetTimer() }.register(jobManager, "reset") - copy(counter = 0, timer = 0) - } else new - } - } - } - } -} -``` - -Subscribe one-liner: - -```kotlin -store.subscribe( - scope = consumerCoroutineScope, - consume = { action -> /* process side effects */ }, - render = { state -> /* render states */ }, -) -``` - -Custom plugins: - -```kotlin -// create plugins for any store -fun analyticsPlugin(name: String) = genericPlugin { - val analytics = Analytics.getInstance() - onStart { - analytics.log("Screen $name opened") - } - onIntent { - analytics.log(it.asAnalyticsEvent()) - } - // 5+ more hooks - } - -// or for a specific one -val counterPlugin = plugin { - /*...*/ -} -``` - -### Android (Compose): - -```kotlin -val module = module { - factoryOf(::CounterContainer) - - // No more subclassing. Use StoreViewModel for everything and inject containers or stores directly. - viewModel(qualifier()) { StoreViewModel(get().store) } -} - -// collect the store efficiently based on composable's lifecycle -@Composable -fun CounterScreen() = MVIComposable( - store = getViewModel>(qualifier()), -) { state -> // this -> ConsumerScope with send(Intent) - - consume { action -> // consume actions from composables - when (action) { - is ShowMessage -> { - /* ... */ - } - } - } - - when (state) { - is DisplayingCounter -> { - Button(onClick = { intent(ClickedCounter) }) { - Text("Counter: ${state.counter}") - } - } - } -} -``` - -### Android (View): - -```kotlin -class ScreenFragment : Fragment(), MVIView { - - override val container by viewModel(qualifier()) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - subscribe() // One-liner for store subscription. Lifecycle-aware and efficient. - } - - override fun render(state: CounterState) { - // update your views - } - - override fun consume(action: CounterAction) { - // handle actions - } -} -``` - -### Testing DSL - -```kotlin -// using Turbine + Kotest -testStore().subscribeAndTest { - - ClickedCounter resultsIn { - - states.test { - awaitItem() shouldBe DisplayingCounter(counter = 1, timer = 0) - } - actions.test { - awaitItem().shouldBeTypeOf() - } - - } - -} -``` - Ready to try? Start with reading the [Quickstart Guide](https://opensource.respawn.pro/FlowMVI/#/quickstart). ## License diff --git a/docs/quickstart.md b/docs/quickstart.md index e69de29..6c6d0d0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -0,0 +1,229 @@ +# Get started with ApiResult + +Browse +code: [ApiResult](/~https://github.com/respawn-app/kmmutils/tree/master/apiresult/src/commonMain/kotlin/pro/respawn/kmmutils/apiresult) + +ApiResult is a class that wraps the result of a computation. +Similar to monads, it has 2 main and 1 additional state: + +* `Success` - contains a value returned by a computation +* `Error` - wraps an exception caught during a computation +* `Loading` - intermediate and optional state for async operations + +## Usage + +Example usages cover three main cases: + +* Wrapping a result of a computation +* Wrapping a result of an async computation with multiple coroutines +* Turning a computation into a flow + +```kotlin +// wrap a result of a computation +suspend fun getSubscriptions(userId: String): ApiResult> = ApiResult { + api.getSubscriptions(userId) + } + +// emits: Loading -> Success / Error +fun getSubscriptionsAsync(userId: String): Flow>> = ApiResult.flow { + api.getSubscriptions(id) +} + +// SuspendResult will wait for the result of nested coroutines and propagate exceptions thrown in them +suspend fun getVerifiedSubs(userId: String) = SuspendResult { // this: CoroutineScope + val subs = api.getSubscriptions(userId) + + launch { + api.verifySubscriptions(subs) + } + launch { + storage.saveSubsscriptions(subs) + } + + subs +} +``` + +After you create your ApiResult, apply a variety of transformations on it: + +```kotlin +val state: SubscriptionState = repo.getSubscriptions(userId) + .errorOnNull() // map nulls to error states with compile-time safety + .recover { emptyList() } // recover from some or all errors + .require { securityRepository.isDeviceTrusted() } // conditionally fail the chain + .mapValues(::SubscriptionModel) // map list items + .filter { it.isPurchased } // filter values + .mapError { e -> BillingException(cause = e) } // map exceptions + .then { validateSubscriptions(it) } // execute a computation and continue with its result, propagating errors + .chain { updateGracePeriod(it) } // execute another computation, and if it fails, stop the chain + .onError { subscriptionService.disconnect() } // executed on error + .onEmpty { return SubscriptionState.NotSubscribed } // use non-local returns and short-circuit evaluation + .fold( + onSuccess = { SubscriptionState.Subscribed(it) }, + onError = { SubscriptionState.Error(it) }, + ) // unwrap the result to another value +``` + +## Operators + +There are more than 85 operators covering possible use cases for transforming, wrapping, handling, reducing the result +for collections as well as coroutines. + +### Create: + +* `ApiResult { computation() } ` - wrap the result of a computation +* `ApiResult.flow { computation() }` - produce a flow +* `ApiResult(value) ` - either Error or Success based on the type of the value +* `runResulting { computation() }` - for parity with `runCatching` +* `ApiResult()` - start with an `ApiResult` and then run mapping operators such as `then`. Useful when you want to + check some conditions or evaluate other properties before executing an expensive operation. + +### Fold results: + +* `val (result, error) = ApiResult { ... }` - disassemble the result into two nullable types: `T?` of success, + and `Exception?` of error +* `or(value)` - returns `value` if the result is an Error +* `orElse { computeValue() }` - returns the result of `computeValue()` +* `orNull()` - return `null` if the result is an `Error` +* `exceptionOrNull()` - if the result is an error, returns the exception and discards the result +* `orThrow()` - throw `Error`s. This is the same as the binding operator from functional programming +* `fold(onSuccess = { /* ... */ }, onError = { /* ... */ })` - fold the result to another type +* `onSuccess { computation(it) }` - execute an operation if the result is a `Success`. `it` is the result value +* `onError { e -> e.fallbackValue }` - execute `onError` if exception is of type `CustomException` +* `onLoading { setLoading(true) }` - execute an operation if the result is `Loading` +* `!` (bang) operator: + ```kotlin + val result: ApiResult = ApiResult(1) + val value: Int = !result + ``` + Get the result or throw if it is an `Error`. It is the same as Kotlin's `!!` or calling `orThrow()`. + +### Transform: + +* `unwrap()` - sometimes you get into a situation where you have `ApiResult>`. Fix using this operator. +* `then { anotherCall(it) }` or `flatMap()` - execute another ApiResult call and continue with its result type +* `chain { anotherCall(it) }` - execute another ApiResult call, + but discard it's Success result and continue with the previous result +* `map { it.transform() }` - map `Success` result values to another type +* `tryMap { it.transformOrThrow() } ` - map, but catch exceptions in the `transform` block. This is the same as `then`, + but for calls that do not return an `ApiResult` +* `mapError { e -> CustomException(cause = e) } ` - map `Error` values to another exception type +* `mapLoading { null }` - map `Loading` values +* `mapEither(success = { it.toModel() }, error { e -> CustomException() } )` - map both `Success` and `Error` results. + Loading is not affected and is handled by other operators like `errorOnLoading()` +* `mapOrDefault(default = { null }, { it.toModel() } )` - map the value and return it if the result is `Success`. If + not, just return `default` +* `errorIf { it.isInvalid }` - error if the predicate is true +* `errorUnless { it.isAuthorized }` - make this result an `Error` if a condition is false +* `errorOnNull()` - make this result an `Error` if the success value is null +* `errorOnLoading()` - turns this result to an `Error` if it is `Loading`. Some operators do this under the hood +* `require()` - throws if the result is an `Error` or `Loading` and always returns `Success` +* `require { condition() } ` - aliases for `errorUnless` +* `requireNotNull()` - require that the `Success` value is non-null +* `nullOnError()` - returns `Success` if the result is an error +* `recover { e -> e.defaultValue }` - recover from all exceptions +* `recover { e -> e.defaultValue }` - recover from a specific exception type +* `recoverIf(condition = { it.isRecoverable }, block = { null })` +* `tryRecover { it.defaultValueOrThrow() } ` - recover, but if the `block` throws, wrap the exception and continue +* `tryChain { anotherCallOrThrow(it) } ` - chain another call that can throw and wrap the error if it does. Useful when + the call that you are trying to chain to does not return an `ApiResult` already, unlike what `chain` expects +* `mapErrorToCause()` - map errors to their causes, if present, and if not, return that same exception + +### Collection operators: + +* `mapValues { item -> item.transform() } ` - map collection values of the `Success` result +* `onEmpty { block() } ` - execute an operation if the result is `Success` and the collection is empty +* `orEmpty()` - return an empty collection if this result is an `Error` or `Loading` +* `errorIfEmpty()` - make this result an error if the collection is empty. +* `mapResults { it.toModel() }` - map all successful results of a collection of results +* `mapErrors { e -> CustomException(e) }` - map all errors of a collection of results +* `filter { it.isValid }` - filter each value of a collection of a successful result +* `filterErrors()` - return a list that contains only `Error` results +* `filterSuccesses()` - return a list that contains only `Success` results +* `filterNotNull()` - return a collection where each `Success` result is returned only if its value is not null +* `merge()` - merges all `Success` results into a list, or if any failed, returns `Error` +* `merge(vararg results: ApiResult)` - same as `merge`, but for arbitrary parameters +* `values()` - return a list of values from `Success` results, discarding `Error` or `Loading` +* `firstSuccess()` - return a first `Success` value from a collection of results, or if not present, an `Error` +* `firstSuccessOrNull()` - return the first success value or null if not present +* `firstSuccessOrThrow()` - return the first success value or throws if not present + +### Coroutine operators + +* `SuspendResult { }` - an `ApiResult` builder that takes a suspending `block` and allows to launch coroutines + inside and handle exceptions in any of them. +* `ApiResult.flow { suspendingCall() } ` - emits `Loading` first, then executes `call` and emits its result +* `Flow.asApiResult()` - transforms this flow into another, where the `Loading` value is emitted first, and then + a `Success` or `Error` value is emitted based on exceptions that are thrown in the flow's scope. The resulting flow + will **not** throw **unless** other operators are applied on top of it that can throw (i.e. `onEach`) +* `mapResults { it.transform() }` - maps `Success` value of a flow of results +* `rethrowCancellation()` - an operator that is used when a `CancellationException` may have accidentally been + wrapped. `ApiResult` does not wrap cancellation exceptions, but other code can. Cancellation exceptions should not be + wrapped +* `onEachResult { action() }` - execute `action` on each successful result of the flow + +### Monad comprehensions + +Monad comprehensions are when you "bind" multiple results during a certain operation, +and if any of the bound expressions fails, computation is halted and an error is returned. + +Monad comprehensions are simply not needed with ApiResult. The same can be achieved using existing operators: + +```kotlin +interface Repository { + fun getUser(): ApiResult + fun getSubscriptions(user: User): ApiResult> + fun verifyDevice(): ApiResult +} + +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 user: User = !userRepository.getUser() // if bang does not throw, user is logged in + + !repo.getSubscriptions(user) +} +``` + +## Notes and usage advice + +* ApiResult is **not** an async scheduling engine like Rx. + As soon as you call an operator on the result, it is executed. So pay attention to the order of operators that you + apply on your results. If needed, start with `ApiResult` empty constructor and then use `tryMap` or `then` to delay + the execution until you run other operators. +* ApiResult does **not** catch `Throwable`s. This was a purposeful decision. We want to only catch exceptions that can + be handled. Most `Error`s can not be handled effectively by the application. +* ApiResult does **not** catch `CancellationException`s as they are not meant to be caught at all. + In case you think you might have wrapped a `CancellationException` in your result, + use `rethrowCancellation()` at the end of the chain. +* Same as `kotlin.Result`, ApiResult is not meant to be passed around to the UI layer. + Be sure not to propagate results everywhere in your code, and handle them on the layer responsible for error handling. + +## How does ApiResult differ from other wrappers? + +* `kotlin.Result` is an existing solution for result wrapping, + however, it's far less performant, less type safe and, most importantly, doesn't offer the declarative api as rich as + ApiResult. You could call ApiResult a successor to `kotlin.Result`. +* [kotlin-result](/~https://github.com/michaelbull/kotlin-result/) is similar to ApiResult and is multiplatform, however + its api is not as rich as ApiResult's and the author decided to not limit the Error type to be a child of `Exception`. + If you don't like that ApiResult uses Exceptions, you may use kotlin-result. In our opinion, not using exceptions is a + drawback as they are a powerful, existing, widespread language feature. Having `Exception` as a parent + does not limit what you can do with `ApiResult` but saves you from extra type and operator overhead while enabling + better compatibility with existing language and library features. +* ApiResult serves a different purpose than [Sandwich](/~https://github.com/skydoves/sandwich). + Sandwich specializes in integration with Retrofit and, therefore, is not multiplatform. + ApiResult allows you to wrap any computation, be it Ktor, Retrofit, or a database call. ApiResult is more lightweight + and extensible, because it does not hardcode error handling logic. A simple extension on an ApiResult that + uses `mapErrors` will allow you to transform exceptions to your own error types. +* ApiResult is different from [EitherNet](/~https://github.com/slackhq/EitherNet) because once again - + it doesn't hardcode your error types. ApiResult is multiplatform and lightweight: + no crazy mappings that use reflection to save you from writing 0.5 lines of code to wrap a call in an ApiResult. +* ApiResult is a lighter version of Arrow.kt Monads such as Either. Sometimes you want a monad to wrap your + computations, but don't want to introduce the full complexity and intricacies of Arrow and functional programming. + ApiResult also utilizes existing Exception support in Kotlin instead of trying to wrap any type as an error type. You + can still use sealed interfaces and other features if you subclass Exception from that interface. + ApiResult is easier to understand and use, although less powerful than Arrow.