diff --git a/Sources/RecombinePackage/Action.swift b/Sources/RecombinePackage/Action.swift new file mode 100644 index 0000000..3da5b62 --- /dev/null +++ b/Sources/RecombinePackage/Action.swift @@ -0,0 +1,11 @@ +// +// File.swift +// +// +// Created by Lotte Tortorella on 30/10/20. +// + +public enum ActionStrata { + case raw(Raw) + case refined(Refined) +} diff --git a/Sources/RecombinePackage/Middleware.swift b/Sources/RecombinePackage/Middleware.swift index 8bcfdc0..eabb736 100644 --- a/Sources/RecombinePackage/Middleware.swift +++ b/Sources/RecombinePackage/Middleware.swift @@ -6,17 +6,20 @@ // Copyright © 2019 Charlotte Tortorella. All rights reserved. // +import Combine + /** Middleware is a structure that allows you to modify, filter out and create more actions, before the action being handled reaches the store. */ -public struct Middleware { - typealias Transform = (State, Action) -> [Action] - internal let transform: Transform +public struct Middleware { + public typealias Function = (Publishers.Output.Publisher>, Input) -> AnyPublisher + public typealias Transform = (Publishers.Output.Publisher>, Output) -> Result + internal let transform: Function /// Create a blank slate Middleware. - private init() { - self.transform = { [$1] } + public init() where Input == Output { + self.transform = { Just($1).eraseToAnyPublisher() } } /** @@ -24,105 +27,48 @@ public struct Middleware { - parameter transform: The function that will be able to modify passed actions. */ - internal init(_ transform: @escaping Transform) { + private init(_ transform: @escaping Function) { self.transform = transform } - - /** - Initialises the middleware by concatenating the transformative functions from - the middleware that was passed in. - */ - public init(_ middleware: Self...) { - self = .init(middleware) - } - - /** - Initialises the middleware by concatenating the transformative functions from - the middleware that was passed in. - */ - public init(_ middleware: S) where S.Element == Middleware { - self = middleware.reduce(.init()) { - $0.concat($1) - } - } - + /// Concatenates the transform function of the passed `Middleware` onto the callee's transform. - public func concat(_ other: Middleware) -> Middleware { - .init { state, action in - self.transform(state, action).flatMap { - other.transform(state, $0) - } - } - } - - /// Safe encapsulation of side effects guaranteed not to affect the action being passed through the middleware. - public func sideEffect(_ effect: @escaping (State, Action) -> Void) -> Self { - .init { state, action in - self.transform(state, action).map { - effect(state, $0) - return $0 - } - } - } - - /// Safe encapsulation of side effects guaranteed not to affect the action being passed through the middleware. - public static func sideEffect(_ effect: @escaping (State, Action) -> Void) -> Self { - Middleware().sideEffect(effect) + public func concat(_ other: Middleware) -> Middleware { + return map(other.transform) } /// Transform the action into another action. - public func map(_ transform: @escaping (State, Action) -> Action) -> Self { - .init { state, action in - self.transform(state, action).map { - transform(state, $0) - } - } - } - - /// Transform the action into another action. - public static func map(_ transform: @escaping (State, Action) -> Action) -> Self { - Middleware().map(transform) - } - - /// One to many pattern allowing one action to be turned into multiple. - public func flatMap(_ transform: @escaping (State, Action) -> S) -> Self where S.Element == Action { + public func map( + _ transform: @escaping Transform

+ ) -> Middleware where P.Output == Result, P.Failure == Never { .init { state, action in self.transform(state, action).flatMap { transform(state, $0) } + .eraseToAnyPublisher() } } - /// One to many pattern allowing one action to be turned into multiple. - public static func flatMap(_ transform: @escaping (State, Action) -> S) -> Self where S.Element == Action { - Middleware().flatMap(transform) - } - - /// Filters while mapping actions to new actions. - public func filterMap(_ transform: @escaping (State, Action) -> Action?) -> Self { - .init { state, action in - self.transform(state, action).compactMap { - transform(state, $0) - } - } - } - - /// Filters while mapping actions to new actions. - public static func filterMap(_ transform: @escaping (State, Action) -> Action?) -> Self { - Middleware().filterMap(transform) - } - /// Drop the action iff `isIncluded(action) != true`. - public func filter(_ isIncluded: @escaping (State, Action) -> Bool) -> Self { + public func filter(_ isIncluded: @escaping Transform) -> Self { .init { state, action in self.transform(state, action).filter { isIncluded(state, $0) } + .eraseToAnyPublisher() } } +} + +extension Middleware where Input == Output { + /// Transform the action into another action. + public static func map( + _ transform: @escaping Transform

+ ) -> Middleware where P.Output == Result, P.Failure == Never { + Middleware().map(transform) + } /// Drop the action iff `isIncluded(action) != true`. - public static func filter(_ isIncluded: @escaping (State, Action) -> Bool) -> Self { - Middleware().filter(isIncluded) + public static func filter(_ isIncluded: @escaping Transform) -> Self { + Middleware().filter(isIncluded) } } diff --git a/Sources/RecombinePackage/Reducer.swift b/Sources/RecombinePackage/Reducer.swift index 5fde54f..d6d41e3 100644 --- a/Sources/RecombinePackage/Reducer.swift +++ b/Sources/RecombinePackage/Reducer.swift @@ -14,7 +14,7 @@ public protocol Reducer { var transform: Transform { get } init() init(_ transform: Transform) - func reduce(state: State, actions: [Action]) -> State + func reduce(state: State, action: Action) -> State func concat(_ other: R) -> Self where R.Transform == Transform } @@ -52,8 +52,8 @@ public struct PureReducer: Reducer { } } - public func reduce(state: State, actions: [Action]) -> State { - actions.reduce(state, transform) + public func reduce(state: State, action: Action) -> State { + transform(state, action) } } @@ -80,7 +80,9 @@ public struct MutatingReducer: Reducer { } } - public func reduce(state: State, actions: [Action]) -> State { - actions.reduce(into: state, transform) + public func reduce(state: State, action: Action) -> State { + var s = state + transform(&s, action) + return s } } diff --git a/Sources/RecombinePackage/Store.swift b/Sources/RecombinePackage/Store.swift index a86cd15..6a991ef 100644 --- a/Sources/RecombinePackage/Store.swift +++ b/Sources/RecombinePackage/Store.swift @@ -8,42 +8,58 @@ import Combine -public class Store: ObservableObject { +public class Store: ObservableObject { @Published public private(set) var state: State - public let actions = PassthroughSubject() + public let rawActions = PassthroughSubject() + public let refinedActions = PassthroughSubject() private var cancellables = Set() public required init( state: State, reducer: R, - middleware: Middleware = .init(), + middleware: Middleware, publishOn scheduler: S - ) where R.State == State, R.Action == Action { + ) where R.State == State, R.Action == RefinedAction { self.state = state - actions.scan(state) { state, action in + rawActions.flatMap { [unowned self] action in + middleware.transform(self.$state.prefix(1), action) + } + .subscribe(refinedActions) + .store(in: &cancellables) + + refinedActions.scan(state) { state, action in reducer.reduce( state: state, - actions: middleware.transform(state, action) + action: action ) } - .receive(on: scheduler).sink { [unowned self] state in + .receive(on: scheduler) + .sink { [unowned self] state in self.state = state } .store(in: &cancellables) } - public func lensing(_ keyPath: KeyPath) -> StoreTransform { + public func lensing(_ keyPath: KeyPath) -> StoreTransform { .init(store: self, lensing: keyPath) } - open func dispatch(_ actions: Action...) { - dispatch(actions) + open func dispatch(refined actions: RefinedAction...) { + dispatch(refined: actions) + } + + open func dispatch(refined actions: S) where S.Element == RefinedAction { + actions.forEach(self.refinedActions.send) + } + + open func dispatch(raw actions: RawAction...) { + dispatch(raw: actions) } - open func dispatch(_ actions: S) where S.Element == Action { - actions.forEach(self.actions.send) + open func dispatch(raw actions: S) where S.Element == RawAction { + actions.forEach(self.rawActions.send) } } @@ -53,22 +69,27 @@ extension Store: Subscriber { subscription.request(.unlimited) } - public func receive(_ input: Action) -> Subscribers.Demand { - actions.send(input) + public func receive(_ input: ActionStrata) -> Subscribers.Demand { + switch input { + case let .raw(action): + rawActions.send(action) + case let .refined(action): + refinedActions.send(action) + } return .unlimited } public func receive(completion: Subscribers.Completion) {} } -public class StoreTransform: ObservableObject { +public class StoreTransform: ObservableObject { @Published public private(set) var state: State - private let store: Store + private let store: Store private let keyPath: KeyPath private var cancellables = Set() - public required init(store: Store, lensing keyPath: KeyPath) { + public required init(store: Store, lensing keyPath: KeyPath) { self.store = store self.keyPath = keyPath state = store.state[keyPath: keyPath] @@ -80,16 +101,24 @@ public class StoreTransform: ObservableObject { .store(in: &cancellables) } - public func lensing(_ keyPath: KeyPath) -> StoreTransform { + public func lensing(_ keyPath: KeyPath) -> StoreTransform { .init(store: store, lensing: self.keyPath.appending(path: keyPath)) } - open func dispatch(_ actions: Action...) { - store.dispatch(actions) + open func dispatch(refined actions: RefinedAction...) { + store.dispatch(refined: actions) } - open func dispatch(_ actions: S) where S.Element == Action { - store.dispatch(actions) + open func dispatch(refined actions: S) where S.Element == RefinedAction { + store.dispatch(refined: actions) + } + + open func dispatch(raw actions: RawAction...) { + store.dispatch(raw: actions) + } + + open func dispatch(unrefined actions: S) where S.Element == RawAction { + store.dispatch(raw: actions) } } @@ -99,8 +128,13 @@ extension StoreTransform: Subscriber { subscription.request(.unlimited) } - public func receive(_ input: Action) -> Subscribers.Demand { - store.dispatch(input) + public func receive(_ input: ActionStrata) -> Subscribers.Demand { + switch input { + case let .raw(action): + store.dispatch(raw: action) + case let .refined(action): + store.dispatch(refined: action) + } return .unlimited } diff --git a/Tests/RecombineTests/MiddlewareFakes.swift b/Tests/RecombineTests/MiddlewareFakes.swift index 4b55305..a21b367 100644 --- a/Tests/RecombineTests/MiddlewareFakes.swift +++ b/Tests/RecombineTests/MiddlewareFakes.swift @@ -7,28 +7,32 @@ // import Recombine +import Combine -let firstMiddleware = Middleware().map { state, action in +let firstMiddleware = Middleware.map { state, action -> Just in switch action { case let .string(value): - return .string(value + " First Middleware") + return Just(.string(value + " First Middleware")) default: - return action + return Just(action) } } -let secondMiddleware = Middleware().map { state, action in +let secondMiddleware = Middleware.map { state, action -> Just in switch action { case let .string(value): - return .string(value + " Second Middleware") + return Just(.string(value + " Second Middleware")) default: - return action + return Just(action) } } -let stateAccessingMiddleware = Middleware().map { state, action in - if case let .string(value) = action { - return .string(state.testValue! + state.testValue!) +let stateAccessingMiddleware = Middleware().map { state, action -> AnyPublisher in + if case let .string(value) = action { + return state.map { + .string($0.testValue! + $0.testValue!) + } + .eraseToAnyPublisher() } - return action + return Just(action).eraseToAnyPublisher() } diff --git a/Tests/RecombineTests/StoreDispatchTests.swift b/Tests/RecombineTests/StoreDispatchTests.swift index a679929..a3ed49e 100644 --- a/Tests/RecombineTests/StoreDispatchTests.swift +++ b/Tests/RecombineTests/StoreDispatchTests.swift @@ -10,7 +10,7 @@ import XCTest @testable import Recombine import Combine -fileprivate typealias StoreTestType = Store +fileprivate typealias StoreTestType = Store class ObservableStoreDispatchTests: XCTestCase { @@ -26,10 +26,10 @@ class ObservableStoreDispatchTests: XCTestCase { it subscribes to the property we pass in and dispatches any new values */ func testLiftingWorksAsExpected() { - let subject = PassthroughSubject() - store = Store(state: TestAppState(), reducer: reducer, publishOn: ImmediateScheduler.shared) + let subject = PassthroughSubject, Never>() + store = Store(state: TestAppState(), reducer: reducer, middleware: .init(), publishOn: ImmediateScheduler.shared) subject.subscribe(store) - subject.send(.int(20)) + subject.send(.refined(.int(20))) XCTAssertEqual(store.state.testValue, 20) } } diff --git a/Tests/RecombineTests/StoreMiddlewareTests.swift b/Tests/RecombineTests/StoreMiddlewareTests.swift index 8796de3..e04e5ef 100644 --- a/Tests/RecombineTests/StoreMiddlewareTests.swift +++ b/Tests/RecombineTests/StoreMiddlewareTests.swift @@ -20,48 +20,41 @@ class StoreMiddlewareTests: XCTestCase { let store = Store( state: TestStringAppState(), reducer: testValueStringReducer, - middleware: Middleware(firstMiddleware, secondMiddleware), + middleware: firstMiddleware.concat(secondMiddleware), publishOn: ImmediateScheduler.shared ) let action = SetAction.string("OK") - store.dispatch(action) + store.dispatch(raw: action) XCTAssertEqual(store.state.testValue, "OK First Middleware Second Middleware") } - /** - it middleware can access the store's state - */ - func testMiddlewareCanAccessState() { - var value = "Incorrect" - let store = Store( - state: TestStringAppState(testValue: value), - reducer: testValueStringReducer, - middleware: stateAccessingMiddleware.sideEffect { _, _ in value = "Correct" }, - publishOn: ImmediateScheduler.shared - ) - - store.dispatch(.string("Action That Won't Go Through")) - - XCTAssertEqual(value, "Correct") - } - /** it middleware should not be executed if the previous middleware returned nil */ func testMiddlewareSkipsReducersWhenPassedNil() { - let filteringMiddleware1 = Middleware().filter({ _, _ in false }).sideEffect { _, _ in XCTFail() } - let filteringMiddleware2 = Middleware().filter({ _, _ in false }).filterMap { _, _ in XCTFail(); return nil } + let filteringMiddleware1 = Middleware() + .filter { _, _ in false } + .map { _, _ -> Empty in + XCTFail() + return Empty() + } + let filteringMiddleware2 = Middleware() + .filter { _, _ in false } + .map { _, _ -> Empty in + XCTFail() + return Empty() + } let state = TestStringAppState(testValue: "OK") var store = Store( state: state, reducer: testValueStringReducer, - middleware: Middleware(filteringMiddleware1, filteringMiddleware2), + middleware: filteringMiddleware1.concat(filteringMiddleware2), publishOn: ImmediateScheduler.shared ) - store.dispatch(.string("Action That Won't Go Through")) + store.dispatch(raw: .string("Action That Won't Go Through")) store = Store( state: state, @@ -69,7 +62,7 @@ class StoreMiddlewareTests: XCTestCase { middleware: filteringMiddleware1, publishOn: ImmediateScheduler.shared ) - store.dispatch(.string("Action That Won't Go Through")) + store.dispatch(raw: .string("Action That Won't Go Through")) store = Store( state: state, @@ -77,23 +70,22 @@ class StoreMiddlewareTests: XCTestCase { middleware: filteringMiddleware2, publishOn: ImmediateScheduler.shared ) - store.dispatch(.string("Action That Won't Go Through")) + store.dispatch(raw: .string("Action That Won't Go Through")) } /** it actions should be multiplied via the increase function */ func testMiddlewareMultiplies() { - let multiplexingMiddleware = Middleware() - .flatMap { [$1, $1, $1] } - .filterMap { $1 } + let multiplexingMiddleware = Middleware() + .map { [$1, $1, $1].publisher } let store = Store( state: CounterState(count: 0), reducer: increaseByOneReducer, middleware: multiplexingMiddleware, publishOn: ImmediateScheduler.shared ) - store.dispatch(.noop) + store.dispatch(raw: .noop) XCTAssertEqual(store.state.count, 3) } } diff --git a/Tests/RecombineTests/StoreTests.swift b/Tests/RecombineTests/StoreTests.swift index d515590..8046d12 100644 --- a/Tests/RecombineTests/StoreTests.swift +++ b/Tests/RecombineTests/StoreTests.swift @@ -24,7 +24,7 @@ class ObservableStoreTests: XCTestCase { reducer: testReducer, deInitAction: { deInitCount += 1 } ) - Just(.int(100)).subscribe(store) + Just(.refined(.int(100))).subscribe(store) XCTAssertEqual(store.state.testValue, 100) } @@ -33,7 +33,7 @@ class ObservableStoreTests: XCTestCase { } // Used for deinitialization test -class DeInitStore: Store { +class DeInitStore: Store { var deInitAction: (() -> Void)? deinit { @@ -43,7 +43,7 @@ class DeInitStore: Store { convenience init( state: State, reducer: MutatingReducer, - middleware: Middleware = Middleware(), + middleware: Middleware = .init(), deInitAction: @escaping () -> Void ) { self.init( @@ -58,7 +58,7 @@ class DeInitStore: Store { required init( state: State, reducer: R, - middleware: Middleware = .init(), + middleware: Middleware = .init(), publishOn scheduler: S ) where State == R.State, SetAction == R.Action, S : Scheduler, R : Reducer { super.init(