Skip to content

Commit e65574b

Browse files
committed
Make API simpler and more extendable
1 parent 1bdf021 commit e65574b

7 files changed

Lines changed: 245 additions & 197 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ implementation("io.nexure:fsm:2.0.0")
1515

1616
## Usage
1717
Here is a fictional example of what a state machine could look like that models the process of a
18-
payment. In summary it has
18+
payment. In summary, it has
1919
- _Initial_ state `CREATED`
2020
- _Intermediary_ states `PENDING` and `AUTHORIZED`
2121
- _Terminal_ states `SETTLED` and `REFUSED`
@@ -33,4 +33,4 @@ implementation("io.nexure:fsm:2.0.0")
3333

3434
See [ExampleStateMachineTest.kt](lib/src/test/kotlin/io/nexure/fsm/ExampleStateMachine.kt) for an
3535
example of how a state machine with the above states and transitions is built, and how it can
36-
be invoked to execute certain actions on a given state transition.
36+
be invoked to execute certain actions on state transition.

lib/src/main/kotlin/io/nexure/fsm/StateMachine.kt

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,39 @@ interface StateMachine<S : Any, E : Any> {
2525
*/
2626
fun reduceState(events: List<E>): S
2727

28-
// /**
29-
// * Transition into the initial state, executing the action - if any - setup in the state machine for initialization.
30-
// *
31-
// * Returns the state after the initialization, which will always be the initial state.
32-
// */
33-
// suspend fun initialize(signal: N): S
34-
3528
/**
3629
* Execute a transition from [state] to another state depending on [event].
37-
* If an action is associated with the state transition, it will then be executed,
38-
* with [signal] as input. Returns a [Transition] indicating if the transition was permitted and
39-
* successful or not.
30+
* Returns a [Transition] indicating if the transition was permitted and
31+
* successful or not by the state machine.
4032
*
4133
* It is recommended that the return value is checked for the desired outcome, if it is critical
4234
* that an event for example is accepted and not rejected.
4335
*/
44-
suspend fun onEvent(state: S, event: E, action: suspend () -> Unit = {}): Transition<S>
36+
fun onEvent(state: S, event: E): Transition<S>
4537

4638
companion object {
47-
fun <S : Any, E : Any, N : Any> builder(): StateMachineBuilder<S, E> = StateMachineBuilder()
39+
fun <S : Any, E : Any> builder(): StateMachineBuilder.Uninitialized<S, E> = StateMachineBuilder()
4840
}
4941
}
42+
43+
/**
44+
* Execute a transition from [state] to another state depending on [event].
45+
* Returns a [Transition] indicating if the transition was permitted and
46+
* successful or not by the state machine.
47+
*
48+
* It is recommended that the return value is checked for the desired outcome, if it is critical
49+
* that an event for example is accepted and not rejected.
50+
*
51+
* This extension method is just syntactic sugar for calling
52+
* ```kotlin
53+
* stateMachine.onEvent(currentState, event).onTransition { newState ->
54+
* // Do something
55+
* }
56+
* ```
57+
*/
58+
inline fun <S : Any, E : Any> StateMachine<S, E>.onEvent(
59+
state: S,
60+
event: E,
61+
action: (state: S) -> Unit
62+
): Transition<S> = onEvent(state, event).onTransition { action(it) }
63+

lib/src/main/kotlin/io/nexure/fsm/StateMachineBuilder.kt

Lines changed: 126 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -4,85 +4,137 @@ package io.nexure.fsm
44
* - [S] - the type of state that the state machine handles
55
* - [E] - the type of events that the can trigger state changes
66
*/
7-
class StateMachineBuilder<S : Any, E : Any> private constructor(
8-
private var initialState: S? = null,
9-
private val transitions: List<Edge<S, E>> = emptyList(),
10-
private val postInterceptors: List<(S, S, E) -> Unit> = emptyList(),
11-
) {
12-
constructor() : this(null, emptyList(), emptyList())
13-
14-
/**
15-
* Set the initial state for this state machine. There must be exactly one initial state,
16-
* no more or less. Failing to set an initial state for a state machine will cause an
17-
* [InvalidStateMachineException] to be thrown when [build()] is invoked.
18-
*
19-
* Calling this method more than once, with a different initial state will also cause an
20-
* [InvalidStateMachineException] to be thrown, but immediately upon the second call to this
21-
* method rather when the state machine is built.
22-
*/
23-
@Throws(InvalidStateMachineException::class)
24-
fun initial(state: S): StateMachineBuilder<S, E> {
25-
return if (initialState == null) {
26-
StateMachineBuilder(state, transitions, postInterceptors)
27-
} else if (state === initialState) {
28-
StateMachineBuilder(initialState, transitions, postInterceptors)
29-
} else {
30-
throw InvalidStateMachineException("There can only be one initial state")
31-
}
7+
sealed class StateMachineBuilder<S : Any, E : Any> {
8+
class Uninitialized<S : Any, E : Any> internal constructor(
9+
private val transitions: List<Edge<S, E>> = emptyList(),
10+
) : StateMachineBuilder<S, E>() {
11+
/**
12+
* Set the initial state for this state machine. There can only one initial state,
13+
* no more or less.
14+
*/
15+
fun initial(state: S): Initialized<S, E> = Initialized(state, transitions)
3216
}
3317

34-
/**
35-
* Create a state transition from [source] state to [target] state that will be triggered by
36-
* [event], and execute an optional [action] when doing the state transition. There can be
37-
* multiple events that connect [source] and [target], but there must never be any ambiguous
38-
* transitions.
39-
*
40-
* For example, having both of the following transitions, would NOT be permitted
41-
* - `(S1, E1) -> S2`
42-
* - `(S1, E1) -> S3`
43-
*
44-
* since it would not be clear if the new state should be `S2` or `S3` when event `E1` is
45-
* received.
46-
*/
47-
fun connect(
48-
source: S,
49-
target: S,
50-
event: E,
51-
): StateMachineBuilder<S, E> = connect(Edge(source, target, event))
52-
53-
private fun connect(edge: Edge<S, E>): StateMachineBuilder<S, E> =
54-
StateMachineBuilder(initialState, transitions.plus(edge), postInterceptors)
18+
class Initialized<S : Any, E : Any> internal constructor(
19+
private val initialState: S,
20+
private val transitions: List<Edge<S, E>>,
21+
) : StateMachineBuilder<S, E>() {
22+
/**
23+
* Create a state transition from [source] state to [target] state that will be triggered by
24+
* [event]. There can be multiple events that connect [source] and [target],
25+
* but there must never be any ambiguous transitions.
26+
*
27+
* For example, having both of the following transitions, would NOT be permitted
28+
* - `(S1, E1) -> S2`
29+
* - `(S1, E1) -> S3`
30+
*
31+
* since it would not be clear if the new state should be `S2` or `S3` when event `E1` is
32+
* received.
33+
*/
34+
fun connect(
35+
source: S,
36+
target: S,
37+
event: E,
38+
): Initialized<S, E> = connect(Edge(source, target, event))
5539

56-
/**
57-
* Add an interceptor that is run _after_ a successful processing of an event by the state
58-
* machine. This interceptor will not be run if the event was rejected by the state machine, or
59-
* if there was an exception thrown while executing the state machine action (if any).
60-
*/
61-
fun postIntercept(
62-
interception: (source: S, target: S, event: E) -> Unit
63-
): StateMachineBuilder<S, E> =
64-
StateMachineBuilder(initialState, transitions, postInterceptors.plus(interception))
40+
private fun connect(edge: Edge<S, E>): Initialized<S, E> =
41+
Initialized(initialState, transitions.plus(edge))
6542

66-
/**
67-
* @throws InvalidStateMachineException if the configured state machine is not valid. The main
68-
* reasons for a state machine not being valid are:
69-
* - No initial state
70-
* - More than one initial state
71-
* - The state machine is not connected (some states are not possible to reach from the initial
72-
* state)
73-
* - The same source state and event is defined twice
74-
*/
75-
@Throws(InvalidStateMachineException::class)
76-
fun build(): StateMachine<S, E> {
77-
val initState: S = initialState
78-
?: throw InvalidStateMachineException("No initial state set for state machine")
43+
/**
44+
* @throws InvalidStateMachineException if the configured state machine is not valid. The main
45+
* reasons for a state machine not being valid are:
46+
* - No initial state
47+
* - More than one initial state
48+
* - The state machine is not connected (some states are not possible to reach from the initial
49+
* state)
50+
* - The same source state and event is defined twice
51+
*/
52+
@Throws(InvalidStateMachineException::class)
53+
fun build(): StateMachine<S, E> {
54+
StateMachineValidator.validate(initialState, transitions)
7955

80-
StateMachineValidator.validate(initState, transitions)
56+
return StateMachineImpl(
57+
initialState,
58+
transitions,
59+
)
60+
}
61+
}
8162

82-
return StateMachineImpl(
83-
initState,
84-
transitions,
85-
postInterceptors
86-
)
63+
companion object {
64+
operator fun <S : Any, E : Any> invoke(): Uninitialized<S, E> = Uninitialized()
8765
}
8866
}
67+
//
68+
///**
69+
// * - [S] - the type of state that the state machine handles
70+
// * - [E] - the type of events that the can trigger state changes
71+
// */
72+
//class StateMachineBuilder<S : Any, E : Any> private constructor(
73+
// private var initialState: S? = null,
74+
// private val transitions: List<Edge<S, E>> = emptyList(),
75+
//) {
76+
// constructor() : this(null, emptyList())
77+
//
78+
// /**
79+
// * Set the initial state for this state machine. There must be exactly one initial state,
80+
// * no more or less. Failing to set an initial state for a state machine will cause an
81+
// * [InvalidStateMachineException] to be thrown when [build()] is invoked.
82+
// *
83+
// * Calling this method more than once, with a different initial state will also cause an
84+
// * [InvalidStateMachineException] to be thrown, but immediately upon the second call to this
85+
// * method rather when the state machine is built.
86+
// */
87+
// @Throws(InvalidStateMachineException::class)
88+
// fun initial(state: S): StateMachineBuilder<S, E> {
89+
// return if (initialState == null) {
90+
// StateMachineBuilder(state, transitions)
91+
// } else if (state === initialState) {
92+
// StateMachineBuilder(initialState, transitions)
93+
// } else {
94+
// throw InvalidStateMachineException("There can only be one initial state")
95+
// }
96+
// }
97+
//
98+
// /**
99+
// * Create a state transition from [source] state to [target] state that will be triggered by
100+
// * [event]. There can be multiple events that connect [source] and [target],
101+
// * but there must never be any ambiguous transitions.
102+
// *
103+
// * For example, having both of the following transitions, would NOT be permitted
104+
// * - `(S1, E1) -> S2`
105+
// * - `(S1, E1) -> S3`
106+
// *
107+
// * since it would not be clear if the new state should be `S2` or `S3` when event `E1` is
108+
// * received.
109+
// */
110+
// fun connect(
111+
// source: S,
112+
// target: S,
113+
// event: E,
114+
// ): StateMachineBuilder<S, E> = connect(Edge(source, target, event))
115+
//
116+
// private fun connect(edge: Edge<S, E>): StateMachineBuilder<S, E> =
117+
// StateMachineBuilder(initialState, transitions.plus(edge))
118+
//
119+
// /**
120+
// * @throws InvalidStateMachineException if the configured state machine is not valid. The main
121+
// * reasons for a state machine not being valid are:
122+
// * - No initial state
123+
// * - More than one initial state
124+
// * - The state machine is not connected (some states are not possible to reach from the initial
125+
// * state)
126+
// * - The same source state and event is defined twice
127+
// */
128+
// @Throws(InvalidStateMachineException::class)
129+
// fun build(): StateMachine<S, E> {
130+
// val initState: S = initialState
131+
// ?: throw InvalidStateMachineException("No initial state set for state machine")
132+
//
133+
// StateMachineValidator.validate(initState, transitions)
134+
//
135+
// return StateMachineImpl(
136+
// initState,
137+
// transitions,
138+
// )
139+
// }
140+
//}

lib/src/main/kotlin/io/nexure/fsm/StateMachineImpl.kt

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package io.nexure.fsm
33
internal class StateMachineImpl<S : Any, E : Any>(
44
private val initialState: S,
55
private val transitions: List<Edge<S, E>>,
6-
private val postInterceptors: List<(S, S, E) -> Unit>
76
) : StateMachine<S, E> {
87
private val allowedTransitions: Map<S?, Set<Pair<S, E?>>> = transitions
98
.groupBy { it.source }
@@ -24,20 +23,9 @@ internal class StateMachineImpl<S : Any, E : Any>(
2423
override fun reduceState(events: List<E>): S =
2524
events.fold(initialState) { state, event -> nextState(state, event) ?: state }
2625

27-
private fun postIntercept(source: S, target: S, event: E) {
28-
postInterceptors.forEach { intercept -> intercept(source, target, event) }
29-
}
30-
31-
// override suspend fun initialize(signal: N): S {
32-
// initialAction(signal)
33-
// return initialState
34-
// }
35-
36-
override suspend fun onEvent(state: S, event: E, action: suspend () -> Unit): Transition<S> {
26+
override fun onEvent(state: S, event: E): Transition<S> {
3727
val next: S = nextState(state, event) ?: return Rejected
38-
action()
39-
postIntercept(state, next, event)
40-
return Executed(next)
28+
return Accepted(next)
4129
}
4230

4331
private fun nextState(source: S, event: E): S? {

lib/src/main/kotlin/io/nexure/fsm/Transition.kt

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,54 @@ package io.nexure.fsm
22

33
/**
44
* Outcome of processing of event by state machine. This will indicate if an event was accepted or
5-
* rejected by the state machine, or if there was an exception while processing the event.
5+
* rejected by the state machine.
66
*/
77
sealed class Transition<out S : Any> {
8-
fun transitioned(): Boolean = this is Executed<S>
8+
fun transitioned(): Boolean = this is Accepted<S>
99

1010
/**
1111
* Returns
12-
* - The new state if the transition was permitted and successful ([Executed])
12+
* - The new state if the transition was permitted and successful ([Accepted])
1313
* - `null` if the transitioned was rejected ([Rejected])
1414
*/
1515
fun stateOrNull(): S? {
1616
return when (this) {
17-
is Executed -> this.state
17+
is Accepted -> this.state
1818
Rejected -> null
1919
}
2020
}
21+
22+
/**
23+
* Invoke this lambda if a transition was executed and successful
24+
*/
25+
inline fun onTransition(action: (state: S) -> Unit): Transition<S> {
26+
if (this is Accepted) {
27+
action(this.state)
28+
}
29+
return this
30+
}
31+
32+
/**
33+
* Invoke this lambda if a transition was rejected by the state machine
34+
*/
35+
inline fun onRejection(handle: () -> Unit): Transition<S> {
36+
if (this is Rejected) {
37+
handle()
38+
}
39+
return this
40+
}
2141
}
2242

2343
/**
24-
* The event was successfully processed. Any action associated with the transition, including
25-
* interceptors, will have been executed successfully.
44+
* The event was accepted and a state transition occurred. The [state] property reflects the new state.
2645
*/
27-
data class Executed<S : Any>(val state: S) : Transition<S>() {
46+
data class Accepted<S : Any>(val state: S) : Transition<S>() {
2847
override fun toString(): String = "Executed($state)"
2948
}
3049

3150
/**
3251
* The event was rejected because the event was not permitted by the state machine in the current
33-
* state, which means that no part of any state transition action or interceptors were executed.
52+
* state.
3453
*/
3554
object Rejected : Transition<Nothing>() {
3655
override fun toString(): String = "Rejected"

0 commit comments

Comments
 (0)