Skip to content

Commit b1be2bf

Browse files
authored
Remove generic parameter from MessageSubscriptionResponseAsyncSequence (#476)
The type was impossible for users to spell due to Swift compiler limitations with opaque types. historyBeforeSubscribe now returns `any PaginatedResult<Message>`. RFC: https://ably.atlassian.net/wiki/spaces/CHA/pages/4647747585/CHARFC-032+Chat+Swift+Concrete+MessageSubscription
1 parent f6973ee commit b1be2bf

File tree

3 files changed

+100
-12
lines changed

3 files changed

+100
-12
lines changed

Sources/AblyChat/Messages.swift

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,17 @@ public extension Messages {
125125
*
126126
* - Returns: A subscription ``MessageSubscription`` that can be used to iterate through new messages.
127127
*/
128-
func subscribe(bufferingPolicy: BufferingPolicy) -> MessageSubscriptionResponseAsyncSequence<SubscribeResponse.HistoryResult> {
128+
func subscribe(bufferingPolicy: BufferingPolicy) -> MessageSubscriptionResponseAsyncSequence {
129129
var emitEvent: ((ChatMessageEvent) -> Void)?
130130
let subscription = subscribe { event in
131131
emitEvent?(event)
132132
}
133133

134134
let subscriptionAsyncSequence = MessageSubscriptionResponseAsyncSequence(
135135
bufferingPolicy: bufferingPolicy,
136-
historyBeforeSubscribe: subscription.historyBeforeSubscribe,
137-
)
136+
) { params throws(ErrorInfo) in
137+
try await subscription.historyBeforeSubscribe(withParams: params)
138+
}
138139
emitEvent = { [weak subscriptionAsyncSequence] event in
139140
subscriptionAsyncSequence?.emit(event)
140141
}
@@ -149,7 +150,7 @@ public extension Messages {
149150
}
150151

151152
/// Same as calling ``subscribe(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``.
152-
func subscribe() -> MessageSubscriptionResponseAsyncSequence<SubscribeResponse.HistoryResult> {
153+
func subscribe() -> MessageSubscriptionResponseAsyncSequence {
153154
subscribe(bufferingPolicy: .unbounded)
154155
}
155156
}
@@ -409,27 +410,43 @@ public struct ChatMessageEvent: Sendable {
409410
/// A non-throwing `AsyncSequence` whose element is ``ChatMessageEvent``. The Chat SDK uses this type as the return value of the `AsyncSequence` convenience variants of the ``Messages`` methods that allow you to find out about received chat messages.
410411
///
411412
/// You should only iterate over a given `MessageSubscriptionResponseAsyncSequence` once; the results of iterating more than once are undefined.
412-
public final class MessageSubscriptionResponseAsyncSequence<HistoryResult: PaginatedResult<Message>>: Sendable, AsyncSequence {
413+
public final class MessageSubscriptionResponseAsyncSequence: Sendable, AsyncSequence {
414+
/*
415+
Design note: Unlike other SDK types, this is a concrete class rather than a protocol. Ideally, for
416+
consistency with the rest of the SDK, this would be a protocol that users could reference as
417+
`any MessageSubscriptionResponseAsyncSequence`. However, creating an AsyncSequence-inheriting protocol
418+
that allows iteration without `try` on an existential requires the `Failure` associated type, which is
419+
only available in iOS 18+.
420+
421+
Additionally, this class intentionally has no generic parameters, even though the underlying implementation
422+
uses concrete PaginatedResult types. This is because the type is impossible for users to spell due to Swift
423+
compiler limitations with opaque types — attempting to write
424+
`MessageSubscriptionResponseAsyncSequence<ChatClient.Rooms.Room.Messages.HistoryResult>` fails with
425+
"Underlying type for opaque result type 'some Rooms<...>' could not be inferred from return expression".
426+
By removing the generic parameter and having `historyBeforeSubscribe(withParams:)` return
427+
`any PaginatedResult<Message>`, users can store subscriptions as simple properties
428+
(e.g., `var subscription: MessageSubscriptionResponseAsyncSequence?`).
429+
*/
413430
// swiftlint:disable:next missing_docs
414431
public typealias Element = ChatMessageEvent
415432

416433
private let subscription: SubscriptionAsyncSequence<Element>
417434

418435
// can be set by either initialiser
419-
private let historyBeforeSubscribe: @Sendable (HistoryBeforeSubscribeParams) async throws(ErrorInfo) -> HistoryResult
436+
private let historyBeforeSubscribe: @Sendable (HistoryBeforeSubscribeParams) async throws(ErrorInfo) -> any PaginatedResult<Message>
420437

421438
// used internally
422439
internal init(
423440
bufferingPolicy: BufferingPolicy,
424-
historyBeforeSubscribe: @escaping @Sendable (HistoryBeforeSubscribeParams) async throws(ErrorInfo) -> HistoryResult,
441+
historyBeforeSubscribe: @escaping @Sendable (HistoryBeforeSubscribeParams) async throws(ErrorInfo) -> any PaginatedResult<Message>,
425442
) {
426443
subscription = .init(bufferingPolicy: bufferingPolicy)
427444
self.historyBeforeSubscribe = historyBeforeSubscribe
428445
}
429446

430447
// used for testing
431448
// swiftlint:disable:next missing_docs
432-
public init<Underlying: AsyncSequence & Sendable>(mockAsyncSequence: Underlying, mockHistoryBeforeSubscribe: @escaping @Sendable (HistoryBeforeSubscribeParams) async throws(ErrorInfo) -> HistoryResult) where Underlying.Element == Element {
449+
public init<Underlying: AsyncSequence & Sendable>(mockAsyncSequence: Underlying, mockHistoryBeforeSubscribe: @escaping @Sendable (HistoryBeforeSubscribeParams) async throws(ErrorInfo) -> any PaginatedResult<Message>) where Underlying.Element == Element {
433450
subscription = .init(mockAsyncSequence: mockAsyncSequence)
434451
historyBeforeSubscribe = mockHistoryBeforeSubscribe
435452
}
@@ -444,7 +461,7 @@ public final class MessageSubscriptionResponseAsyncSequence<HistoryResult: Pagin
444461
}
445462

446463
// swiftlint:disable:next missing_docs
447-
public func historyBeforeSubscribe(withParams params: HistoryBeforeSubscribeParams) async throws(ErrorInfo) -> HistoryResult {
464+
public func historyBeforeSubscribe(withParams params: HistoryBeforeSubscribeParams) async throws(ErrorInfo) -> any PaginatedResult<Message> {
448465
try await historyBeforeSubscribe(params)
449466
}
450467

Tests/AblyChatTests/MessageSubscriptionResponseAsyncSequenceTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ struct MessageSubscriptionResponseAsyncSequenceTests {
3434
@Test
3535
func withMockAsyncSequence() async {
3636
let events = messages.map { ChatMessageEvent(message: $0) }
37-
let subscription = MessageSubscriptionResponseAsyncSequence<MockPaginatedResult>(mockAsyncSequence: events.async) { _ in fatalError("Not implemented") }
37+
let subscription = MessageSubscriptionResponseAsyncSequence(mockAsyncSequence: events.async) { _ in fatalError("Not implemented") }
3838
#expect(await Array(subscription.prefix(2)).map(\.message.text) == ["First", "Second"])
3939
}
4040

4141
@Test
4242
func emit() async {
43-
let subscription = MessageSubscriptionResponseAsyncSequence<MockPaginatedResult>(bufferingPolicy: .unbounded) { _ in fatalError("Not implemented") }
43+
let subscription = MessageSubscriptionResponseAsyncSequence(bufferingPolicy: .unbounded) { _ in fatalError("Not implemented") }
4444
async let emittedElements = Array(subscription.prefix(2))
4545
subscription.emit(ChatMessageEvent(message: messages[0]))
4646
subscription.emit(ChatMessageEvent(message: messages[1]))
@@ -54,6 +54,6 @@ struct MessageSubscriptionResponseAsyncSequenceTests {
5454
let subscription = MessageSubscriptionResponseAsyncSequence(mockAsyncSequence: [].async) { _ in mockPaginatedResult }
5555

5656
let result = try await subscription.historyBeforeSubscribe(withParams: .init())
57-
#expect(result === mockPaginatedResult)
57+
#expect(result as AnyObject === mockPaginatedResult as AnyObject)
5858
}
5959
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
@testable import AblyChat
2+
import AsyncAlgorithms
3+
import Foundation
4+
import Testing
5+
6+
/// Tests proving that MessageSubscriptionResponseAsyncSequence can be stored as a property without needing to specify generic parameters.
7+
struct MessageSubscriptionStorageTests {
8+
// MARK: - Can store as property
9+
10+
@Test
11+
func canStoreSubscriptionAsProperty() async {
12+
final class ChatManager {
13+
var subscription: MessageSubscriptionResponseAsyncSequence?
14+
}
15+
16+
let manager = ChatManager()
17+
let mockSubscription = MessageSubscriptionResponseAsyncSequence(
18+
mockAsyncSequence: [].async,
19+
) { _ in
20+
fatalError("Not implemented")
21+
}
22+
23+
// Can assign
24+
manager.subscription = mockSubscription
25+
#expect(manager.subscription != nil)
26+
27+
// Can clear
28+
manager.subscription = nil
29+
#expect(manager.subscription == nil)
30+
}
31+
32+
// MARK: - Can iterate without try
33+
34+
/// This test proves that users can iterate over a stored subscription WITHOUT using `try`.
35+
@Test
36+
func canIterateWithoutTry() async {
37+
final class ChatManager {
38+
var subscription: MessageSubscriptionResponseAsyncSequence?
39+
40+
// This method is `async` not `async throws`
41+
// It would fail to compile if `try` was needed to iterate
42+
func processMessages() async -> [String] {
43+
guard let subscription else {
44+
return []
45+
}
46+
47+
var receivedTexts: [String] = []
48+
for await event in subscription {
49+
receivedTexts.append(event.message.text)
50+
}
51+
return receivedTexts
52+
}
53+
}
54+
55+
let messages = [
56+
Message(serial: "1", action: .messageCreate, clientID: "user1", text: "Hello", metadata: [:], headers: [:], version: .init(serial: "1", timestamp: Date()), timestamp: Date(), reactions: .empty),
57+
Message(serial: "2", action: .messageCreate, clientID: "user2", text: "World", metadata: [:], headers: [:], version: .init(serial: "2", timestamp: Date()), timestamp: Date(), reactions: .empty),
58+
]
59+
let events = messages.map { ChatMessageEvent(message: $0) }
60+
61+
let manager = ChatManager()
62+
manager.subscription = MessageSubscriptionResponseAsyncSequence(
63+
mockAsyncSequence: events.async,
64+
) { _ in
65+
fatalError("Not implemented")
66+
}
67+
68+
let receivedTexts = await manager.processMessages()
69+
#expect(receivedTexts == ["Hello", "World"])
70+
}
71+
}

0 commit comments

Comments
 (0)