Skip to content

Commit

Permalink
Implement the ability to fetch a room
Browse files Browse the repository at this point in the history
Part of #19. References to spec are based on [1] at commit aa7455d.

The @preconcurrency imports of ably-cocoa are temporary and will be
removed once [2] is done; created #31 for tracking.

I’ve decided to, for now, throw ably-cocoa’s ARTErrorInfo for
consistency with JS; created #32 to revisit this later.

[1] ably/specification#200
[2] ably/ably-cocoa#1962
  • Loading branch information
lawrence-forooghian committed Aug 29, 2024
1 parent bc06747 commit 6ab6ad3
Show file tree
Hide file tree
Showing 8 changed files with 268 additions and 6 deletions.
67 changes: 67 additions & 0 deletions Sources/AblyChat/Errors.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Ably

/**
The error domain used for the ``Ably.ARTErrorInfo`` error instances thrown by the Ably Chat SDK.

See ``ErrorCode`` for the possible ``ARTErrorInfo.code`` values.
*/
public let errorDomain = "AblyChatErrorDomain"

/**
The error codes for errors in the ``errorDomain`` error domain.
*/
public enum ErrorCode: Int {
/// ``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:)``.
///
/// TODO this code is a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32
case inconsistentRoomOptions = 1

/// The ``ARTErrorInfo.statusCode`` that should be returned for this error.
internal var statusCode: Int {
// TODO: These are currently a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32
switch self {
case .inconsistentRoomOptions:
400
}
}
}

/**
The errors thrown by the Chat SDK.

This type exists in addition to ``ErrorCode`` to allow us to attach metadata which can be incorporated into the error’s `localizedDescription`.
*/
internal enum ChatError {
case inconsistentRoomOptions(requested: RoomOptions, existing: RoomOptions)

/// The ``ARTErrorInfo.code`` that should be returned for this error.
internal var code: ErrorCode {
switch self {
case .inconsistentRoomOptions:
.inconsistentRoomOptions
}
}

/// The ``ARTErrorInfo.localizedDescription`` that should be returned for this error.
internal var localizedDescription: String {
switch self {
case let .inconsistentRoomOptions(requested, existing):
"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)"
}
}
}

internal extension ARTErrorInfo {
convenience init(chatError: ChatError) {
var userInfo: [String: Any] = [:]
// 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.
userInfo["ARTErrorInfoStatusCode"] = chatError.code.statusCode
userInfo[NSLocalizedDescriptionKey] = chatError.localizedDescription

self.init(
domain: errorDomain,
code: chatError.code.rawValue,
userInfo: userInfo
)
}
}
48 changes: 48 additions & 0 deletions Sources/AblyChat/Room.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@preconcurrency import Ably

public protocol Room: AnyObject, Sendable {
var roomID: String { get }
var messages: any Messages { get }
Expand All @@ -14,3 +16,49 @@ public protocol Room: AnyObject, Sendable {
func detach() async throws
var options: RoomOptions { get }
}

public final class DefaultRoom: Room {
public let roomID: String
public let options: RoomOptions

// Exposed for testing.
internal let realtime: any ARTRealtimeProtocol

internal init(realtime: any ARTRealtimeProtocol, roomID: String, options: RoomOptions) {
self.realtime = realtime
self.roomID = roomID
self.options = options
}

public var messages: any Messages {
fatalError("Not yet implemented")
}

public var presence: any Presence {
fatalError("Not yet implemented")
}

public var reactions: any RoomReactions {
fatalError("Not yet implemented")
}

public var typing: any Typing {
fatalError("Not yet implemented")
}

public var occupancy: any Occupancy {
fatalError("Not yet implemented")
}

public var status: any RoomStatus {
fatalError("Not yet implemented")
}

public func attach() async throws {
fatalError("Not yet implemented")
}

public func detach() async throws {
fatalError("Not yet implemented")
}
}
10 changes: 5 additions & 5 deletions Sources/AblyChat/RoomOptions.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

public struct RoomOptions: Sendable {
public struct RoomOptions: Sendable, Equatable {
public var presence: PresenceOptions?
public var typing: TypingOptions?
public var reactions: RoomReactionsOptions?
Expand All @@ -14,7 +14,7 @@ public struct RoomOptions: Sendable {
}
}

public struct PresenceOptions: Sendable {
public struct PresenceOptions: Sendable, Equatable {
public var enter = true
public var subscribe = true

Expand All @@ -24,18 +24,18 @@ public struct PresenceOptions: Sendable {
}
}

public struct TypingOptions: Sendable {
public struct TypingOptions: Sendable, Equatable {
public var timeout: TimeInterval = 10

public init(timeout: TimeInterval = 10) {
self.timeout = timeout
}
}

public struct RoomReactionsOptions: Sendable {
public struct RoomReactionsOptions: Sendable, Equatable {
public init() {}
}

public struct OccupancyOptions: Sendable {
public struct OccupancyOptions: Sendable, Equatable {
public init() {}
}
59 changes: 59 additions & 0 deletions Sources/AblyChat/Rooms.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,64 @@
@preconcurrency import Ably

public protocol Rooms: AnyObject, Sendable {
func get(roomID: String, options: RoomOptions) throws -> any Room
func release(roomID: String) async throws
var clientOptions: ClientOptions { get }
}

internal final class DefaultRooms: Rooms {
private let realtime: ARTRealtimeProtocol
private let storage = Storage()

internal init(realtime: ARTRealtimeProtocol) {
self.realtime = realtime
}

/// Thread-safe storage of the set of rooms.
private class Storage: @unchecked Sendable {
/// Mutex used to synchronize access to ``rooms``.
private let lock = NSLock()

/// The set of rooms, keyed by room ID. Only access whilst holding ``lock``.
private var rooms: [String: DefaultRoom] = [:]

/// If there is an existing room with the given ID, returns it. Else creates a new room with the given ID and options.
public func getOrCreate(realtime: any ARTRealtimeProtocol, roomID: String, options: RoomOptions) -> DefaultRoom {
let room: DefaultRoom
lock.lock()
// CHA-RC1b
if let existingRoom = rooms[roomID] {
room = existingRoom
} else {
room = DefaultRoom(realtime: realtime, roomID: roomID, options: options)
rooms[roomID] = room
}
lock.unlock()
return room
}
}

internal func get(roomID: String, options: RoomOptions) throws -> any Room {
let room = storage.getOrCreate(
realtime: realtime,
roomID: roomID,
options: options
)

if room.options != options {
throw ARTErrorInfo(
chatError: .inconsistentRoomOptions(requested: options, existing: room.options)
)
}

return room
}

internal func release(roomID _: String) async throws {
fatalError("Not yet implemented")
}

internal var clientOptions: ClientOptions {
fatalError("Not yet implemented")
}
}
2 changes: 1 addition & 1 deletion Tests/AblyChatTests/AblyChatTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import XCTest

final class AblyChatTests: XCTestCase {
func testExample() throws {
XCTAssertNoThrow(DefaultChatClient(realtime: MockRealtime(key: ""), clientOptions: ClientOptions()))
XCTAssertNoThrow(DefaultChatClient(realtime: MockRealtime.create(), clientOptions: ClientOptions()))
}
}
64 changes: 64 additions & 0 deletions Tests/AblyChatTests/DefaultRoomsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
@testable import AblyChat
import XCTest

class DefaultRoomsTests: XCTestCase {
// @spec CHA-RC1a
func test_get_returnsRoomWithGivenID() throws {
// Given: an instance of DefaultRooms
let realtime = MockRealtime.create()
let rooms = DefaultRooms(realtime: realtime)

// When: get(roomID:options:) is called
let roomID = "basketball"
let options = RoomOptions()
let room = try rooms.get(roomID: roomID, options: options)

// Then: It returns a DefaultRoom instance that uses the same Realtime instance, with the given ID and options
let defaultRoom = try XCTUnwrap(room as? DefaultRoom)
XCTAssertIdentical(defaultRoom.realtime, realtime)
XCTAssertEqual(defaultRoom.roomID, roomID)
XCTAssertEqual(defaultRoom.options, options)
}

// @spec CHA-RC1b
func test_get_returnsExistingRoomWithGivenID() throws {
// Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID
let realtime = MockRealtime.create()
let rooms = DefaultRooms(realtime: realtime)

let roomID = "basketball"
let options = RoomOptions()
let firstRoom = try rooms.get(roomID: roomID, options: options)

// When: get(roomID:options:) is called with the same room ID
let secondRoom = try rooms.get(roomID: roomID, options: options)

// Then: It returns the same room object
XCTAssertIdentical(secondRoom, firstRoom)
}

// @spec CHA-RC1c
func test_get_throwsErrorWhenOptionsDoNotMatch() throws {
// Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options
let realtime = MockRealtime.create()
let rooms = DefaultRooms(realtime: realtime)

let roomID = "basketball"
let options = RoomOptions()
_ = try rooms.get(roomID: roomID, options: options)

// When: get(roomID:options:) is called with the same ID but different options
let differentOptions = RoomOptions(presence: .init(subscribe: false))

let caughtError: Error?
do {
_ = try rooms.get(roomID: roomID, options: differentOptions)
caughtError = nil
} catch {
caughtError = error
}

// Then: It throws an inconsistentRoomOptions error
try assertIsChatError(caughtError, withCode: .inconsistentRoomOptions)
}
}
15 changes: 15 additions & 0 deletions Tests/AblyChatTests/Helpers/Helpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Ably
@testable import AblyChat
import XCTest

/**
Asserts that a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code.
*/
func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, file: StaticString = #filePath, line: UInt = #line) throws {
let error = try XCTUnwrap(maybeError, "Expected an error", file: file, line: line)
let ablyError = try XCTUnwrap(error as? ARTErrorInfo, "Expected an ARTErrorInfo", file: file, line: line)

XCTAssertEqual(ablyError.domain, AblyChat.errorDomain as String, file: file, line: line)
XCTAssertEqual(ablyError.code, code.rawValue, file: file, line: line)
XCTAssertEqual(ablyError.statusCode, code.statusCode, file: file, line: line)
}
9 changes: 9 additions & 0 deletions Tests/AblyChatTests/Mocks/MockRealtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ class MockRealtime: NSObject, ARTRealtimeProtocol {

required init(token _: String) {}

/**
Creates an instance of MockRealtime.

This exists to give a convenient way to create an instance, because `init` is marked as unavailable in `ARTRealtimeProtocol`.
*/
static func create() -> MockRealtime {
MockRealtime(key: "")
}

func time(_: @escaping ARTDateTimeCallback) {
fatalError("Not implemented")
}
Expand Down

0 comments on commit 6ab6ad3

Please sign in to comment.