Skip to content

Commit d3e59a0

Browse files
committed
Add asynchronous effect handling routes
1 parent ed23844 commit d3e59a0

File tree

2 files changed

+386
-0
lines changed

2 files changed

+386
-0
lines changed
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright 2019-2024 Spotify AB.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
16+
public extension _PartialEffectRouter where EffectParameters: Sendable {
17+
/// Routes the `Effect` to an asynchronous closure.
18+
///
19+
/// - Parameter handler: An asynchronous closure receiving the `Effect`'s parameters as input.
20+
/// - Returns: An `EffectRouter` that includes a handler for the given `Effect`.
21+
func to(_ handler: @escaping @Sendable (EffectParameters) async -> Void) -> EffectRouter<Effect, Event> {
22+
to { parameters, _ in
23+
await handler(parameters)
24+
}
25+
}
26+
27+
/// Routes the `Effect` to an asynchronous throwing closure.
28+
///
29+
/// - Parameter handler: An asynchronous throwing closure receiving the `Effect`'s parameters as input.
30+
/// - Returns: An `EffectRouter` that includes a handler for the given `Effect`.
31+
func to(_ handler: @escaping @Sendable (EffectParameters) async throws -> Void) -> EffectRouter<Effect, Event> {
32+
to { parameters, _ in
33+
try await handler(parameters)
34+
}
35+
}
36+
}
37+
38+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
39+
public extension _PartialEffectRouter where EffectParameters == Void {
40+
/// Routes the `Effect` to an asynchronous closure.
41+
///
42+
/// - Parameter handler: An asynchronous closure.
43+
/// - Returns: An `EffectRouter` that includes a handler for the given `Effect`.
44+
func to(_ handler: @escaping @Sendable () async -> Void) -> EffectRouter<Effect, Event> {
45+
to { _, _ in
46+
await handler()
47+
}
48+
}
49+
50+
/// Routes the `Effect` to an asynchronous throwing closure.
51+
///
52+
/// - Parameter handler: An asynchronous throwing closure.
53+
/// - Returns: An `EffectRouter` that includes a handler for the given `Effect`.
54+
func to(_ handler: @escaping @Sendable () async throws -> Void) -> EffectRouter<Effect, Event> {
55+
to { _, _ in
56+
try await handler()
57+
}
58+
}
59+
}
60+
61+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
62+
public extension _PartialEffectRouter where EffectParameters: Sendable {
63+
/// Routes the `Effect` to an asynchronous closure producing a single `Event`.
64+
///
65+
/// - Parameter handler: An asynchronous closure receiving the `Effect`'s parameters as input and producing a single `Event` as output.
66+
/// - Returns: An `EffectRouter` that includes a handler for the given `Effect`.
67+
func to(_ handler: @escaping @Sendable (EffectParameters) async -> Event) -> EffectRouter<Effect, Event> {
68+
to { parameters, callback in
69+
await callback(handler(parameters))
70+
}
71+
}
72+
73+
/// Routes the `Effect` to an asynchronous throwing closure producing a single `Event`.
74+
///
75+
/// - Parameter handler: An asynchronous throwing closure receiving the `Effect`'s parameters as input and producing a single `Event` as output.
76+
/// - Returns: An `EffectRouter` that includes a handler for the given `Effect`.
77+
func to(_ handler: @escaping @Sendable (EffectParameters) async throws -> Event) -> EffectRouter<Effect, Event> {
78+
to { parameters, callback in
79+
try await callback(handler(parameters))
80+
}
81+
}
82+
}
83+
84+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
85+
public extension _PartialEffectRouter where EffectParameters == Void {
86+
/// Routes the `Effect` to an asynchronous closure producing a single `Event`.
87+
///
88+
/// - Parameter handler: An asynchronous closure producing a single `Event` as output.
89+
/// - Returns: An `EffectRouter` that includes a handler for the given `Effect`.
90+
func to(_ handler: @escaping @Sendable () async -> Event) -> EffectRouter<Effect, Event> {
91+
to { _, callback in
92+
await callback(handler())
93+
}
94+
}
95+
96+
/// Routes the `Effect` to an asynchronous throwing closure producing a single `Event`.
97+
///
98+
/// - Parameter handler: An asynchronous throwing closure producing a single `Event` as output.
99+
/// - Returns: An `EffectRouter` that includes a handler for the given `Effect`.
100+
func to(_ handler: @escaping @Sendable () async throws -> Event) -> EffectRouter<Effect, Event> {
101+
to { _, callback in
102+
try await callback(handler())
103+
}
104+
}
105+
}
106+
107+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
108+
public extension _PartialEffectRouter where EffectParameters: Sendable {
109+
/// Routes the `Effect` to an asynchronous closure producing a sequence of `Event`s.
110+
///
111+
/// - Parameter handler: An asynchronous closure receiving the `Effect`'s parameters as input and producing an `AsyncSequence` of `Event`s as output.
112+
/// - Returns: An `EffectRouter` that includes a handler for the given `Effect`.
113+
func to<S: AsyncSequence>(
114+
_ handler: @escaping @Sendable (EffectParameters) async -> S
115+
) -> EffectRouter<Effect, Event> where S.Element == Event {
116+
to { parameters, callback in
117+
for try await output in await handler(parameters) {
118+
callback(output)
119+
}
120+
}
121+
}
122+
}
123+
124+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
125+
public extension _PartialEffectRouter where EffectParameters == Void {
126+
/// Routes the `Effect` to an asynchronous closure producing a sequence of `Event`s.
127+
///
128+
/// - Parameter handler: An asynchronous closure producing an `AsyncSequence` of `Event`s as output.
129+
/// - Returns: An `EffectRouter` that includes a handler for the given `Effect`.
130+
func to<S: AsyncSequence>(
131+
_ handler: @escaping @Sendable () async -> S
132+
) -> EffectRouter<Effect, Event> where S.Element == Event {
133+
to { _, callback in
134+
for try await output in await handler() {
135+
callback(output)
136+
}
137+
}
138+
}
139+
}
140+
141+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
142+
private extension _PartialEffectRouter where EffectParameters: Sendable {
143+
private struct UncheckedSendable<Value>: @unchecked Sendable {
144+
let wrappedValue: Value
145+
}
146+
147+
func to(
148+
_ handler: @escaping @Sendable (EffectParameters, Consumer<Event>) async -> Void
149+
) -> EffectRouter<Effect, Event> {
150+
to { parameters, callback in
151+
let sendableCallback = UncheckedSendable(wrappedValue: callback)
152+
153+
let task = Task {
154+
defer { sendableCallback.wrappedValue.end() }
155+
await handler(parameters, sendableCallback.wrappedValue.send)
156+
}
157+
158+
return AnonymousDisposable {
159+
task.cancel()
160+
}
161+
}
162+
}
163+
164+
func to(
165+
_ handler: @escaping @Sendable (EffectParameters, Consumer<Event>) async throws -> Void
166+
) -> EffectRouter<Effect, Event> {
167+
to { parameters, callback in
168+
let sendableCallback = UncheckedSendable(wrappedValue: callback)
169+
170+
let task = Task {
171+
defer { sendableCallback.wrappedValue.end() }
172+
try await handler(parameters, sendableCallback.wrappedValue.send)
173+
}
174+
175+
return AnonymousDisposable {
176+
task.cancel()
177+
}
178+
}
179+
}
180+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Copyright 2019-2024 Spotify AB.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
@testable import MobiusCore
16+
17+
import Foundation
18+
import Nimble
19+
import Quick
20+
21+
private enum Effect {
22+
case effect1
23+
case effect2(param1: Int)
24+
case effect3(param1: Int, param2: String)
25+
}
26+
27+
private enum Event {
28+
case eventForEffect1
29+
case eventForEffect2
30+
case eventForEffect3
31+
}
32+
33+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
34+
final class EffectRouterDSL_ConcurrencyTests: QuickSpec {
35+
// swiftlint:disable:next function_body_length
36+
override func spec() {
37+
describe("EffectRouter DSL") {
38+
context("routing to a side-effecting function") {
39+
it("supports async handlers") {
40+
let effectPerformedCount = Synchronized(value: 0)
41+
let routerConnection = EffectRouter<Effect, Event>()
42+
.routeCase(Effect.effect1).to { () async in
43+
effectPerformedCount.value += 1
44+
}
45+
.routeCase(Effect.effect2).to { (_: Int) async in
46+
effectPerformedCount.value += 1
47+
}
48+
.routeCase(Effect.effect3).to { (_: (_: Int, _: String)) async in
49+
effectPerformedCount.value += 1
50+
}
51+
.asConnectable
52+
.connect { _ in }
53+
54+
routerConnection.accept(.effect1)
55+
expect(effectPerformedCount.value).toEventually(equal(1))
56+
57+
routerConnection.accept(.effect2(param1: 123))
58+
expect(effectPerformedCount.value).toEventually(equal(2))
59+
60+
routerConnection.accept(.effect3(param1: 123, param2: "Foo"))
61+
expect(effectPerformedCount.value).toEventually(equal(3))
62+
}
63+
64+
it("supports async throwing handlers") {
65+
let effectPerformedCount = Synchronized(value: 0)
66+
let routerConnection = EffectRouter<Effect, Event>()
67+
.routeCase(Effect.effect1).to { () async throws in
68+
effectPerformedCount.value += 1
69+
}
70+
.routeCase(Effect.effect2).to { (_: Int) async throws in
71+
effectPerformedCount.value += 1
72+
}
73+
.routeCase(Effect.effect3).to { (_: (_: Int, _: String)) async throws in
74+
effectPerformedCount.value += 1
75+
}
76+
.asConnectable
77+
.connect { _ in }
78+
79+
routerConnection.accept(.effect1)
80+
expect(effectPerformedCount.value).toEventually(equal(1))
81+
82+
routerConnection.accept(.effect2(param1: 123))
83+
expect(effectPerformedCount.value).toEventually(equal(2))
84+
85+
routerConnection.accept(.effect3(param1: 123, param2: "Foo"))
86+
expect(effectPerformedCount.value).toEventually(equal(3))
87+
}
88+
}
89+
90+
context("routing to a event-returning function") {
91+
it("supports async handlers") {
92+
let events: Synchronized<[Event]> = .init(value: [])
93+
let routerConnection = EffectRouter<Effect, Event>()
94+
.routeCase(Effect.effect1).to { () async -> Event in
95+
.eventForEffect1
96+
}
97+
.routeCase(Effect.effect2).to { (_: Int) async -> Event in
98+
.eventForEffect2
99+
}
100+
.routeCase(Effect.effect3).to { (_: (_: Int, _: String)) async -> Event in
101+
.eventForEffect3
102+
}
103+
.asConnectable
104+
.connect { event in events.mutate { events in events.append(event) } }
105+
106+
routerConnection.accept(.effect1)
107+
expect(events.value).toEventually(equal([.eventForEffect1]))
108+
109+
routerConnection.accept(.effect2(param1: 123))
110+
expect(events.value).toEventually(equal([.eventForEffect1, .eventForEffect2]))
111+
112+
routerConnection.accept(.effect3(param1: 123, param2: "Foo"))
113+
expect(events.value).toEventually(equal([.eventForEffect1, .eventForEffect2, .eventForEffect3]))
114+
}
115+
116+
it("supports async throwing handlers") {
117+
let events: Synchronized<[Event]> = .init(value: [])
118+
let routerConnection = EffectRouter<Effect, Event>()
119+
.routeCase(Effect.effect1).to { () async throws -> Event in
120+
.eventForEffect1
121+
}
122+
.routeCase(Effect.effect2).to { (_: Int) async throws -> Event in
123+
.eventForEffect2
124+
}
125+
.routeCase(Effect.effect3).to { (_: (_: Int, _: String)) async throws -> Event in
126+
.eventForEffect3
127+
}
128+
.asConnectable
129+
.connect { event in events.mutate { events in events.append(event) } }
130+
131+
routerConnection.accept(.effect1)
132+
expect(events.value).toEventually(equal([.eventForEffect1]))
133+
134+
routerConnection.accept(.effect2(param1: 123))
135+
expect(events.value).toEventually(equal([.eventForEffect1, .eventForEffect2]))
136+
137+
routerConnection.accept(.effect3(param1: 123, param2: "Foo"))
138+
expect(events.value).toEventually(equal([.eventForEffect1, .eventForEffect2, .eventForEffect3]))
139+
}
140+
}
141+
142+
context("routing to a sequence-returning function") {
143+
it("supports async handlers") {
144+
let events: Synchronized<[Event]> = .init(value: [])
145+
let routerConnection = EffectRouter<Effect, Event>()
146+
.routeCase(Effect.effect1).to { () async -> AsyncStream<Event> in
147+
AsyncStream { continuation in
148+
continuation.yield(.eventForEffect1)
149+
continuation.yield(.eventForEffect1)
150+
continuation.finish()
151+
}
152+
}
153+
.routeCase(Effect.effect2).to { (_: Int) async -> AsyncStream<Event> in
154+
AsyncStream { continuation in
155+
continuation.yield(.eventForEffect2)
156+
continuation.yield(.eventForEffect2)
157+
continuation.finish()
158+
}
159+
}
160+
.routeCase(Effect.effect3).to { (_: (_: Int, _: String)) async -> AsyncStream<Event> in
161+
AsyncStream { continuation in
162+
continuation.yield(.eventForEffect3)
163+
continuation.yield(.eventForEffect3)
164+
continuation.finish()
165+
}
166+
}
167+
.asConnectable
168+
.connect { event in events.mutate { events in events.append(event) } }
169+
170+
routerConnection.accept(.effect1)
171+
expect(events.value).toEventually(equal([.eventForEffect1, .eventForEffect1]))
172+
173+
routerConnection.accept(.effect2(param1: 123))
174+
expect(events.value).toEventually(equal([.eventForEffect1, .eventForEffect1, .eventForEffect2, .eventForEffect2]))
175+
176+
routerConnection.accept(.effect3(param1: 123, param2: "Foo"))
177+
expect(events.value).toEventually(equal([.eventForEffect1, .eventForEffect1, .eventForEffect2, .eventForEffect2, .eventForEffect3, .eventForEffect3]))
178+
}
179+
}
180+
}
181+
}
182+
}
183+
184+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
185+
private final class UncheckedBox<T>: @unchecked Sendable {
186+
var t: T?
187+
}
188+
189+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
190+
private func asyncExpect<T: Sendable>(
191+
file: FileString = #file,
192+
line: UInt = #line,
193+
_ expression: @autoclosure @escaping @Sendable () -> (() async throws -> T?)
194+
) -> Expectation<T> {
195+
expect(file: file, line: line) {
196+
let box = UncheckedBox<T>()
197+
let semaphore = DispatchSemaphore(value: 0)
198+
Task {
199+
defer { semaphore.signal() }
200+
box.t = try await expression()()
201+
}
202+
semaphore.wait()
203+
204+
return box.t
205+
}
206+
}

0 commit comments

Comments
 (0)