diff --git a/lib/src/main/kotlin/xyz/block/domainapi/util/Controller.kt b/lib/src/main/kotlin/xyz/block/domainapi/util/Controller.kt index a7f7925..f4f62a6 100644 --- a/lib/src/main/kotlin/xyz/block/domainapi/util/Controller.kt +++ b/lib/src/main/kotlin/xyz/block/domainapi/util/Controller.kt @@ -10,13 +10,14 @@ import xyz.block.domainapi.ProcessingState import xyz.block.domainapi.ResultCode /** - * An abstract class that represents a controller in the implementation of a domain API based on a state machine. + * An interface that represents a controller in the implementation of a domain API based on a state machine. * Concrete implementations are responsible for dealing with interactions through the _execute_ and _resume_ endpoints * for a specific state of the process value. */ -abstract class Controller, V : Value, R>( +interface Controller, V : Value, R> { + + /** Implementers must supply the state machine (e.g., via constructor). */ val stateMachine: StateMachine -) { /** * Called when a client calls the execute or resume endpoints. * @@ -29,26 +30,50 @@ abstract class Controller, V : Value, R>( value: V, inputs: List>, operation: Operation - ): Result> = + ): Result> = result { result { handleCancelled(value, inputs).bind() ?: processInputs(value, inputs, operation).bind() - } + }.onFailure { + handleFailure(it, value).bind() + }.bind() + } + /** + * Concrete classes implement this function to process the inputs sent by the client. + * + * @param value The current process value. + * @param inputs Any inputs sent by the client. + * @param operation Indicates which operation was called by the client. + * + * @return The resulting [ProcessingState]. + */ abstract fun processInputs( value: V, inputs: List>, operation: Operation ): Result> + /** + * Called when something goes wrong. + * + * @param failure The failure that happened. + * @param value The current value. + * @return The updated value if failing implies, for example, transitioning to a failed state. + */ + abstract fun handleFailure(failure: Throwable, value: V): Result + + /** + * What to do if the client sends a `CANCELLED` hurdle response. By default, a + * [xyz.block.domainapi.DomainApiError.ProcessWasCancelled] exception is returned. + */ open fun handleCancelled( value: V, requirementResults: List> - ): Result?> = - result { - requirementResults.find { it.result == ResultCode.CANCELLED }?.let { - raise(DomainApiError.ProcessWasCancelled(it.id.toString())) - } + ): Result?> = result { + requirementResults.find { it.result == ResultCode.CANCELLED }?.let { + raise(DomainApiError.ProcessWasCancelled(it.id.toString())) } + } } enum class Operation { CREATE, EXECUTE, RESUME } diff --git a/lib/src/main/kotlin/xyz/block/domainapi/util/InfoCollectionController.kt b/lib/src/main/kotlin/xyz/block/domainapi/util/InfoCollectionController.kt index ab13882..8368834 100644 --- a/lib/src/main/kotlin/xyz/block/domainapi/util/InfoCollectionController.kt +++ b/lib/src/main/kotlin/xyz/block/domainapi/util/InfoCollectionController.kt @@ -1,7 +1,6 @@ package xyz.block.domainapi.util import app.cash.kfsm.State -import app.cash.kfsm.StateMachine import app.cash.kfsm.Value import app.cash.quiver.extensions.failure import app.cash.quiver.extensions.success @@ -16,48 +15,48 @@ import xyz.block.domainapi.UserInteraction import xyz.block.domainapi.UserInteraction.Hurdle @Suppress("TooManyFunctions") -abstract class InfoCollectionController< - ID, - STATE : State, - T : Value, - R ->( - private val pendingCollectionState: STATE, - stateMachine: StateMachine -) : Controller(stateMachine) { +interface InfoCollectionController< + ID, + STATE : State, + T : Value, + R + > : Controller { + + val pendingCollectionState: STATE + override fun processInputs( value: T, inputs: List>, operation: Operation - ): Result> = - result { - processInputsFromOperation(value, inputs, operation).bind() - ?: if (inputs.all { it is Input.HurdleResponse }) { - val hurdleResponses = inputs.map { it as Input.HurdleResponse } - when (value.state) { - pendingCollectionState -> processPendingCollectionState(value, hurdleResponses).bind() - else -> - raise( - IllegalStateException("State should be $pendingCollectionState but was ${value.state}") - ) + ): Result> = result { + processInputsFromOperation(value, inputs, operation).bind() + ?: if (inputs.all { it is Input.HurdleResponse }) { + val hurdleResponses = inputs.map { it as Input.HurdleResponse } + when (value.state) { + pendingCollectionState -> processPendingCollectionState(value, hurdleResponses).bind() + else -> { + val failure = IllegalStateException("State should be $pendingCollectionState but was ${value.state}") + raise(failure) } - } else { - raise(IllegalArgumentException("Inputs should be hurdle responses")) } - } + } else { + val failure = IllegalArgumentException("Inputs should be hurdle responses") + raise(failure) + } + } /** * Provides a mechanism to react differently depending on the operation that invoked this * controller. Does nothing by default. */ - open fun processInputsFromOperation( + fun processInputsFromOperation( value: T, inputs: List>, operation: Operation ): Result?> = Result.success(null) /** When all the requirements are satisfied, this function is called to transition the process to the next state. */ - abstract fun transition(value: T): Result + fun transition(value: T): Result /** * Finds any missing requirements for this controller by inspecting the process value. @@ -65,7 +64,7 @@ abstract class InfoCollectionController< * @param value The process value to inspect. * @return A set of missing requirements. */ - abstract fun findMissingRequirements(value: T): Result> + fun findMissingRequirements(value: T): Result> /** * Updates the process value with the result of a requirement. The requirement result might just @@ -75,13 +74,10 @@ abstract class InfoCollectionController< * @param hurdleResponse The response to a hurdle sent by the server. * @return The updated process value. */ - abstract fun updateValue( - value: T, - hurdleResponse: Input.HurdleResponse - ): Result + fun updateValue(value: T, hurdleResponse: Input.HurdleResponse): Result /** Called if the process is cancelled. */ - abstract fun onCancel(value: T): Result + fun onCancel(value: T): Result /** * Returns a hurdle for the given requirement ID. If previous hurdles are modified then new @@ -93,24 +89,12 @@ abstract class InfoCollectionController< * @return A list of hurdles that contains the hurdle for the given requirement ID, if one was * generated, and any previous hurdles that were modified. */ - abstract fun getHurdlesForRequirementId( + fun getHurdlesForRequirementId( requirementId: R, value: T, previousHurdles: List> ): Result>> - /** - * Called when something goes wrong and gets passed in the most recent version of the process. - * - * @param failure The failure that happened. - * @param value The current value. - * @return The updated value if failing implies, for example, transitioning to a failed state. - */ - abstract fun fail( - failure: Throwable, - value: T - ): Result - /** * The info collection controller might need to end by showing a notification. For example, if the * user is shown a scam warning, and they decide to cancel the withdrawal, then the system might @@ -120,25 +104,40 @@ abstract class InfoCollectionController< * @param value The process value. * @return A final hurdle for the given process value, if required. */ - open fun getFinalNotification(value: T): Result?> = null.success() + fun getFinalNotification(value: T): Result?> = null.success() @Suppress("CognitiveComplexMethod") private fun processPendingCollectionState( value: T, - hurdleResponses: List> - ): Result> = - result { - // Otherwise we need to check the hurdle results to see if everything that is required has been sent to us - // But first we need to check if the process was cancelled - in this case there is no need to continue - // processing - checkCancelled(value, hurdleResponses).bind() - - // Find any requirements that are missing to be able to transition to the next state - val missingRequirements = findMissingRequirements(value).bind() - - // If all requirements are complete then we call the onComplete function - if (missingRequirements.isEmpty()) { - val updatedValue = transition(value).bind() + hurdleResponses: List>, + ): Result> = result { + // Otherwise we need to check the hurdle results to see if everything that is required has been sent to us + // But first we need to check if the process was cancelled - in this case there is no need to continue + // processing + checkCancelled(value, hurdleResponses).bind() + + // Find any requirements that are missing to be able to transition to the next state + val missingRequirements = findMissingRequirements(value).bind() + + // If all requirements are complete then we call the onComplete function + if (missingRequirements.isEmpty()) { + val updatedValue = transition(value).bind() + val finalNotification = getFinalNotification(updatedValue).bind() + if (finalNotification != null) { + ProcessingState.UserInteractions(listOf(finalNotification), null) + } else { + ProcessingState.Complete(updatedValue) + } + } else { + // Updating results and, if complete, transitioning to the finished collection state should be done in a single + // transaction + val (updatedMissingRequirements, updatedValue) = + processResultsAndMaybeTransition( + value, + hurdleResponses, + missingRequirements, + ).bind() + if (updatedMissingRequirements.isEmpty()) { val finalNotification = getFinalNotification(updatedValue).bind() if (finalNotification != null) { ProcessingState.UserInteractions(listOf(finalNotification), null) @@ -146,64 +145,39 @@ abstract class InfoCollectionController< ProcessingState.Complete(updatedValue) } } else { - // Updating results and, if complete, transitioning to the finished collection state should be done in a single - // transaction - val (updatedMissingRequirements, updatedValue) = - processResultsAndMaybeTransition( - value, - hurdleResponses, - missingRequirements - ).bind() - if (updatedMissingRequirements.isEmpty()) { - val finalNotification = getFinalNotification(updatedValue).bind() - if (finalNotification != null) { - ProcessingState.UserInteractions(listOf(finalNotification), null) + // This is likely to involve RPC calls so it's done outside the transaction + val hurdles = updatedMissingRequirements.fold(emptyList>()) { + acc, + requirementId + -> + val newHurdles = getHurdlesForRequirementId(requirementId, updatedValue, acc).bind() + mergeHurdles(acc, newHurdles) + } + ProcessingState.UserInteractions( + hurdles = hurdles, + nextEndpoint = if (hurdles.any { requiresSecureEndpoint(it.id) }) { + DomainApi.Endpoint.SECURE_EXECUTE } else { - ProcessingState.Complete(updatedValue) + DomainApi.Endpoint.EXECUTE } - } else { - // This is likely to involve RPC calls so it's done outside the transaction - val hurdles = - updatedMissingRequirements.fold(emptyList>()) { - acc, - requirementId - -> - val newHurdles = getHurdlesForRequirementId(requirementId, updatedValue, acc).bind() - mergeHurdles(acc, newHurdles) - } - ProcessingState.UserInteractions( - hurdles = hurdles, - nextEndpoint = - if (hurdles.any { requiresSecureEndpoint(it.id) }) { - DomainApi.Endpoint.SECURE_EXECUTE - } else { - DomainApi.Endpoint.EXECUTE - } - ) - } + ) } } + } - private fun mergeHurdles( - current: List>, - updates: List> - ): List> { + private fun mergeHurdles(current: List>, updates: List>): List> { val updateMap = updates.associateBy { it.id } - val updatedCurrent = - current.map { hurdle -> - updateMap[hurdle.id] ?: hurdle - } + val updatedCurrent = current.map { hurdle -> + updateMap[hurdle.id] ?: hurdle + } val currentIds = current.map { it.id }.toSet() val newHurdles = updates.filterNot { it.id in currentIds } return updatedCurrent + newHurdles } - abstract fun requiresSecureEndpoint(requirement: R): Boolean + fun requiresSecureEndpoint(requirement: R): Boolean - open fun goBack( - value: T, - hurdleResponse: Input.HurdleResponse - ): Result = + fun goBack(value: T, hurdleResponse: Input.HurdleResponse): Result = UnsupportedHurdleResultCode( value.id.toString(), ResultCode.BACK @@ -212,7 +186,7 @@ abstract class InfoCollectionController< private fun processResultsAndMaybeTransition( value: T, hurdleResponses: List>, - missingRequirements: List + missingRequirements: List, ) = result { val (_, updatedValue) = processHurdleResponses(value, hurdleResponses, missingRequirements).bind() @@ -227,28 +201,24 @@ abstract class InfoCollectionController< } /** Checks if the process was canceled and calls the onCancel function. */ - private fun checkCancelled( - value: T, - requirementResults: List> - ): Result = - result { - requirementResults - .find { it.result == ResultCode.CANCELLED } - ?.let { - onCancel(value).bind() - raise(DomainApiError.ProcessWasCancelled(it.id.toString())) - } - } + private fun checkCancelled(value: T, requirementResults: List>): Result = result { + requirementResults + .find { it.result == ResultCode.CANCELLED } + ?.let { + onCancel(value).bind() + raise(DomainApiError.ProcessWasCancelled(it.id.toString())) + } + } private fun processHurdleResponses( value: T, hurdleResponses: List>, - missingRequirements: List + missingRequirements: List, ) = result { hurdleResponses .fold(Pair(missingRequirements, value).success()) { - accumulator: Result, T>>, - hurdleResponse + accumulator: Result, T>>, + hurdleResponse -> // These are the current requirements that are still missing val (currentMissingRequirements, currentValue) = accumulator.bind() @@ -256,29 +226,26 @@ abstract class InfoCollectionController< // If the hurdle result is in the missing set then we process it if (currentMissingRequirements.contains(hurdleResponse.id)) { // Process the requirement response - val updatedValue = - when (hurdleResponse.result) { - ResultCode.CLEARED, ResultCode.FAILED -> updateValue(currentValue, hurdleResponse) - ResultCode.SKIPPED -> currentValue.success() - ResultCode.BACK -> goBack(value, hurdleResponse) - else -> - IllegalArgumentException( - "Unexpected hurdle response code ${hurdleResponse.result}" - ).failure() - }.onFailure { fail(it, currentValue).bind() }.bind() + val updatedValue = when (hurdleResponse.result) { + ResultCode.CLEARED, ResultCode.FAILED -> updateValue(currentValue, hurdleResponse) + ResultCode.SKIPPED -> currentValue.success() + ResultCode.BACK -> goBack(value, hurdleResponse) + else -> IllegalArgumentException( + "Unexpected hurdle response code ${hurdleResponse.result}" + ).failure() + }.bind() // Once the result has been processed successfully then we remove from the set of missing requirements Result.success(Pair(currentMissingRequirements - hurdleResponse.id, updatedValue)) } else { // If it is not we fail because we want to be strict about the results we accept - val failure = - DomainApiError.InvalidRequirementResult( - value.id.toString(), - hurdleResponse.id.toString() - ) - fail(failure, currentValue).bind() + val failure = DomainApiError.InvalidRequirementResult( + value.id.toString(), + hurdleResponse.id.toString() + ) raise(failure) } - }.bind() + } + .bind() } } diff --git a/lib/src/test/kotlin/xyz/block/domainapi/util/FinalController.kt b/lib/src/test/kotlin/xyz/block/domainapi/util/FinalController.kt index 0b3a334..d609720 100644 --- a/lib/src/test/kotlin/xyz/block/domainapi/util/FinalController.kt +++ b/lib/src/test/kotlin/xyz/block/domainapi/util/FinalController.kt @@ -1,13 +1,14 @@ package xyz.block.domainapi.util import app.cash.kfsm.StateMachine +import app.cash.quiver.extensions.failure import arrow.core.raise.result import xyz.block.domainapi.Input import xyz.block.domainapi.ProcessingState class FinalController( - stateMachine: StateMachine -) : Controller(stateMachine) { + override val stateMachine: StateMachine +) : Controller { override fun processInputs( value: TestValue, inputs: List>, @@ -22,4 +23,6 @@ class FinalController( else -> raise(IllegalStateException("Invalid state ${value.state}")) } } + + override fun handleFailure(failure: Throwable, value: TestValue): Result = failure.failure() } diff --git a/lib/src/test/kotlin/xyz/block/domainapi/util/TestController.kt b/lib/src/test/kotlin/xyz/block/domainapi/util/TestController.kt index 2686f1c..7f4b95c 100644 --- a/lib/src/test/kotlin/xyz/block/domainapi/util/TestController.kt +++ b/lib/src/test/kotlin/xyz/block/domainapi/util/TestController.kt @@ -7,11 +7,10 @@ import xyz.block.domainapi.Input import xyz.block.domainapi.UserInteraction class TestController( - stateMachine: StateMachine -) : InfoCollectionController( - pendingCollectionState = Initial, - stateMachine = stateMachine - ) { + override val stateMachine: StateMachine +) : InfoCollectionController { + override val pendingCollectionState = Initial + override fun findMissingRequirements(value: TestValue) = when (value.state) { is Initial -> listOf(TestRequirement.REQ1, TestRequirement.REQ2).success() @@ -38,14 +37,6 @@ class TestController( override fun requiresSecureEndpoint(requirement: TestRequirement): Boolean = false - override fun fail( - failure: Throwable, - value: TestValue - ): Result = - result { - value - } - override fun transition(value: TestValue): Result = result { when (value.state) { @@ -56,4 +47,8 @@ class TestController( else -> raise(IllegalStateException("Invalid state ${value.state}")) } } + + override fun handleFailure(failure: Throwable, value: TestValue): Result = result { + value + } }