-
Notifications
You must be signed in to change notification settings - Fork 41
Expand file tree
/
Copy pathEffectRouter.swift
More file actions
174 lines (158 loc) · 7.82 KB
/
EffectRouter.swift
File metadata and controls
174 lines (158 loc) · 7.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
// Copyright Spotify AB.
// SPDX-License-Identifier: Apache-2.0
import Foundation
/// An `EffectRouter` defines the relationship between the effects in your domain and the constructs which handle those
/// effects.
///
/// - Note: Each effect in your domain must be linked to exactly one handler. A runtime crash will occur if zero or
/// multiple handlers were found for some received input.
///
/// To define the relationship between an effect and its handler, you need two parts. The first is the routing criteria.
/// There are two choices here:
/// - `.routeEffects(equalTo: constant)` - Routing to effects which are equal to `constant`.
/// - `.routeEffects(withParameters: extractParameters)` - Routing effects that satisfy
/// a parameter extracting function: `(Effect) -> EffectParameters?`. If this function returns a non-`nil` value,
/// that route is taken and the non-`nil` value is sent as the input to the route.
///
/// These two routing criteria can be matched with one of five types of targets:
/// - `.to { effect in ... }`: A fire-and-forget style function of type `(EffectParameters) -> Void`.
/// - `.onMainActor().to { effect in ... }`: A fire-and-forget style function of type
/// `@MainActor @Sendable (EffectParameters) -> Void`. This is equivalent to
/// `.on(queue: .main).to { ... }` but keeps the closure explicitly main-actor-isolated.
/// - `.toEvent { effect in ... }`: A function which returns an optional event to send back into the loop:
/// `(EffectParameters) -> Event?`. This makes it easy to send a single event caused by the effect.
/// - `.to(EffectHandler)`: This should be used for effects which require asynchronous behavior or produce more than
/// one event, and which have a clear definition of when an effect has been handled. For example, an effect handler
/// which performs a network request and dispatches an event back into the loop once it is finished or if it fails.
/// - `.to(Connectable)`: This should be used for effect handlers which do not have a clear definition of when a given
/// effect has been handled. For example, an effect handler which will continue to produce events indefinitely once
/// it has been started.
public struct EffectRouter<Effect, Event> {
private let routes: [Route<Effect, Event>]
public init() {
routes = []
}
fileprivate init(routes: [Route<Effect, Event>]) {
self.routes = routes
}
/// Add a route for effects which satisfy `extractParameters`.
///
/// `extractParameters` is a function which returns an optional value for a given effect. If this value is
/// non-`nil`, this route will be taken with that non-`nil` value as input. A different route will be taken if `nil`
/// is returned.
///
/// - Parameter extractParameters: a function which returns a non-`nil` value if this route should be taken, and
/// `nil` if a different route should be taken.
public func routeEffects<EffectParameters>(
withParameters extractParameters: @escaping (Effect) -> EffectParameters?
) -> _PartialEffectRouter<Effect, EffectParameters, Event> {
return _PartialEffectRouter(routes: routes, path: extractParameters, queue: nil)
}
/// Convert this `EffectRouter` into `Connectable` which can be attached to a Mobius Loop, or called on its own to
/// handle effects.
public var asConnectable: AnyConnectable<Effect, Event> {
return compose(routes: routes)
}
}
/// A `_PartialEffectRouter` represents the state between a `routeEffects` call and the corresponding `to` or `toEvent`.
///
/// Every `routeEffects` should be followed immediately by a `to` or `toEvent`, and client code should not refer to the
/// `_PartialEffectRouter` type directly.
public struct _PartialEffectRouter<Effect, EffectParameters, Event> {
fileprivate let routes: [Route<Effect, Event>]
fileprivate let path: (Effect) -> EffectParameters?
fileprivate let queue: DispatchQueue?
/// Route to an `EffectHandler`.
///
/// - Parameter effectHandler: the `EffectHandler` for the route in question.
public func to<Handler: EffectHandler>(
_ effectHandler: Handler
) -> EffectRouter<Effect, Event> where Handler.EffectParameters == EffectParameters, Handler.Event == Event {
let connectable = EffectExecutor(handleInput: effectHandler.handle)
let route = Route<Effect, Event>(extractParameters: path, connectable: connectable, queue: queue)
return EffectRouter(routes: routes + [route])
}
/// Route to a Connectable.
///
/// - Parameter connectable: a connectable which will be used to handle effects.
public func to<C: Connectable>(
_ connectable: C
) -> EffectRouter<Effect, Event> where C.Input == EffectParameters, C.Output == Event {
let connectable = ThreadSafeConnectable(connectable: connectable)
let route = Route(extractParameters: path, connectable: connectable, queue: queue)
return EffectRouter(routes: routes + [route])
}
/// Handle an the current `Effect` asynchronously on the provided `DispatchQueue`
///
/// Warning: Dispatching events to a loop from a different queue is not a thread-safe operation and will require
/// manual synchronization unless the loop is run in a `MobiusController`.
/// See: [Using MobiusController](https://github.com/spotify/Mobius.swift/wiki/Using-MobiusController).
///
///
/// - Parameter queue: The `DispatchQueue` that the current `Effect` should be handled on.
public func on(queue: DispatchQueue) -> Self {
return Self(routes: routes, path: path, queue: queue)
}
}
private struct Route<Input, Output> {
let connect: (@escaping Consumer<Output>) -> ConnectedRoute<Input>
init<EffectParameters, Conn: Connectable>(
extractParameters: @escaping (Input) -> EffectParameters?,
connectable: Conn,
queue: DispatchQueue?
) where Conn.Input == EffectParameters, Conn.Output == Output {
connect = { output in
let connection = connectable.connect(output)
return ConnectedRoute(
tryToHandle: { input in
if let parameters = extractParameters(input) {
return {
if let queue = queue {
queue.async {
connection.accept(parameters)
}
} else {
connection.accept(parameters)
}
}
} else {
return nil
}
},
disposable: connection
)
}
}
}
private struct ConnectedRoute<Input> {
let tryToHandle: (Input) -> (() -> Void)?
let disposable: Disposable
}
private func compose<Input, Output>(
routes: [Route<Input, Output>]
) -> AnyConnectable<Input, Output> {
return AnyConnectable { output in
let connectedRoutes = routes
.map { route in route.connect(output) }
return Connection(
acceptClosure: { effect in
let handlers = connectedRoutes
.compactMap { route in route.tryToHandle(effect) }
if let handleEffect = handlers.first, handlers.count == 1 {
handleEffect()
} else {
MobiusHooks.errorHandler(
"Error: \(handlers.count) EffectHandlers could be found for effect: \(effect). " +
"Exactly 1 is required.",
#file,
#line
)
}
},
disposeClosure: {
connectedRoutes
.forEach { route in route.disposable.dispose() }
}
)
}
}