MobiusCore/Source/EffectHandlers/EffectRouter.swift (102 lines of code) (raw):

// 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 four types of targets: /// - `.to { effect in ... }`: A fire-and-forget style function of type `(EffectParameters) -> Void`. /// - `.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() } } ) } }