Skip to content

Commit a3fbf06

Browse files
Implement the ability to fetch a room
Part of #19. References to spec are based on [1] at commit aa7455d. I’ve decided to, for now, throw ably-cocoa’s ARTErrorInfo for consistency with JS; created #32 to revisit this later. We have decided (see [3]) that we’re going to try using actors as our mechanism for concurrency-safe management of mutable state. We accept that this will lead to more of the public API needing to be annotated as `async` (as has happened to Rooms.get here), which in some cases might lead to weird-looking API, and have chosen to accept this compromise in order to get the safety checking offered to us by the compiler, and because of developers’ aversion to writing "@unchecked Sendable". We might not have needed to make this compromise if we had access to Swift 6 / iOS 18’s Mutex type, which allows for synchronous management of mutable state in a way that the compiler is happy with. But, none of the decisions here need to be final; we can see how we feel about the API as it evolves and as our knowledge of the language grows. [1] ably/specification#200 [2] ably/ably-cocoa#1962 [3] #33 (comment)
1 parent 202161b commit a3fbf06

File tree

6 files changed

+219
-9
lines changed

6 files changed

+219
-9
lines changed

Sources/AblyChat/Errors.swift

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Ably
2+
3+
/**
4+
The error domain used for the ``Ably.ARTErrorInfo`` error instances thrown by the Ably Chat SDK.
5+
6+
See ``ErrorCode`` for the possible ``ARTErrorInfo.code`` values.
7+
*/
8+
public let errorDomain = "AblyChatErrorDomain"
9+
10+
/**
11+
The error codes for errors in the ``errorDomain`` error domain.
12+
*/
13+
public enum ErrorCode: Int {
14+
/// ``Rooms.get(roomID:options:)`` was called with a different set of room options than was used on a previous call. You must first release the existing room instance using ``Rooms.release(roomID:)``.
15+
///
16+
/// TODO this code is a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32
17+
case inconsistentRoomOptions = 1
18+
19+
/// The ``ARTErrorInfo.statusCode`` that should be returned for this error.
20+
internal var statusCode: Int {
21+
// TODO: These are currently a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32
22+
switch self {
23+
case .inconsistentRoomOptions:
24+
400
25+
}
26+
}
27+
}
28+
29+
/**
30+
The errors thrown by the Chat SDK.
31+
32+
This type exists in addition to ``ErrorCode`` to allow us to attach metadata which can be incorporated into the error’s `localizedDescription`.
33+
*/
34+
internal enum ChatError {
35+
case inconsistentRoomOptions(requested: RoomOptions, existing: RoomOptions)
36+
37+
/// The ``ARTErrorInfo.code`` that should be returned for this error.
38+
internal var code: ErrorCode {
39+
switch self {
40+
case .inconsistentRoomOptions:
41+
.inconsistentRoomOptions
42+
}
43+
}
44+
45+
/// The ``ARTErrorInfo.localizedDescription`` that should be returned for this error.
46+
internal var localizedDescription: String {
47+
switch self {
48+
case let .inconsistentRoomOptions(requested, existing):
49+
"Rooms.get(roomID:options:) was called with a different set of room options than was used on a previous call. You must first release the existing room instance using Rooms.release(roomID:). Requested options: \(requested), existing options: \(existing)"
50+
}
51+
}
52+
}
53+
54+
internal extension ARTErrorInfo {
55+
convenience init(chatError: ChatError) {
56+
var userInfo: [String: Any] = [:]
57+
// TODO: copied and pasted from implementation of -[ARTErrorInfo createWithCode:status:message:requestId:] because there’s no way to pass domain; revisit in https://github.com/ably-labs/ably-chat-swift/issues/32. Also the ARTErrorInfoStatusCode variable in ably-cocoa is not public.
58+
userInfo["ARTErrorInfoStatusCode"] = chatError.code.statusCode
59+
userInfo[NSLocalizedDescriptionKey] = chatError.localizedDescription
60+
61+
self.init(
62+
domain: errorDomain,
63+
code: chatError.code.rawValue,
64+
userInfo: userInfo
65+
)
66+
}
67+
}

Sources/AblyChat/Room.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Ably
2+
13
public protocol Room: AnyObject, Sendable {
24
var roomID: String { get }
35
var messages: any Messages { get }
@@ -14,3 +16,49 @@ public protocol Room: AnyObject, Sendable {
1416
func detach() async throws
1517
var options: RoomOptions { get }
1618
}
19+
20+
internal actor DefaultRoom: Room {
21+
nonisolated internal let roomID: String
22+
nonisolated internal let options: RoomOptions
23+
24+
// Exposed for testing.
25+
nonisolated internal let realtime: any ARTRealtimeProtocol
26+
27+
internal init(realtime: any ARTRealtimeProtocol, roomID: String, options: RoomOptions) {
28+
self.realtime = realtime
29+
self.roomID = roomID
30+
self.options = options
31+
}
32+
33+
public nonisolated var messages: any Messages {
34+
fatalError("Not yet implemented")
35+
}
36+
37+
public nonisolated var presence: any Presence {
38+
fatalError("Not yet implemented")
39+
}
40+
41+
public nonisolated var reactions: any RoomReactions {
42+
fatalError("Not yet implemented")
43+
}
44+
45+
public nonisolated var typing: any Typing {
46+
fatalError("Not yet implemented")
47+
}
48+
49+
public nonisolated var occupancy: any Occupancy {
50+
fatalError("Not yet implemented")
51+
}
52+
53+
public nonisolated var status: any RoomStatus {
54+
fatalError("Not yet implemented")
55+
}
56+
57+
public func attach() async throws {
58+
fatalError("Not yet implemented")
59+
}
60+
61+
public func detach() async throws {
62+
fatalError("Not yet implemented")
63+
}
64+
}

Sources/AblyChat/RoomOptions.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Foundation
22

3-
public struct RoomOptions: Sendable {
3+
public struct RoomOptions: Sendable, Equatable {
44
public var presence: PresenceOptions?
55
public var typing: TypingOptions?
66
public var reactions: RoomReactionsOptions?
@@ -14,7 +14,7 @@ public struct RoomOptions: Sendable {
1414
}
1515
}
1616

17-
public struct PresenceOptions: Sendable {
17+
public struct PresenceOptions: Sendable, Equatable {
1818
public var enter = true
1919
public var subscribe = true
2020

@@ -24,18 +24,18 @@ public struct PresenceOptions: Sendable {
2424
}
2525
}
2626

27-
public struct TypingOptions: Sendable {
27+
public struct TypingOptions: Sendable, Equatable {
2828
public var timeout: TimeInterval = 10
2929

3030
public init(timeout: TimeInterval = 10) {
3131
self.timeout = timeout
3232
}
3333
}
3434

35-
public struct RoomReactionsOptions: Sendable {
35+
public struct RoomReactionsOptions: Sendable, Equatable {
3636
public init() {}
3737
}
3838

39-
public struct OccupancyOptions: Sendable {
39+
public struct OccupancyOptions: Sendable, Equatable {
4040
public init() {}
4141
}

Sources/AblyChat/Rooms.swift

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import Ably
1+
@preconcurrency import Ably
22

33
public protocol Rooms: AnyObject, Sendable {
4-
func get(roomID: String, options: RoomOptions) throws -> any Room
4+
func get(roomID: String, options: RoomOptions) async throws -> any Room
55
func release(roomID: String) async throws
66
var clientOptions: ClientOptions { get }
77
}
@@ -11,13 +11,29 @@ internal actor DefaultRooms: Rooms {
1111
internal nonisolated let realtime: ARTRealtimeProtocol
1212
internal nonisolated let clientOptions: ClientOptions
1313

14+
/// The set of rooms, keyed by room ID.
15+
private var rooms: [String: DefaultRoom] = [:]
16+
1417
internal init(realtime: ARTRealtimeProtocol, clientOptions: ClientOptions) {
1518
self.realtime = realtime
1619
self.clientOptions = clientOptions
1720
}
1821

19-
internal nonisolated func get(roomID _: String, options _: RoomOptions) throws -> any Room {
20-
fatalError("Not yet implemented")
22+
internal func get(roomID: String, options: RoomOptions) throws -> any Room {
23+
// CHA-RC1b
24+
if let existingRoom = rooms[roomID] {
25+
if existingRoom.options != options {
26+
throw ARTErrorInfo(
27+
chatError: .inconsistentRoomOptions(requested: options, existing: existingRoom.options)
28+
)
29+
}
30+
31+
return existingRoom
32+
} else {
33+
let room = DefaultRoom(realtime: realtime, roomID: roomID, options: options)
34+
rooms[roomID] = room
35+
return room
36+
}
2137
}
2238

2339
internal func release(roomID _: String) async throws {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
@testable import AblyChat
2+
import XCTest
3+
4+
class DefaultRoomsTests: XCTestCase {
5+
// @spec CHA-RC1a
6+
func test_get_returnsRoomWithGivenID() async throws {
7+
// Given: an instance of DefaultRooms
8+
let realtime = MockRealtime.create()
9+
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init())
10+
11+
// When: get(roomID:options:) is called
12+
let roomID = "basketball"
13+
let options = RoomOptions()
14+
let room = try await rooms.get(roomID: roomID, options: options)
15+
16+
// Then: It returns a DefaultRoom instance that uses the same Realtime instance, with the given ID and options
17+
let defaultRoom = try XCTUnwrap(room as? DefaultRoom)
18+
XCTAssertIdentical(defaultRoom.realtime, realtime)
19+
XCTAssertEqual(defaultRoom.roomID, roomID)
20+
XCTAssertEqual(defaultRoom.options, options)
21+
}
22+
23+
// @spec CHA-RC1b
24+
func test_get_returnsExistingRoomWithGivenID() async throws {
25+
// Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID
26+
let realtime = MockRealtime.create()
27+
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init())
28+
29+
let roomID = "basketball"
30+
let options = RoomOptions()
31+
let firstRoom = try await rooms.get(roomID: roomID, options: options)
32+
33+
// When: get(roomID:options:) is called with the same room ID
34+
let secondRoom = try await rooms.get(roomID: roomID, options: options)
35+
36+
// Then: It returns the same room object
37+
XCTAssertIdentical(secondRoom, firstRoom)
38+
}
39+
40+
// @spec CHA-RC1c
41+
func test_get_throwsErrorWhenOptionsDoNotMatch() async throws {
42+
// Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options
43+
let realtime = MockRealtime.create()
44+
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init())
45+
46+
let roomID = "basketball"
47+
let options = RoomOptions()
48+
_ = try await rooms.get(roomID: roomID, options: options)
49+
50+
// When: get(roomID:options:) is called with the same ID but different options
51+
let differentOptions = RoomOptions(presence: .init(subscribe: false))
52+
53+
let caughtError: Error?
54+
do {
55+
_ = try await rooms.get(roomID: roomID, options: differentOptions)
56+
caughtError = nil
57+
} catch {
58+
caughtError = error
59+
}
60+
61+
// Then: It throws an inconsistentRoomOptions error
62+
try assertIsChatError(caughtError, withCode: .inconsistentRoomOptions)
63+
}
64+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Ably
2+
@testable import AblyChat
3+
import XCTest
4+
5+
/**
6+
Asserts that a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code.
7+
*/
8+
func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, file: StaticString = #filePath, line: UInt = #line) throws {
9+
let error = try XCTUnwrap(maybeError, "Expected an error", file: file, line: line)
10+
let ablyError = try XCTUnwrap(error as? ARTErrorInfo, "Expected an ARTErrorInfo", file: file, line: line)
11+
12+
XCTAssertEqual(ablyError.domain, AblyChat.errorDomain as String, file: file, line: line)
13+
XCTAssertEqual(ablyError.code, code.rawValue, file: file, line: line)
14+
XCTAssertEqual(ablyError.statusCode, code.statusCode, file: file, line: line)
15+
}

0 commit comments

Comments
 (0)