diff --git a/Docs/img/recombine-diagram.svg b/Docs/img/recombine-diagram.svg new file mode 100644 index 0000000..1deae18 --- /dev/null +++ b/Docs/img/recombine-diagram.svg @@ -0,0 +1,9 @@ + + + View Action Reducer Middleware StoreState  Refined action Raw action Refined action State \ No newline at end of file diff --git a/Docs/img/recombine_concept.graffle b/Docs/img/recombine_concept.graffle deleted file mode 100644 index 2e273c9..0000000 Binary files a/Docs/img/recombine_concept.graffle and /dev/null differ diff --git a/Docs/img/recombine_concept.png b/Docs/img/recombine_concept.png deleted file mode 100644 index 8e74af3..0000000 Binary files a/Docs/img/recombine_concept.png and /dev/null differ diff --git a/README.md b/README.md index 2a6d581..dd02f97 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@ A non-comprehensive list of benefits: # About Recombine -Recombine relies on three principles: +Recombine relies on four principles: - **The Store** stores your entire app state in the form of a single data structure. This state can only be modified by dispatching Actions to the store. Whenever the state in the store changes, the store will notify all observers. - **Actions** are a declarative way of describing a state change. Actions don't contain any code, they are consumed by the store and forwarded to reducers. Reducers will handle the actions by implementing a different state change for each action. - **Reducers** provide pure functions that create a new app state from actions and the current app state. These are your business and navigation logic routers. - **Middleware** is a transformative type that lets you go from unrefined actions to refined ones, allowing for asynchronous calls and shortcut expansion of one action into many. Middleware is perfect for extracting records from databases or servers. -![](Docs/img/recombine_concept.png) +![Recombine flow diagram](Docs/img/recombine-diagram.svg) For a very simple app, one that maintains a counter, you can define the app state as following: diff --git a/Sources/RecombinePackage/Middleware.swift b/Sources/RecombinePackage/Middleware.swift index 81b1f56..760e18b 100644 --- a/Sources/RecombinePackage/Middleware.swift +++ b/Sources/RecombinePackage/Middleware.swift @@ -1,28 +1,127 @@ import Combine -/// Middleware is a dependency injection structure that allows you to transform raw actions into refined ones, -/// Refined actions produced by Middleware are then forwarded to the main reducer. +/// A dependency injection structure where you transform raw actions, into refined actions which are sent to the store's `Reducer`. /// +/// The middleware is where you handle side effects, asynchronous calls, and generally code which interacts with the outside world (ie: making a network call, loading app data from disk, getting the user's current location), and also aggregate operations like resetting the state. Much like the rest of Recombine, `Middleware` harnesses Combine and its publishers to represent these interactions. +/// +///`Middleware` is generic over 3 types: +/// * `State`: The data structure which represents the current app state. +/// * `Input`: Most commonly raw actions, this is the value that will be transformed into the `Output`. +/// * `Output`: Most commonly refined actions, this is the result of the `Input`'s transformation, which is then sent to the store's `Reducer` +/// +/// When creating the middleware, you pass in the `State`, `Input`, and `Output` in the angle brackets, and then a closure which takes two arguments –  a publisher of `State`, the `Input`, and which returns an `AnyPublisher` of the `Output`. +/// +/// Critically, you don't have access to the current state itself – only a "stream" where you can send refined actions. +/// +/// Because you need to return an `AnyPublisher`, you usually make your asynchronous calls using Combine publishers, which you can `flatMap(_:)` into the `statePublisher` to return a refined action. It is recommended to make publisher extensions on common types which don't already have one, like `FileManager` or `CLLocationManager`. +/// +/// For example, a middleware which handles making a network call and resetting the app's state: +/// +/// static let middleware = Middleware { statePublisher, action -> AnyPublisher in +/// switch action { +/// case let networkCall(url): +/// URLSession.shared.dataTaskPublisher(for: url) +/// .map(\.data) +/// .decode(type: MyModel.self, decoder: JSONDecoder()) +/// .replaceError(with: MyModel()) +/// .flatMap { myModel in +/// statePublisher.map { _ in +/// return .setModel(myModel) +/// } +/// } +/// .eraseToAnyPublisher() +/// } +/// case resetAppState: +/// return [ +/// .setModel(MyModel.empty), +/// .usernameModification(.delete)) +/// ] +/// .publisher +/// .eraseToAnyPublisher() +/// } +/// } +/// In the code above, the network call is made in the form of `URLSession`'s `dataTaskPublisher(for:)`. We decode the data and change the publisher's error type using `replaceError(with:)` (since the returned `AnyPublisher`'s error type must be `Never` – this can be done with other operators like `catch(:)` and `mapError(_:)`). +/// +/// Then, we replace the `URLSession` publisher with the `statePublisher` using `flatMap(_:)`, which itself returns a refined action: `.setModel(MyModel)`. +/// +/// This middleware also handles an aggregate operation, resetting the app state. It simply returns an array of refined actions, which is turned into a publisher using the `publisher` property on the `Sequence` protocol. public struct Middleware { public typealias StatePublisher = Publishers.First.Publisher> - public typealias Function = (StatePublisher, Input) -> AnyPublisher public typealias Transform = (StatePublisher, Output) -> Result - internal let transform: Function + /// The closure which takes in the `StatePublisher` and `Input`, and transforms it into an `AnyPublisher`; the heart of the middleware. + internal let transform: (StatePublisher, Input) -> AnyPublisher - /// Create a passthrough Middleware. + /// Create an empty passthrough `Middleware.` + /// + /// The input type must be equivalent to the output type. + /// + ///For example: + /// + /// static let passthroughMiddleware = Middleware() public init() where Input == Output { self.transform = { Just($1).eraseToAnyPublisher() } } - /// Initialises the middleware with a transformative function. - /// - parameter transform: The function that will be able to modify passed actions. + /// Initialises the middleware with a closure which handles transforming the raw actions and returning refined actions. + /// - parameter transform: The closure which takes a publisher of `State`, and the `Middleware`'s `Input`, and returns a publisher who's output is the `Middleware`'s `Output`. + /// + /// The `transform` closure takes two parameters: + /// * A publisher wrapping over the state that was passed into the `Middleware`'s angle brackets. + /// * The middleware's input – most commonly raw actions. + /// + /// The closure then returns a publisher who's output is equivalent to the `Middleware`'s `Output` – most commonly refined actions. + /// + /// For example: + /// + /// static let middleware = Middleware { statePublisher, action -> AnyPublisher in + /// switch action { + /// case let findCurrentLocation(service): + /// CLLocationManager.currentLocationPublisher(service: service) + /// .map { LocationModel(location: $0) } + /// .flatMap { location in + /// statePublisher.map { _ in + /// return .setLocation(to: location) + /// } + /// } + /// .catch { err in + /// return Just(.locationError(err)) + /// } + /// .eraseToAnyPublisher() + /// For a more detailed explanation, go to the `Middleware` documentation. public init( _ transform: @escaping (StatePublisher, Input) -> P ) where P.Output == Output, P.Failure == Never { self.transform = { transform($0, $1).eraseToAnyPublisher() } } - /// Concatenates the transform function of the passed `Middleware` onto the callee's transform. + /// Adds two middlewares together, concatenating the passed-in middleware's closure to the caller's own closure. + /// - Parameter other: The other middleware, who's `State`, `Input`, and `Output` must be equivalent to the callers'. + /// - Returns: A `Middleware` who's closure is the result of concatenating the caller's closure and the passed in middleware's closure. + /// + /// Use this function when you want to break up your middleware code to make it more compositional. + /// + /// For example: + /// + /// static let middleware = Middleware { statePublisher, action -> AnyPublisher in + /// switch action { + /// case loadAppData: + /// FileManager.default.loadPublisher(from: "appData.json", in: .applicationSupportDirectory) + /// .decode(type: State.self, decoder: JSONDecoder()) + /// // etc... + /// default: + /// break + /// } + /// } + /// .concat( + /// Middleware { statePublisher, action -> AnyPublisher in + /// switch action { + /// case let displayBluetoothPeripherals(services: services): + /// CBCentralManager.peripheralsPublisher(services: services) + /// .map(\.peripheralName) + /// // etc... + /// default: + /// break + /// ) public func concat(_ other: Middleware) -> Middleware { .init { state, action in self.transform(state, action).flatMap {