From 6624901340147a4b1e9a1a9d392208dbd14a2bc7 Mon Sep 17 00:00:00 2001 From: Jeremy Mawson Date: Mon, 15 Apr 2024 10:04:55 +1000 Subject: [PATCH] update readme for new api --- README.md | 227 +++++++++--------- lib/api/lib.api | 2 +- .../main/kotlin/app/cash/kfsm/Transitioner.kt | 2 +- .../cash/kfsm/exemplar/HamsterTransitioner.kt | 31 +++ .../cash/kfsm/exemplar/HamsterTransitions.kt | 1 + ...tDayTest.kt => PenelopesPerfectDayTest.kt} | 74 ++---- 6 files changed, 165 insertions(+), 172 deletions(-) create mode 100644 lib/src/test/kotlin/app/cash/kfsm/exemplar/HamsterTransitioner.kt rename lib/src/test/kotlin/app/cash/kfsm/exemplar/{PenelopePerfectDayTest.kt => PenelopesPerfectDayTest.kt} (60%) diff --git a/README.md b/README.md index d85aa99..40c7fc4 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,153 @@ -KFSM is Finite State Machinery for Kotlin. +kFSM is Finite State Machinery for Kotlin. [](https://central.sonatype.com/namespace/app.cash.kfsm) - ## How to use -See [lib/src/test/kotlin/com/squareup/cash/kfsm/exemplar](https://github.com/cashapp/kfsm/tree/main/lib/src/test/kotlin/com/squareup/cash/kfsm/exemplar) -for an example of how to use this library. +There are four key components to building your state machine. -In a KFSM state machine, the states are defined as classes that extend `State` and declare the states that can be -transitioned to. Transitions are defined as instances of `Transition` and encapsulate the effect of transitioning. +1. The nodes representing different states in the machine - `State` +2. The type to be transitioned through the machine - `Value` +3. The effects that are defined by transitioning from one state to the next - `Transition` +4. A transitioner, which can be customised when you need to define pre and post transition hooks - `Transitioner` -Take this state machine, for example: +Let's build a state machine for a traffic light. ```mermaid ---- -title: Hamster States of Being ---- stateDiagram-v2 - [*] --> Asleep - Asleep --> Awake - Awake --> Eating - Eating --> Asleep - Eating --> Resting - Eating --> RunningOnWheel - Resting --> Asleep - RunningOnWheel --> Asleep - RunningOnWheel --> Resting + [*] --> Green + Amber --> Red + Green --> Amber + Red --> Green ``` -### The states +### State -Your state machine is defined as a set of sealed objects extending State. Each State must declare zero or more other -states that it can transition to. +The states are a collection of related classes that define a distinct state that the value can be in. They also define +which states are valid next states. ```kotlin -/** - * Base class for all the states a Hamster can embody. - */ -sealed class HamsterState(vararg to: HamsterState) : State(to.toSet()) - -/** Hamster is awake... and hungry! */ -object Awake : HamsterState(Eating) - -/** Hamster is eating ... what will they do next? */ -object Eating : HamsterState(RunningOnWheel, Asleep, Resting) - -/** Wheeeeeee! */ -object RunningOnWheel : HamsterState(Asleep, Resting) - -/** Sits in the corner, chilling */ -object Resting : HamsterState(Asleep) - -/** Zzzzzzzzz */ -object Asleep : HamsterState(Awake) +sealed class Color(to: () -> Set) : app.cash.kfsm.State(to) +data object Green : Color({ setOf(Amber) }) +data object Amber : Color({ setOf(Red) }) +data object Red : Color({ setOf(Green) }) ``` -#### Test your state machine +### Value -The utility `StateMachine.verify` will assert that a defined state machine is valid - i.e. that all states are visited -from a given starting state. +The value is responsible for knowing and updating its current state. ```kotlin -StateMachine.verify(Awake) shouldBeRight true +data class Light(override val state: Color) : Value { + override fun update(newState: Color): Light = this.copy(state = newState) +} ``` -#### Document your state machine +### Transition -The utility `StateMachine.mermaid` will generate a mermaid diagram of your state machine. This can be rendered in markdown. -The diagram of HamsterStates above was created using this utility. +Types that provide the required side-effects that define a transition in the machine. ```kotlin -StateMachine.mermaid(Asleep) shouldBeRight """stateDiagram-v2 - | [*] --> Asleep - | Asleep --> Awake - | Awake --> Eating - | Eating --> Asleep - | Eating --> Resting - | Eating --> RunningOnWheel - | Resting --> Asleep - | RunningOnWheel --> Asleep - | RunningOnWheel --> Resting - """.trimMargin() -``` - +abstract class ColorChange( + from: NonEmptySet, + to: Color +) : Transition(from, to) { + // Convenience constructor for when the from set has only one value + constructor(from: Color, to: Color) : this(nonEmptySetOf(from), to) +} -### The value +class Go(private val camera: Camera) : ColorChange(from = Red, to = Green) { + override suspend fun effect(value: Light) = camera.disable() +} -Your transitionable value must extend `Transitionable`. This simply means it has a property `state`. +object Slow : ColorChange(from = Green, to = Amber) -```kotlin -data class Hamster(override val state: HamsterState) : Transitionable +class Stop(private val camera: Camera) : ColorChange(from = Amber, to = Red) { + override suspend fun effect(value: Light) = camera.enable() +} ``` +### Transitioner -### The transitions - -For a value to move from one state to another it must be transitioned. Declare each of the valid transitions separately, -starting with a base class. +Moving a value from one state to another is done by the transitioner. We provide it with a function that declares how to +persist values. ```kotlin -abstract class HamsterTransition : Transition { +class LightTransitioner( + private val database: Database +) : Transitioner( + persist = { it.also(database::update).right() } +) +``` - constructor(from: HamsterState, to: HamsterState) : super(from, to) - constructor(from: Set, to: HamsterState) : super(from, to) +Each time a transition is successful, the persist function will be called. - override fun makeFailure( - value: Hamster, - effectCompleted: Boolean, - updateCompleted: Boolean, - cause: Throwable - ): HamsterFailure = InternalHamsterError(cause) -} -``` +#### Pre and Post Transition Hooks -Transitions must define the side-effects that take place as part of the transition. +It is sometimes necessary to execute effects before and after a transition. These can be defined on the transitioner. ```kotlin -class EatBreakfast(private val food: String) : HamsterTransition(from = Awake, to = Eating) { +class LightTransitioner ... { + override suspend fun preHook(value: V, via: T): ErrorOr = Either.catch { + globalLock.lock(value) + } - override suspend fun effect(value: Hamster): Either = - when (food) { - "broccoli" -> value.eat(food).right() - "cheese" -> LactoseIntoleranceTroubles(food).left() - else -> value.eat(food).right() + override suspend fun postHook(from: S, value: V, via: T): ErrorOr = Either.catch { + globalLock.unlock(value) + notificationService.send(via.successNotifications()) } } ``` -Transitions must also define how to create a failure object, given some important context such as whether the effect has -completed successfully (there are steps after the effect which might fail). +### Transitioning + +With the state machine and transitioner defined, we can progress any value through the machine by using the +transitioner. ```kotlin - override fun makeFailure( - value: Hamster, - effectCompleted: Boolean, - updateCompleted: Boolean, - cause: Throwable - ): HamsterFailure = InternalHamsterError(cause) +val transitioner = LightTransitioner(database) +val greenLight: ErrorOr = transitioner.transition(redLight, Go) ``` -Any attempt to define a transition where the `from` state cannot directly reach the `to` state will be an invariant -failure and result in exceptions when wiring your application. +### More examples + +See [lib/src/test/kotlin/app/cash/kfsm/exemplar](https://github.com/cashapp/kfsm/tree/main/lib/src/test/kotlin/app/cash/kfsm/exemplar) +for a full example of how to use this library. + +## Safety +How does kFSM help validate the correctness of your state machine and your values? -### The transitioner +1. It is impossible to define a Transition that does not comply with the transitions defined in the States. For example, + a transition that attempts to define an arrow between `Red` and `Amber` will fail at construction. +2. If a value has already transitioned to the target state, then a subsequent request will not execute the transition a + second time. The result will be success. I.e. it is a no-op. + 1. (unless you have defined a circular/self-transition, in which case it will) +3. If a value is in a state unrelated to the executed transition, then the result will be an error and no effect will be + executed. -The StateTransitioner is the machine that applies a transition to a value. In the constructor, you must define how to -update the value (e.g. write to DB) and how to notify of the successful transition (e.g. emit an event) +### Testing your state machine + +The utility `StateMachine.verify` will assert that a defined state machine is valid - i.e. that all states are visited +from a given starting state. ```kotlin - val transitioner = StateTransitioner( - update = { h, t -> h.copy(state = t.to) }, - notifyOnSuccess = { h, t -> println("updated: $h via $t") } - ) +StateMachine.verify(Green) shouldBeRight true ``` -Then you can transition. + +### Document your state machine + +The utility `StateMachine.mermaid` will generate a mermaid diagram of your state machine. This can be rendered in markdown. +The diagram of `Color` above was created using this utility. ```kotlin -transitioner.transition(hamster, EatBreakfast("broccoli")) +StateMachine.mermaid(Green) shouldBeRight """stateDiagram-v2 + [*] --> Green + Amber --> Red + Green --> Amber + Red --> Green + """.trimMargin() ``` ## Documentation @@ -167,27 +155,26 @@ transitioner.transition(hamster, EatBreakfast("broccoli")) The API documentation is published with each release at [https://cashapp.github.io/kfsm](https://cashapp.github.io/kfsm) +See a list of changes in each release in the [CHANGELOG](CHANGELOG.md). + +## Contributing + +For details on contributing, see the [CONTRIBUTING](CONTRIBUTING.md) guide. -## Building +### Building -> ℹ️ kfsm uses [Hermit](https://cashapp.github.io/hermit/). +> ℹ️ kFSM uses [Hermit](https://cashapp.github.io/hermit/). > ->> Hermit ensures that your team, your contributors, and your CI have the same consistent tooling. Here are the [installation instructions](https://cashapp.github.io/hermit/usage/get-started/#installing-hermit). ->> +>> Hermit ensures that your team, your contributors, and your CI have the same consistent tooling. Here are +> > the [installation instructions](https://cashapp.github.io/hermit/usage/get-started/#installing-hermit). +>> >> [Activate Hermit](https://cashapp.github.io/hermit/usage/get-started/#activating-an-environment) either -by [enabling the shell hooks](https://cashapp.github.io/hermit/usage/shell/) (one-time only, recommended) or manually -sourcing the env with `. ./bin/activate-hermit`. +> > by [enabling the shell hooks](https://cashapp.github.io/hermit/usage/shell/) (one-time only, recommended) or +> > manually +> > sourcing the env with `. ./bin/activate-hermit`. Use gradle to run all tests ```shell gradle build ``` - -## Changelog - -See a list of changes in each release in the [CHANGELOG](CHANGELOG.md). - -## Contributing - -For details on contributing, see the [CONTRIBUTING](CONTRIBUTING.md) guide. diff --git a/lib/api/lib.api b/lib/api/lib.api index beca3a3..f53dd17 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -24,7 +24,7 @@ public class app/cash/kfsm/Transition { public final fun getTo ()Lapp/cash/kfsm/State; } -public class app/cash/kfsm/Transitioner { +public abstract class app/cash/kfsm/Transitioner { public fun ()V public fun (Lkotlin/jvm/functions/Function2;)V public synthetic fun (Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/lib/src/main/kotlin/app/cash/kfsm/Transitioner.kt b/lib/src/main/kotlin/app/cash/kfsm/Transitioner.kt index b85a3fc..19e89bc 100644 --- a/lib/src/main/kotlin/app/cash/kfsm/Transitioner.kt +++ b/lib/src/main/kotlin/app/cash/kfsm/Transitioner.kt @@ -9,7 +9,7 @@ import arrow.core.left import arrow.core.raise.either import arrow.core.right -open class Transitioner, V: Value, S : State>( +abstract class Transitioner, V: Value, S : State>( private val persist: suspend (V) -> ErrorOr = { it.right() } ) { diff --git a/lib/src/test/kotlin/app/cash/kfsm/exemplar/HamsterTransitioner.kt b/lib/src/test/kotlin/app/cash/kfsm/exemplar/HamsterTransitioner.kt new file mode 100644 index 0000000..ec7b566 --- /dev/null +++ b/lib/src/test/kotlin/app/cash/kfsm/exemplar/HamsterTransitioner.kt @@ -0,0 +1,31 @@ +package app.cash.kfsm.exemplar + +import app.cash.kfsm.Transitioner +import app.cash.kfsm.exemplar.Hamster.State +import app.cash.quiver.extensions.ErrorOr +import arrow.core.Either +import arrow.core.right + +class HamsterTransitioner( + val saves: MutableList = mutableListOf() +) : Transitioner( + // This is where you define how to save your updated value to a data store + persist = { it.also(saves::add).right() } +) { + + val locks = mutableListOf() + val unlocks = mutableListOf() + val notifications = mutableListOf() + + + // Any action you might wish to take prior to transitioning, such as pessimistic locking + override suspend fun preHook(value: Hamster, via: HamsterTransition): ErrorOr = Either.catch { + locks.add(value) + } + + // Any action you might wish to take after transitioning successfully, such as sending events or notifications + override suspend fun postHook(from: State, value: Hamster, via: HamsterTransition): ErrorOr = Either.catch { + notifications.add("${value.name} was $from, then began ${via.description} and is now ${via.to}") + unlocks.add(value) + } +} diff --git a/lib/src/test/kotlin/app/cash/kfsm/exemplar/HamsterTransitions.kt b/lib/src/test/kotlin/app/cash/kfsm/exemplar/HamsterTransitions.kt index 17d8e69..e1ae918 100644 --- a/lib/src/test/kotlin/app/cash/kfsm/exemplar/HamsterTransitions.kt +++ b/lib/src/test/kotlin/app/cash/kfsm/exemplar/HamsterTransitions.kt @@ -17,6 +17,7 @@ abstract class HamsterTransition( from: NonEmptySet, to: Hamster.State ) : Transition(from, to) { + // Convenience constructor for when the from set has only one value constructor(from: Hamster.State, to: Hamster.State) : this(nonEmptySetOf(from), to) // Demonstrates how you can add base behaviour to transitions for use in pre and post hooks. diff --git a/lib/src/test/kotlin/app/cash/kfsm/exemplar/PenelopePerfectDayTest.kt b/lib/src/test/kotlin/app/cash/kfsm/exemplar/PenelopesPerfectDayTest.kt similarity index 60% rename from lib/src/test/kotlin/app/cash/kfsm/exemplar/PenelopePerfectDayTest.kt rename to lib/src/test/kotlin/app/cash/kfsm/exemplar/PenelopesPerfectDayTest.kt index f7aca99..819280d 100644 --- a/lib/src/test/kotlin/app/cash/kfsm/exemplar/PenelopePerfectDayTest.kt +++ b/lib/src/test/kotlin/app/cash/kfsm/exemplar/PenelopesPerfectDayTest.kt @@ -6,11 +6,7 @@ import app.cash.kfsm.exemplar.Hamster.Asleep import app.cash.kfsm.exemplar.Hamster.Awake import app.cash.kfsm.exemplar.Hamster.Eating import app.cash.kfsm.exemplar.Hamster.RunningOnWheel -import app.cash.kfsm.exemplar.Hamster.State -import app.cash.quiver.extensions.ErrorOr -import arrow.core.Either import arrow.core.flatMap -import arrow.core.right import io.kotest.assertions.arrow.core.shouldBeLeft import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.core.spec.IsolationMode @@ -18,53 +14,31 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.shouldBe -class PenelopePerfectDayTest : StringSpec({ +class PenelopesPerfectDayTest : StringSpec({ isolationMode = IsolationMode.InstancePerTest val hamster = Hamster(name = "Penelope", state = Awake) - val saves = mutableListOf() - val locks = mutableListOf() - val unlocks = mutableListOf() - val notifications = mutableListOf() - - // If you do not require pre and post hooks, you can simply instantiate a transitioner & provide the persistence - // function as a constructor argument. - // In this example, we extend the transitioner in order to define hooks that will be executed before each transition - // and after each successful transition. - val transitioner = object : Transitioner( - // This is where you define how to save your updated value to a data store - persist = { it.also(saves::add).right() } - ) { - - // Any action you might wish to take prior to transitioning, such as pessimistic locking - override suspend fun preHook(value: Hamster, via: HamsterTransition): ErrorOr = Either.catch { - locks.add(value) - } - - // Any action you might wish to take after transitioning successfully, such as sending events or notifications - override suspend fun postHook(from: State, value: Hamster, via: HamsterTransition): ErrorOr = Either.catch { - notifications.add("${value.name} was $from, then began ${via.description} and is now ${via.to}") - unlocks.add(value) - } - } + // In this example we extend the transitioner with our own type `HamsterTransitioner` in order to define + // hooks that will be executed before each transition and after each successful transition. + val transitioner = HamsterTransitioner() "a newly woken hamster eats broccoli" { val result = transitioner.transition(hamster, EatBreakfast("broccoli")).shouldBeRight() result.state shouldBe Eating - locks shouldBe listOf(hamster) - unlocks shouldBe listOf(result) - saves shouldBe listOf(result) - notifications shouldBe listOf("Penelope was Awake, then began eating broccoli for breakfast and is now Eating") + transitioner.locks shouldBe listOf(hamster) + transitioner.unlocks shouldBe listOf(result) + transitioner.saves shouldBe listOf(result) + transitioner.notifications shouldBe listOf("Penelope was Awake, then began eating broccoli for breakfast and is now Eating") } "the hamster has trouble eating cheese" { transitioner.transition(hamster, EatBreakfast("cheese")) shouldBeLeft LactoseIntoleranceTroubles("cheese") - locks shouldBe listOf(hamster) - unlocks.shouldBeEmpty() - saves.shouldBeEmpty() - notifications.shouldBeEmpty() + transitioner.locks shouldBe listOf(hamster) + transitioner.unlocks.shouldBeEmpty() + transitioner.saves.shouldBeEmpty() + transitioner.notifications.shouldBeEmpty() } "a sleeping hamster can awaken yet again" { @@ -74,22 +48,22 @@ class PenelopePerfectDayTest : StringSpec({ .flatMap { transitioner.transition(it, WakeUp) } .flatMap { transitioner.transition(it, EatBreakfast("broccoli")) } .shouldBeRight().state shouldBe Eating - locks shouldBe listOf( + transitioner.locks shouldBe listOf( hamster, hamster.copy(state = Eating), hamster.copy(state = RunningOnWheel), hamster.copy(state = Asleep), hamster.copy(state = Awake), ) - unlocks shouldBe saves - saves shouldBe listOf( + transitioner.unlocks shouldBe transitioner.saves + transitioner.saves shouldBe listOf( hamster.copy(state = Eating), hamster.copy(state = RunningOnWheel), hamster.copy(state = Asleep), hamster.copy(state = Awake), hamster.copy(state = Eating), ) - notifications shouldBe listOf( + transitioner.notifications shouldBe listOf( "Penelope was Awake, then began eating broccoli for breakfast and is now Eating", "Penelope was Eating, then began running on the wheel and is now RunningOnWheel", "Penelope was RunningOnWheel, then began going to bed and is now Asleep", @@ -100,20 +74,20 @@ class PenelopePerfectDayTest : StringSpec({ "a sleeping hamster cannot immediately start running on the wheel" { transitioner.transition(hamster.copy(state = Asleep), RunOnWheel).shouldBeLeft() - locks.shouldBeEmpty() - unlocks.shouldBeEmpty() - saves.shouldBeEmpty() - notifications.shouldBeEmpty() + transitioner.locks.shouldBeEmpty() + transitioner.unlocks.shouldBeEmpty() + transitioner.saves.shouldBeEmpty() + transitioner.notifications.shouldBeEmpty() } "an eating hamster who wants to eat twice as hard will just keep eating" { val eatingHamster = hamster.copy(state = Eating) transitioner.transition(eatingHamster, EatBreakfast("broccoli")) .shouldBeRight(eatingHamster) - locks.shouldBeEmpty() - unlocks.shouldBeEmpty() - saves.shouldBeEmpty() - notifications.shouldBeEmpty() + transitioner.locks.shouldBeEmpty() + transitioner.unlocks.shouldBeEmpty() + transitioner.saves.shouldBeEmpty() + transitioner.notifications.shouldBeEmpty() } // Add a test like this to ensure you don't have states that cannot be reached