diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d7ccdf2..406c8662 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,19 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` - When defining a `struct` that is emitted by the public API of the library, make sure to define a public memberwise initializer so that users can create one to be emitted by their mocks. (There is no way to make Swift’s autogenerated memberwise initializer public, so you will need to write one yourself. In Xcode, you can do this by clicking at the start of the type declaration and doing Editor → Refactor → Generate Memberwise Initializer.) - When writing code that implements behaviour specified by the Chat SDK features spec, add a comment that references the identifier of the relevant spec item. +### Throwing errors + +- The public API of the SDK should use typed throws, and the thrown errors should be of type `ARTErrorInfo`. +- Currently, we throw the `InternalError` type everywhere internally, and then convert it to `ARTErrorInfo` at the public API. This allows us to use richer Swift errors for our internals. + +If you haven't worked with typed throws before, be aware of a few sharp edges: + +- Some of the Swift standard library does not (yet?) interact as nicely with typed throws as you might hope. + - It is not currently possible to create a `Task`, `CheckedContinuation`, or `AsyncThrowingStream` with a specific error type. You will need to instead return a `Result` and then call its `.get()` method. + - `Dictionary.mapValues` does not support typed throws. We have our own extension `ablyChat_mapValuesWithTypedThrow` which does; use this. +- There are times when the compiler struggles to infer the type of the error thrown within a `do` block. In these cases, you can disable type inference for a `do` block and explicitly specify the type of the thrown error, like: `do throws(InternalError) { … }`. +- The compiler will never infer the type of the error thrown by a closure; you will need to specify this yourself; e.g. `let items = try jsonValues.map { jsonValue throws(InternalError) in … }`. + ### Testing guidelines #### Exposing test-only APIs diff --git a/Example/AblyChatExample/Mocks/MockClients.swift b/Example/AblyChatExample/Mocks/MockClients.swift index 76423fb8..2b660c0d 100644 --- a/Example/AblyChatExample/Mocks/MockClients.swift +++ b/Example/AblyChatExample/Mocks/MockClients.swift @@ -23,7 +23,7 @@ actor MockRooms: Rooms { let clientOptions: ChatClientOptions private var rooms = [String: MockRoom]() - func get(roomID: String, options: RoomOptions) async throws -> any Room { + func get(roomID: String, options: RoomOptions) async throws(ARTErrorInfo) -> any Room { if let room = rooms[roomID] { return room } @@ -66,11 +66,11 @@ actor MockRoom: Room { private let mockSubscriptions = MockSubscriptionStorage() - func attach() async throws { + func attach() async throws(ARTErrorInfo) { print("Mock client attached to room with roomID: \(roomID)") } - func detach() async throws { + func detach() async throws(ARTErrorInfo) { fatalError("Not yet implemented") } @@ -122,11 +122,11 @@ actor MockMessages: Messages { } } - func get(options _: QueryOptions) async throws -> any PaginatedResult { + func get(options _: QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult { MockMessagesPaginatedResult(clientID: clientID, roomID: roomID) } - func send(params: SendMessageParams) async throws -> Message { + func send(params: SendMessageParams) async throws(ARTErrorInfo) -> Message { let message = Message( serial: "\(Date().timeIntervalSince1970)", action: .create, @@ -144,7 +144,7 @@ actor MockMessages: Messages { return message } - func update(newMessage: Message, description _: String?, metadata _: OperationMetadata?) async throws -> Message { + func update(newMessage: Message, description _: String?, metadata _: OperationMetadata?) async throws(ARTErrorInfo) -> Message { let message = Message( serial: newMessage.serial, action: .update, @@ -162,7 +162,7 @@ actor MockMessages: Messages { return message } - func delete(message: Message, params _: DeleteMessageParams) async throws -> Message { + func delete(message: Message, params _: DeleteMessageParams) async throws(ARTErrorInfo) -> Message { let message = Message( serial: message.serial, action: .delete, @@ -211,7 +211,7 @@ actor MockRoomReactions: RoomReactions { }, interval: Double.random(in: 0.3 ... 0.6)) } - func send(params: SendReactionParams) async throws { + func send(params: SendReactionParams) async throws(ARTErrorInfo) { let reaction = Reaction( type: params.type, metadata: [:], @@ -258,15 +258,15 @@ actor MockTyping: Typing { .init(mockAsyncSequence: createSubscription()) } - func get() async throws -> Set { + func get() async throws(ARTErrorInfo) -> Set { Set(MockStrings.names.shuffled().prefix(2)) } - func start() async throws { + func start() async throws(ARTErrorInfo) { mockSubscriptions.emit(TypingEvent(currentlyTyping: [clientID])) } - func stop() async throws { + func stop() async throws(ARTErrorInfo) { mockSubscriptions.emit(TypingEvent(currentlyTyping: [])) } @@ -297,7 +297,7 @@ actor MockPresence: Presence { }, interval: 5) } - func get() async throws -> [PresenceMember] { + func get() async throws(ARTErrorInfo) -> [PresenceMember] { MockStrings.names.shuffled().map { name in PresenceMember( clientID: name, @@ -309,7 +309,7 @@ actor MockPresence: Presence { } } - func get(params _: PresenceQuery) async throws -> [PresenceMember] { + func get(params _: PresenceQuery) async throws(ARTErrorInfo) -> [PresenceMember] { MockStrings.names.shuffled().map { name in PresenceMember( clientID: name, @@ -321,19 +321,19 @@ actor MockPresence: Presence { } } - func isUserPresent(clientID _: String) async throws -> Bool { + func isUserPresent(clientID _: String) async throws(ARTErrorInfo) -> Bool { fatalError("Not yet implemented") } - func enter() async throws { + func enter() async throws(ARTErrorInfo) { try await enter(dataForEvent: nil) } - func enter(data: PresenceData) async throws { + func enter(data: PresenceData) async throws(ARTErrorInfo) { try await enter(dataForEvent: data) } - private func enter(dataForEvent: PresenceData?) async throws { + private func enter(dataForEvent: PresenceData?) async throws(ARTErrorInfo) { mockSubscriptions.emit( PresenceEvent( action: .enter, @@ -344,15 +344,15 @@ actor MockPresence: Presence { ) } - func update() async throws { + func update() async throws(ARTErrorInfo) { try await update(dataForEvent: nil) } - func update(data: PresenceData) async throws { + func update(data: PresenceData) async throws(ARTErrorInfo) { try await update(dataForEvent: data) } - private func update(dataForEvent: PresenceData? = nil) async throws { + private func update(dataForEvent: PresenceData? = nil) async throws(ARTErrorInfo) { mockSubscriptions.emit( PresenceEvent( action: .update, @@ -363,15 +363,15 @@ actor MockPresence: Presence { ) } - func leave() async throws { + func leave() async throws(ARTErrorInfo) { try await leave(dataForEvent: nil) } - func leave(data: PresenceData) async throws { + func leave(data: PresenceData) async throws(ARTErrorInfo) { try await leave(dataForEvent: data) } - func leave(dataForEvent: PresenceData? = nil) async throws { + func leave(dataForEvent: PresenceData? = nil) async throws(ARTErrorInfo) { mockSubscriptions.emit( PresenceEvent( action: .leave, @@ -419,7 +419,7 @@ actor MockOccupancy: Occupancy { .init(mockAsyncSequence: createSubscription()) } - func get() async throws -> OccupancyEvent { + func get() async throws(ARTErrorInfo) -> OccupancyEvent { OccupancyEvent(connections: 10, presenceMembers: 5) } diff --git a/Sources/AblyChat/AblyCocoaExtensions/Ably+Concurrency.swift b/Sources/AblyChat/AblyCocoaExtensions/Ably+Concurrency.swift index 985952d2..baacec4a 100644 --- a/Sources/AblyChat/AblyCocoaExtensions/Ably+Concurrency.swift +++ b/Sources/AblyChat/AblyCocoaExtensions/Ably+Concurrency.swift @@ -3,28 +3,170 @@ import Ably // This file contains extensions to ably-cocoa’s types, to make them easier to use in Swift concurrency. // TODO: remove once we improve this experience in ably-cocoa (https://github.com/ably/ably-cocoa/issues/1967) +internal extension ARTRealtimeInstanceMethodsProtocol { + func requestAsync(_ method: String, path: String, params: [String: String]?, body: Any?, headers: [String: String]?) async throws(InternalError) -> ARTHTTPPaginatedResponse { + do { + return try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + do { + try request(method, path: path, params: params, body: body, headers: headers) { response, error in + if let error { + continuation.resume(returning: .failure(error)) + } else if let response { + continuation.resume(returning: .success(response)) + } else { + preconditionFailure("There is no error, so expected a response") + } + } + } catch { + // This is a weird bit of API design in ably-cocoa (see https://github.com/ably/ably-cocoa/issues/2043 for fixing it); it throws an error to indicate a programmer error (it should be using exceptions). Since the type of the thrown error is NSError and not ARTErrorInfo, which would mess up our typed throw, let's not try and propagate it. + fatalError("ably-cocoa request threw an error - this indicates a programmer error") + } + }.get() + } catch { + throw error.toInternalError() + } + } +} + internal extension ARTRealtimeChannelProtocol { - func attachAsync() async throws(ARTErrorInfo) { - try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in - attach { error in - if let error { - continuation.resume(returning: .failure(error)) - } else { - continuation.resume(returning: .success(())) - } - } - }.get() - } - - func detachAsync() async throws(ARTErrorInfo) { - try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in - detach { error in - if let error { - continuation.resume(returning: .failure(error)) - } else { - continuation.resume(returning: .success(())) - } - } - }.get() + func attachAsync() async throws(InternalError) { + do { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + attach { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } catch { + throw error.toInternalError() + } + } + + func detachAsync() async throws(InternalError) { + do { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + detach { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } catch { + throw error.toInternalError() + } + } +} + +/// A `Sendable` version of `ARTPresenceMessage`. Only contains the properties that the Chat SDK is currently using; add as needed. +internal struct PresenceMessage { + internal var clientId: String? + internal var timestamp: Date? + internal var action: ARTPresenceAction + internal var data: JSONValue? + internal var extras: [String: JSONValue]? +} + +internal extension PresenceMessage { + init(ablyCocoaPresenceMessage: ARTPresenceMessage) { + clientId = ablyCocoaPresenceMessage.clientId + timestamp = ablyCocoaPresenceMessage.timestamp + action = ablyCocoaPresenceMessage.action + if let ablyCocoaData = ablyCocoaPresenceMessage.data { + data = .init(ablyCocoaData: ablyCocoaData) + } + if let ablyCocoaExtras = ablyCocoaPresenceMessage.extras { + extras = JSONValue.objectFromAblyCocoaExtras(ablyCocoaExtras) + } + } +} + +internal extension ARTRealtimePresenceProtocol { + func getAsync() async throws(InternalError) -> [PresenceMessage] { + do { + return try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + get { members, error in + if let error { + continuation.resume(returning: .failure(error)) + } else if let members { + continuation.resume(returning: .success(members.map { .init(ablyCocoaPresenceMessage: $0) })) + } else { + preconditionFailure("There is no error, so expected members") + } + } + }.get() + } catch { + throw error.toInternalError() + } + } + + func getAsync(_ query: ARTRealtimePresenceQuery) async throws(InternalError) -> [PresenceMessage] { + do { + return try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + get(query) { members, error in + if let error { + continuation.resume(returning: .failure(error)) + } else if let members { + continuation.resume(returning: .success(members.map { .init(ablyCocoaPresenceMessage: $0) })) + } else { + preconditionFailure("There is no error, so expected members") + } + } + }.get() + } catch { + throw error.toInternalError() + } + } + + func leaveAsync(_ data: JSONValue?) async throws(InternalError) { + do { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + leave(data?.toAblyCocoaData) { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } catch { + throw error.toInternalError() + } + } + + func enterClientAsync(_ clientID: String, data: JSONValue?) async throws(InternalError) { + do { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + enterClient(clientID, data: data?.toAblyCocoaData) { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } catch { + throw error.toInternalError() + } + } + + func updateAsync(_ data: JSONValue?) async throws(InternalError) { + do { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + update(data?.toAblyCocoaData) { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } catch { + throw error.toInternalError() + } } } diff --git a/Sources/AblyChat/ChatAPI.swift b/Sources/AblyChat/ChatAPI.swift index 1ddbe193..642230e1 100644 --- a/Sources/AblyChat/ChatAPI.swift +++ b/Sources/AblyChat/ChatAPI.swift @@ -10,7 +10,7 @@ internal final class ChatAPI: Sendable { } // (CHA-M6) Messages should be queryable from a paginated REST API. - internal func getMessages(roomId: String, params: QueryOptions) async throws -> any PaginatedResult { + internal func getMessages(roomId: String, params: QueryOptions) async throws(InternalError) -> any PaginatedResult { let endpoint = "\(apiVersionV2)/rooms/\(roomId)/messages" return try await makePaginatedRequest(endpoint, params: params.asQueryItems()) } @@ -19,7 +19,7 @@ internal final class ChatAPI: Sendable { internal let serial: String internal let createdAt: Int64 - internal init(jsonObject: [String: JSONValue]) throws { + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { serial = try jsonObject.stringValueForKey("serial") createdAt = try Int64(jsonObject.numberValueForKey("createdAt")) } @@ -29,7 +29,7 @@ internal final class ChatAPI: Sendable { internal let version: String internal let timestamp: Int64 - internal init(jsonObject: [String: JSONValue]) throws { + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { version = try jsonObject.stringValueForKey("version") timestamp = try Int64(jsonObject.numberValueForKey("timestamp")) } @@ -40,9 +40,9 @@ internal final class ChatAPI: Sendable { // (CHA-M3) Messages are sent to Ably via the Chat REST API, using the send method. // (CHA-M3a) When a message is sent successfully, the caller shall receive a struct representing the Message in response (as if it were received via Realtime event). - internal func sendMessage(roomId: String, params: SendMessageParams) async throws -> Message { + internal func sendMessage(roomId: String, params: SendMessageParams) async throws(InternalError) -> Message { guard let clientId = realtime.clientId else { - throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") + throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.").toInternalError() } let endpoint = "\(apiVersionV2)/rooms/\(roomId)/messages" @@ -79,9 +79,9 @@ internal final class ChatAPI: Sendable { // (CHA-M8) A client must be able to update a message in a room. // (CHA-M8a) A client may update a message via the Chat REST API by calling the update method. - internal func updateMessage(with modifiedMessage: Message, description: String?, metadata: OperationMetadata?) async throws -> Message { + internal func updateMessage(with modifiedMessage: Message, description: String?, metadata: OperationMetadata?) async throws(InternalError) -> Message { guard let clientID = realtime.clientId else { - throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") + throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.").toInternalError() } let endpoint = "\(apiVersionV2)/rooms/\(modifiedMessage.roomID)/messages/\(modifiedMessage.serial)" @@ -131,7 +131,7 @@ internal final class ChatAPI: Sendable { // (CHA-M9) A client must be able to delete a message in a room. // (CHA-M9a) A client may delete a message via the Chat REST API by calling the delete method. - internal func deleteMessage(message: Message, params: DeleteMessageParams) async throws -> Message { + internal func deleteMessage(message: Message, params: DeleteMessageParams) async throws(InternalError) -> Message { let endpoint = "\(apiVersionV2)/rooms/\(message.roomID)/messages/\(message.serial)/delete" var body: [String: JSONValue] = [:] @@ -169,59 +169,39 @@ internal final class ChatAPI: Sendable { return message } - internal func getOccupancy(roomId: String) async throws -> OccupancyEvent { + internal func getOccupancy(roomId: String) async throws(InternalError) -> OccupancyEvent { let endpoint = "\(apiVersion)/rooms/\(roomId)/occupancy" return try await makeRequest(endpoint, method: "GET") } - private func makeRequest(_ url: String, method: String, body: [String: JSONValue]? = nil) async throws -> Response { + private func makeRequest(_ url: String, method: String, body: [String: JSONValue]? = nil) async throws(InternalError) -> Response { let ablyCocoaBody: Any? = if let body { JSONValue.object(body).toAblyCocoaData } else { nil } - return try await withCheckedThrowingContinuation { continuation in - do { - try realtime.request(method, path: url, params: [:], body: ablyCocoaBody, headers: [:]) { paginatedResponse, error in - if let error { - // (CHA-M3e & CHA-M8d & CHA-M9c) If an error is returned from the REST API, its ErrorInfo representation shall be thrown as the result of the send call. - continuation.resume(throwing: ARTErrorInfo.create(from: error)) - return - } - - guard let firstItem = paginatedResponse?.items.first else { - continuation.resume(throwing: ChatError.noItemInResponse) - return - } - - do { - let jsonValue = JSONValue(ablyCocoaData: firstItem) - let decodedResponse = try Response(jsonValue: jsonValue) - continuation.resume(returning: decodedResponse) - } catch { - continuation.resume(throwing: error) - } - } - } catch { - continuation.resume(throwing: error) - } + // (CHA-M3e & CHA-M8d & CHA-M9c) If an error is returned from the REST API, its ErrorInfo representation shall be thrown as the result of the send call. + let paginatedResponse = try await realtime.requestAsync(method, path: url, params: [:], body: ablyCocoaBody, headers: [:]) + + guard let firstItem = paginatedResponse.items.first else { + throw ChatError.noItemInResponse.toInternalError() } + + let jsonValue = JSONValue(ablyCocoaData: firstItem) + return try Response(jsonValue: jsonValue) } private func makePaginatedRequest( _ url: String, params: [String: String]? = nil - ) async throws -> any PaginatedResult { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation, _>) in - do { - try realtime.request("GET", path: url, params: params, body: nil, headers: [:]) { paginatedResponse, error in - ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) - } - } catch { - continuation.resume(throwing: error) - } + ) async throws(InternalError) -> any PaginatedResult { + let paginatedResponse = try await realtime.requestAsync("GET", path: url, params: params, body: nil, headers: [:]) + let jsonValues = paginatedResponse.items.map { JSONValue(ablyCocoaData: $0) } + let items = try jsonValues.map { jsonValue throws(InternalError) in + try Response(jsonValue: jsonValue) } + return paginatedResponse.toPaginatedResult(items: items) } internal enum ChatError: Error { diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift index 8aa562b4..f1f65d7e 100644 --- a/Sources/AblyChat/DefaultMessages.swift +++ b/Sources/AblyChat/DefaultMessages.swift @@ -35,112 +35,132 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { } // (CHA-M4) Messages can be received via a subscription in realtime. - internal func subscribe(bufferingPolicy: BufferingPolicy) async throws -> MessageSubscription { - logger.log(message: "Subscribing to messages", level: .debug) - let uuid = UUID() - let serial = try await resolveSubscriptionStart() - let messageSubscription = MessageSubscription( - bufferingPolicy: bufferingPolicy - ) { [weak self] queryOptions in - guard let self else { throw MessagesError.noReferenceToSelf } - return try await getBeforeSubscriptionStart(uuid, params: queryOptions) - } + internal func subscribe(bufferingPolicy: BufferingPolicy) async throws(ARTErrorInfo) -> MessageSubscription { + do { + logger.log(message: "Subscribing to messages", level: .debug) + let uuid = UUID() + let serial = try await resolveSubscriptionStart() + let messageSubscription = MessageSubscription( + bufferingPolicy: bufferingPolicy + ) { [weak self] queryOptions in + guard let self else { throw MessagesError.noReferenceToSelf } + return try await getBeforeSubscriptionStart(uuid, params: queryOptions) + } - // (CHA-M4a) A subscription can be registered to receive incoming messages. Adding a subscription has no side effects on the status of the room or the underlying realtime channel. - subscriptionPoints[uuid] = .init(subscription: messageSubscription, serial: serial) + // (CHA-M4a) A subscription can be registered to receive incoming messages. Adding a subscription has no side effects on the status of the room or the underlying realtime channel. + subscriptionPoints[uuid] = .init(subscription: messageSubscription, serial: serial) + + // (CHA-M4c) When a realtime message with name set to message.created is received, it is translated into a message event, which contains a type field with the event type as well as a message field containing the Message Struct. This event is then broadcast to all subscribers. + // (CHA-M4d) If a realtime message with an unknown name is received, the SDK shall silently discard the message, though it may log at DEBUG or TRACE level. + // (CHA-M5k) Incoming realtime events that are malformed (unknown field should be ignored) shall not be emitted to subscribers. + let eventListener = channel.subscribe(RealtimeMessageName.chatMessage.rawValue) { message in + Task { + // TODO: Revisit errors thrown as part of https://github.com/ably-labs/ably-chat-swift/issues/32 + guard let ablyCocoaData = message.data, + let data = JSONValue(ablyCocoaData: ablyCocoaData).objectValue, + let text = data["text"]?.stringValue + else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text") + } - // (CHA-M4c) When a realtime message with name set to message.created is received, it is translated into a message event, which contains a type field with the event type as well as a message field containing the Message Struct. This event is then broadcast to all subscribers. - // (CHA-M4d) If a realtime message with an unknown name is received, the SDK shall silently discard the message, though it may log at DEBUG or TRACE level. - // (CHA-M5k) Incoming realtime events that are malformed (unknown field should be ignored) shall not be emitted to subscribers. - let eventListener = channel.subscribe(RealtimeMessageName.chatMessage.rawValue) { message in - Task { - // TODO: Revisit errors thrown as part of https://github.com/ably-labs/ably-chat-swift/issues/32 - guard let ablyCocoaData = message.data, - let data = JSONValue(ablyCocoaData: ablyCocoaData).objectValue, - let text = data["text"]?.stringValue - else { - throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text") - } + guard let ablyCocoaExtras = message.extras else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without extras") + } - guard let ablyCocoaExtras = message.extras else { - throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without extras") - } + let extras = JSONValue.objectFromAblyCocoaExtras(ablyCocoaExtras) - let extras = JSONValue.objectFromAblyCocoaExtras(ablyCocoaExtras) + guard let serial = message.serial else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without serial") + } - guard let serial = message.serial else { - throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without serial") - } + guard let clientID = message.clientId else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") + } - guard let clientID = message.clientId else { - throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") - } + guard let version = message.version else { + throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without version") + } - guard let version = message.version else { - throw ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without version") - } + let metadata = try data.optionalObjectValueForKey("metadata") - let metadata = try data.optionalObjectValueForKey("metadata") + let headers: Headers? = if let headersJSONObject = try extras.optionalObjectValueForKey("headers") { + try headersJSONObject.mapValues { try HeadersValue(jsonValue: $0) } + } else { + nil + } - let headers: Headers? = if let headersJSONObject = try extras.optionalObjectValueForKey("headers") { - try headersJSONObject.mapValues { try HeadersValue(jsonValue: $0) } - } else { - nil - } + guard let action = MessageAction.fromRealtimeAction(message.action) else { + return + } - guard let action = MessageAction.fromRealtimeAction(message.action) else { - return - } + // `message.operation?.toChatOperation()` is throwing but the linter prefers putting the `try` on Message initialization instead of having it nested. + let message = try Message( + serial: serial, + action: action, + clientID: clientID, + roomID: self.roomID, + text: text, + createdAt: message.timestamp, + metadata: metadata ?? .init(), + headers: headers ?? .init(), + version: version, + timestamp: message.timestamp, + operation: message.operation?.toChatOperation() + ) - // `message.operation?.toChatOperation()` is throwing but the linter prefers putting the `try` on Message initialization instead of having it nested. - let message = try Message( - serial: serial, - action: action, - clientID: clientID, - roomID: self.roomID, - text: text, - createdAt: message.timestamp, - metadata: metadata ?? .init(), - headers: headers ?? .init(), - version: version, - timestamp: message.timestamp, - operation: message.operation?.toChatOperation() - ) - - messageSubscription.emit(message) + messageSubscription.emit(message) + } } - } - messageSubscription.addTerminationHandler { - Task { - await MainActor.run { [weak self] () in - guard let self else { - return + messageSubscription.addTerminationHandler { + Task { + await MainActor.run { [weak self] () in + guard let self else { + return + } + channel.unsubscribe(eventListener) + subscriptionPoints.removeValue(forKey: uuid) } - channel.unsubscribe(eventListener) - subscriptionPoints.removeValue(forKey: uuid) } } - } - return messageSubscription + return messageSubscription + } catch { + throw error.toARTErrorInfo() + } } // (CHA-M6a) A method must be exposed that accepts the standard Ably REST API query parameters. It shall call the “REST API”#rest-fetching-messages and return a PaginatedResult containing messages, which can then be paginated through. - internal func get(options: QueryOptions) async throws -> any PaginatedResult { - try await chatAPI.getMessages(roomId: roomID, params: options) + internal func get(options: QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult { + do { + return try await chatAPI.getMessages(roomId: roomID, params: options) + } catch { + throw error.toARTErrorInfo() + } } - internal func send(params: SendMessageParams) async throws -> Message { - try await chatAPI.sendMessage(roomId: roomID, params: params) + internal func send(params: SendMessageParams) async throws(ARTErrorInfo) -> Message { + do { + return try await chatAPI.sendMessage(roomId: roomID, params: params) + } catch { + throw error.toARTErrorInfo() + } } - internal func update(newMessage: Message, description: String?, metadata: OperationMetadata?) async throws -> Message { - try await chatAPI.updateMessage(with: newMessage, description: description, metadata: metadata) + internal func update(newMessage: Message, description: String?, metadata: OperationMetadata?) async throws(ARTErrorInfo) -> Message { + do { + return try await chatAPI.updateMessage(with: newMessage, description: description, metadata: metadata) + } catch { + throw error.toARTErrorInfo() + } } - internal func delete(message: Message, params: DeleteMessageParams) async throws -> Message { - try await chatAPI.deleteMessage(message: message, params: params) + internal func delete(message: Message, params: DeleteMessageParams) async throws(ARTErrorInfo) -> Message { + do { + return try await chatAPI.deleteMessage(message: message, params: params) + } catch { + throw error.toARTErrorInfo() + } } // (CHA-M7) Users may subscribe to discontinuity events to know when there’s been a break in messages that they need to resolve. Their listener will be called when a discontinuity event is triggered from the room lifecycle. @@ -212,7 +232,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { } } - private func resolveSubscriptionStart() async throws -> String { + private func resolveSubscriptionStart() async throws(InternalError) -> String { logger.log(message: "Resolving subscription start", level: .debug) // (CHA-M5a) If a subscription is added when the underlying realtime channel is ATTACHED, then the subscription point is the current channelSerial of the realtime channel. if channel.state == .attached { @@ -222,7 +242,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { } else { let error = ARTErrorInfo.create(withCode: 40000, status: 400, message: "channel is attached, but channelSerial is not defined") logger.log(message: "Error resolving subscription start: \(error)", level: .error) - throw error + throw error.toInternalError() } } @@ -231,7 +251,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { } // Always returns the attachSerial and not the channelSerial to also serve (CHA-M5c) - If a channel leaves the ATTACHED state and then re-enters ATTACHED with resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial. - private func serialOnChannelAttach() async throws -> String { + private func serialOnChannelAttach() async throws(InternalError) -> String { logger.log(message: "Resolving serial on channel attach", level: .debug) // If the state is already 'attached', return the attachSerial immediately if channel.state == .attached { @@ -241,14 +261,14 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { } else { let error = ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined") logger.log(message: "Error resolving serial on channel attach: \(error)", level: .error) - throw error + throw error.toInternalError() } } // (CHA-M5b) If a subscription is added when the underlying realtime channel is in any other state, then its subscription point becomes the attachSerial at the the point of channel attachment. - return try await withCheckedThrowingContinuation { continuation in + return try await withCheckedContinuation { (continuation: CheckedContinuation, Never>) in // avoids multiple invocations of the continuation - var nillableContinuation: CheckedContinuation? = continuation + var nillableContinuation: CheckedContinuation, Never>? = continuation channel.on { [weak self] stateChange in guard let self else { @@ -260,10 +280,10 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { // Handle successful attachment if let attachSerial = channel.properties.attachSerial { logger.log(message: "Channel is attached, returning attachSerial: \(attachSerial)", level: .debug) - nillableContinuation?.resume(returning: attachSerial) + nillableContinuation?.resume(returning: .success(attachSerial)) } else { logger.log(message: "Channel is attached, but attachSerial is not defined", level: .error) - nillableContinuation?.resume(throwing: ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined")) + nillableContinuation?.resume(returning: .failure(ARTErrorInfo.create(withCode: 40000, status: 400, message: "Channel is attached, but attachSerial is not defined").toInternalError())) } nillableContinuation = nil case .failed, .suspended: @@ -271,10 +291,13 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { logger.log(message: "Channel failed to attach", level: .error) let errorCodeCase = ErrorCode.CaseThatImpliesFixedStatusCode.messagesAttachmentFailed nillableContinuation?.resume( - throwing: ARTErrorInfo.create( - withCode: errorCodeCase.toNumericErrorCode.rawValue, - status: errorCodeCase.statusCode, - message: "Channel failed to attach" + returning: .failure( + ARTErrorInfo.create( + withCode: errorCodeCase.toNumericErrorCode.rawValue, + status: errorCodeCase.statusCode, + message: "Channel failed to attach" + ) + .toInternalError() ) ) nillableContinuation = nil @@ -282,7 +305,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { break } } - } + }.get() } internal enum MessagesError: Error { diff --git a/Sources/AblyChat/DefaultOccupancy.swift b/Sources/AblyChat/DefaultOccupancy.swift index e67aec57..3061bbb6 100644 --- a/Sources/AblyChat/DefaultOccupancy.swift +++ b/Sources/AblyChat/DefaultOccupancy.swift @@ -53,9 +53,13 @@ internal final class DefaultOccupancy: Occupancy, EmitsDiscontinuities { } // (CHA-O3) Users can request an instantaneous occupancy check via the REST API. The request is detailed here (https://sdk.ably.com/builds/ably/specification/main/chat-features/#rest-occupancy-request), with the response format being a simple occupancy event - internal func get() async throws -> OccupancyEvent { - logger.log(message: "Getting occupancy for room: \(roomID)", level: .debug) - return try await chatAPI.getOccupancy(roomId: roomID) + internal func get() async throws(ARTErrorInfo) -> OccupancyEvent { + do { + logger.log(message: "Getting occupancy for room: \(roomID)", level: .debug) + return try await chatAPI.getOccupancy(roomId: roomID) + } catch { + throw error.toARTErrorInfo() + } } // (CHA-O5) Users may subscribe to discontinuity events to know when there’s been a break in occupancy. Their listener will be called when a discontinuity event is triggered from the room lifecycle. For occupancy, there shouldn’t need to be user action as most channels will send occupancy updates regularly as clients churn. diff --git a/Sources/AblyChat/DefaultPresence.swift b/Sources/AblyChat/DefaultPresence.swift index cc8dd529..fb26fb91 100644 --- a/Sources/AblyChat/DefaultPresence.swift +++ b/Sources/AblyChat/DefaultPresence.swift @@ -19,178 +19,182 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { } // (CHA-PR6) It must be possible to retrieve all the @Members of the presence set. The behaviour depends on the current room status, as presence operations in a Realtime Client cause implicit attaches. - internal func get() async throws -> [PresenceMember] { - logger.log(message: "Getting presence", level: .debug) - - // CHA-PR6b to CHA-PR6f - do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) - } catch { - logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) - throw error - } + internal func get() async throws(ARTErrorInfo) -> [PresenceMember] { + do throws(InternalError) { + logger.log(message: "Getting presence", level: .debug) + + // CHA-PR6b to CHA-PR6f + do { + try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + } catch { + logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) + throw error + } - return try await withCheckedThrowingContinuation { continuation in - channel.presence.get { [processPresenceGet] members, error in - do { - let presenceMembers = try processPresenceGet(members, error) - continuation.resume(returning: presenceMembers) - } catch { - continuation.resume(throwing: error) - // processPresenceGet will log any errors - } + let members: [PresenceMessage] + do { + members = try await channel.presence.getAsync() + } catch { + logger.log(message: error.message, level: .error) + throw error } + return try processPresenceGet(members: members) + } catch { + throw error.toARTErrorInfo() } } - internal func get(params: PresenceQuery) async throws -> [PresenceMember] { - logger.log(message: "Getting presence with params: \(params)", level: .debug) + internal func get(params: PresenceQuery) async throws(ARTErrorInfo) -> [PresenceMember] { + do throws(InternalError) { + logger.log(message: "Getting presence with params: \(params)", level: .debug) - // CHA-PR6b to CHA-PR6f - do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) - } catch { - logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) - throw error - } + // CHA-PR6b to CHA-PR6f + do { + try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + } catch { + logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) + throw error + } - return try await withCheckedThrowingContinuation { continuation in - channel.presence.get(params.asARTRealtimePresenceQuery()) { [processPresenceGet] members, error in - do { - let presenceMembers = try processPresenceGet(members, error) - continuation.resume(returning: presenceMembers) - } catch { - continuation.resume(throwing: error) - // processPresenceGet will log any errors - } + let members: [PresenceMessage] + do { + members = try await channel.presence.getAsync(params.asARTRealtimePresenceQuery()) + } catch { + logger.log(message: error.message, level: .error) + throw error } + return try processPresenceGet(members: members) + } catch { + throw error.toARTErrorInfo() } } // (CHA-PR5) It must be possible to query if a given clientId is in the presence set. - internal func isUserPresent(clientID: String) async throws -> Bool { - logger.log(message: "Checking if user is present with clientID: \(clientID)", level: .debug) - - // CHA-PR6b to CHA-PR6f - do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) - } catch { - logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) - throw error - } + internal func isUserPresent(clientID: String) async throws(ARTErrorInfo) -> Bool { + do throws(InternalError) { + logger.log(message: "Checking if user is present with clientID: \(clientID)", level: .debug) + + // CHA-PR6b to CHA-PR6f + do { + try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + } catch { + logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) + throw error + } - return try await withCheckedThrowingContinuation { continuation in - channel.presence.get(ARTRealtimePresenceQuery(clientId: clientID, connectionId: nil)) { [logger] members, error in - guard let members else { - let error = error ?? ARTErrorInfo.createUnknownError() - logger.log(message: error.message, level: .error) - continuation.resume(throwing: error) - return - } - continuation.resume(returning: !members.isEmpty) + let members: [PresenceMessage] + do { + members = try await channel.presence.getAsync(ARTRealtimePresenceQuery(clientId: clientID, connectionId: nil)) + } catch { + logger.log(message: error.message, level: .error) + throw error } + + return !members.isEmpty + } catch { + throw error.toARTErrorInfo() } } - internal func enter(data: PresenceData) async throws { + internal func enter(data: PresenceData) async throws(ARTErrorInfo) { try await enter(optionalData: data) } - internal func enter() async throws { + internal func enter() async throws(ARTErrorInfo) { try await enter(optionalData: nil) } // (CHA-PR3a) Users may choose to enter presence, optionally providing custom data to enter with. The overall presence data must retain the format specified in CHA-PR2. - private func enter(optionalData data: PresenceData?) async throws { - logger.log(message: "Entering presence", level: .debug) - - // CHA-PR3c to CHA-PR3g - do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) - } catch { - logger.log(message: "Error waiting to be able to perform presence enter operation: \(error)", level: .error) - throw error - } + private func enter(optionalData data: PresenceData?) async throws(ARTErrorInfo) { + do throws(InternalError) { + logger.log(message: "Entering presence", level: .debug) + + // CHA-PR3c to CHA-PR3g + do { + try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + } catch { + logger.log(message: "Error waiting to be able to perform presence enter operation: \(error)", level: .error) + throw error + } - let dto = PresenceDataDTO(userCustomData: data) + let dto = PresenceDataDTO(userCustomData: data) - return try await withCheckedThrowingContinuation { continuation in - channel.presence.enterClient(clientID, data: dto.toJSONValue.toAblyCocoaData) { [logger] error in - if let error { - logger.log(message: "Error entering presence: \(error)", level: .error) - continuation.resume(throwing: error) - } else { - continuation.resume() - } + do { + try await channel.presence.enterClientAsync(clientID, data: dto.toJSONValue) + } catch { + logger.log(message: "Error entering presence: \(error)", level: .error) + throw error } + } catch { + throw error.toARTErrorInfo() } } - internal func update(data: PresenceData) async throws { + internal func update(data: PresenceData) async throws(ARTErrorInfo) { try await update(optionalData: data) } - internal func update() async throws { + internal func update() async throws(ARTErrorInfo) { try await update(optionalData: nil) } // (CHA-PR10a) Users may choose to update their presence data, optionally providing custom data to update with. The overall presence data must retain the format specified in CHA-PR2. - private func update(optionalData data: PresenceData?) async throws { - logger.log(message: "Updating presence", level: .debug) - - // CHA-PR10c to CHA-PR10g - do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) - } catch { - logger.log(message: "Error waiting to be able to perform presence update operation: \(error)", level: .error) - throw error - } + private func update(optionalData data: PresenceData?) async throws(ARTErrorInfo) { + do throws(InternalError) { + logger.log(message: "Updating presence", level: .debug) + + // CHA-PR10c to CHA-PR10g + do { + try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + } catch { + logger.log(message: "Error waiting to be able to perform presence update operation: \(error)", level: .error) + throw error + } - let dto = PresenceDataDTO(userCustomData: data) + let dto = PresenceDataDTO(userCustomData: data) - return try await withCheckedThrowingContinuation { continuation in - channel.presence.update(dto.toJSONValue.toAblyCocoaData) { [logger] error in - if let error { - logger.log(message: "Error updating presence: \(error)", level: .error) - continuation.resume(throwing: error) - } else { - continuation.resume() - } + do { + try await channel.presence.updateAsync(dto.toJSONValue) + } catch { + logger.log(message: "Error updating presence: \(error)", level: .error) + throw error } + } catch { + throw error.toARTErrorInfo() } } - internal func leave(data: PresenceData) async throws { + internal func leave(data: PresenceData) async throws(ARTErrorInfo) { try await leave(optionalData: data) } - internal func leave() async throws { + internal func leave() async throws(ARTErrorInfo) { try await leave(optionalData: nil) } // (CHA-PR4a) Users may choose to leave presence, which results in them being removed from the Realtime presence set. - internal func leave(optionalData data: PresenceData?) async throws { - logger.log(message: "Leaving presence", level: .debug) - - // CHA-PR6b to CHA-PR6f - do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) - } catch { - logger.log(message: "Error waiting to be able to perform presence leave operation: \(error)", level: .error) - throw error - } + internal func leave(optionalData data: PresenceData?) async throws(ARTErrorInfo) { + do throws(InternalError) { + logger.log(message: "Leaving presence", level: .debug) + + // CHA-PR6b to CHA-PR6f + do { + try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + } catch { + logger.log(message: "Error waiting to be able to perform presence leave operation: \(error)", level: .error) + throw error + } - let dto = PresenceDataDTO(userCustomData: data) + let dto = PresenceDataDTO(userCustomData: data) - return try await withCheckedThrowingContinuation { continuation in - channel.presence.leave(dto.toJSONValue.toAblyCocoaData) { [logger] error in - if let error { - logger.log(message: "Error leaving presence: \(error)", level: .error) - continuation.resume(throwing: error) - } else { - continuation.resume() - } + do { + try await channel.presence.leaveAsync(dto.toJSONValue) + } catch { + logger.log(message: "Error leaving presence: \(error)", level: .error) + throw error } + } catch { + throw error.toARTErrorInfo() } } @@ -203,7 +207,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { logger.log(message: "Received presence message: \(message)", level: .debug) Task { // processPresenceSubscribe is logging so we don't need to log here - let presenceEvent = try processPresenceSubscribe(message, event) + let presenceEvent = try processPresenceSubscribe(PresenceMessage(ablyCocoaPresenceMessage: message), event) subscription.emit(presenceEvent) } } @@ -223,7 +227,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe, logger] message in logger.log(message: "Received presence message: \(message)", level: .debug) Task { - let presenceEvent = try processPresenceSubscribe(message, event) + let presenceEvent = try processPresenceSubscribe(PresenceMessage(ablyCocoaPresenceMessage: message), event) subscription.emit(presenceEvent) } } @@ -245,55 +249,42 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { await featureChannel.onDiscontinuity(bufferingPolicy: bufferingPolicy) } - private func decodePresenceDataDTO(from ablyCocoaPresenceData: Any?) throws -> PresenceDataDTO { - guard let ablyCocoaPresenceData else { + private func decodePresenceDataDTO(from presenceData: JSONValue?) throws(InternalError) -> PresenceDataDTO { + guard let presenceData else { let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data") logger.log(message: error.message, level: .error) - throw error + throw error.toInternalError() } - let jsonValue = JSONValue(ablyCocoaData: ablyCocoaPresenceData) - do { - return try PresenceDataDTO(jsonValue: jsonValue) + return try PresenceDataDTO(jsonValue: presenceData) } catch { - logger.log(message: "Failed to decode presence data DTO from \(jsonValue), error \(error)", level: .error) + logger.log(message: "Failed to decode presence data DTO from \(presenceData), error \(error)", level: .error) throw error } } - private func processPresenceGet(members: [ARTPresenceMessage]?, error: ARTErrorInfo?) throws -> [PresenceMember] { - guard let members else { - let error = error ?? ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or text") - logger.log(message: error.message, level: .error) - throw error - } - let presenceMembers = try members.map { member in + private func processPresenceGet(members: [PresenceMessage]) throws(InternalError) -> [PresenceMember] { + let presenceMembers = try members.map { member throws(InternalError) in let presenceDataDTO = try decodePresenceDataDTO(from: member.data) guard let clientID = member.clientId else { let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") logger.log(message: error.message, level: .error) - throw error + throw error.toInternalError() } guard let timestamp = member.timestamp else { let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without timestamp") logger.log(message: error.message, level: .error) - throw error - } - - let extras: [String: JSONValue]? = if let ablyCocoaExtras = member.extras { - JSONValue.objectFromAblyCocoaExtras(ablyCocoaExtras) - } else { - nil + throw error.toInternalError() } let presenceMember = PresenceMember( clientID: clientID, data: presenceDataDTO.userCustomData, action: PresenceMember.Action(from: member.action), - extras: extras, + extras: member.extras, updatedAt: timestamp ) @@ -303,7 +294,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { return presenceMembers } - private func processPresenceSubscribe(_ message: ARTPresenceMessage, for event: PresenceEventType) throws -> PresenceEvent { + private func processPresenceSubscribe(_ message: PresenceMessage, for event: PresenceEventType) throws -> PresenceEvent { guard let clientID = message.clientId else { let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") logger.log(message: error.message, level: .error) diff --git a/Sources/AblyChat/DefaultRoomLifecycleContributor.swift b/Sources/AblyChat/DefaultRoomLifecycleContributor.swift index 718d500b..62d12d47 100644 --- a/Sources/AblyChat/DefaultRoomLifecycleContributor.swift +++ b/Sources/AblyChat/DefaultRoomLifecycleContributor.swift @@ -34,11 +34,11 @@ internal final class DefaultRoomLifecycleContributorChannel: RoomLifecycleContri self.underlyingChannel = underlyingChannel } - internal func attach() async throws(ARTErrorInfo) { + internal func attach() async throws(InternalError) { try await underlyingChannel.attachAsync() } - internal func detach() async throws(ARTErrorInfo) { + internal func detach() async throws(InternalError) { try await underlyingChannel.detachAsync() } diff --git a/Sources/AblyChat/DefaultRoomReactions.swift b/Sources/AblyChat/DefaultRoomReactions.swift index 69184845..3654f863 100644 --- a/Sources/AblyChat/DefaultRoomReactions.swift +++ b/Sources/AblyChat/DefaultRoomReactions.swift @@ -21,7 +21,7 @@ internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities { // (CHA-ER3) Ephemeral room reactions are sent to Ably via the Realtime connection via a send method. // (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format. - internal func send(params: SendReactionParams) async throws { + internal func send(params: SendReactionParams) async throws(ARTErrorInfo) { logger.log(message: "Sending reaction with params: \(params)", level: .debug) let dto = RoomReactionDTO(type: params.type, metadata: params.metadata, headers: params.headers) diff --git a/Sources/AblyChat/DefaultTyping.swift b/Sources/AblyChat/DefaultTyping.swift index 5919633c..3a2fedb4 100644 --- a/Sources/AblyChat/DefaultTyping.swift +++ b/Sources/AblyChat/DefaultTyping.swift @@ -43,7 +43,6 @@ internal final class DefaultTyping: Typing { do { // (CHA-T6c) When a presence event is received from the realtime client, the Chat client will perform a presence.get() operation to get the current presence set. This guarantees that we get a fully synced presence set. This is then used to emit the typing clients to the subscriber. let latestTypingMembers = try await get() - // (CHA-T6c2) If multiple presence events are received resulting in concurrent presence.get() calls, then we guarantee that only the “latest” event is emitted. That is to say, if presence event A and B occur in that order, then only the typing event generated by B’s call to presence.get() will be emitted to typing subscribers. let isLatestEvent = await eventTracker.isLatestEvent(currentEventID) guard isLatestEvent else { @@ -83,86 +82,95 @@ internal final class DefaultTyping: Typing { } // (CHA-T2) Users may retrieve a list of the currently typing client IDs. The behaviour depends on the current room status, as presence operations in a Realtime Client cause implicit attaches. - internal func get() async throws -> Set { - logger.log(message: "Getting presence", level: .debug) - - // CHA-T2c to CHA-T2f - do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) - } catch { - logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) - throw error - } + internal func get() async throws(ARTErrorInfo) -> Set { + do throws(InternalError) { + logger.log(message: "Getting presence", level: .debug) + + // CHA-T2c to CHA-T2f + do { + try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + } catch { + logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) + throw error + } - return try await withCheckedThrowingContinuation { continuation in - channel.presence.get { [processPresenceGet] members, error in - do { - let presenceMembers = try processPresenceGet(members, error) - continuation.resume(returning: presenceMembers) - } catch { - continuation.resume(throwing: error) - // processPresenceGet will log any errors - } + let members: [PresenceMessage] + do { + members = try await channel.presence.getAsync() + } catch { + logger.log(message: error.message, level: .error) + throw error } + return try processPresenceGet(members: members) + } catch { + throw error.toARTErrorInfo() } } // (CHA-T4) Users may indicate that they have started typing. - internal func start() async throws { - logger.log(message: "Starting typing indicator for client: \(clientID)", level: .debug) - - do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) - } catch { - logger.log(message: "Error waiting to be able to perform presence enter operation: \(error)", level: .error) - throw error - } + internal func start() async throws(ARTErrorInfo) { + do throws(InternalError) { + logger.log(message: "Starting typing indicator for client: \(clientID)", level: .debug) + + do { + try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + } catch { + logger.log(message: "Error waiting to be able to perform presence enter operation: \(error)", level: .error) + throw error + } - return try await withCheckedThrowingContinuation { continuation in - Task { - let isUserTyping = await timerManager.hasRunningTask() - - // (CHA-T4b) If typing is already in progress, the CHA-T3 timeout is extended to be timeoutMs from now. - if isUserTyping { - logger.log(message: "User is already typing. Extending timeout.", level: .debug) - await timerManager.setTimer(interval: timeout) { [stop] in - Task { - try await stop() + return try await withCheckedContinuation { (continuation: CheckedContinuation, Never>) in + Task { + let isUserTyping = await timerManager.hasRunningTask() + + // (CHA-T4b) If typing is already in progress, the CHA-T3 timeout is extended to be timeoutMs from now. + if isUserTyping { + logger.log(message: "User is already typing. Extending timeout.", level: .debug) + await timerManager.setTimer(interval: timeout) { [stop] in + Task { + try await stop() + } + } + continuation.resume(returning: .success(())) + } else { + // (CHA-T4a) If typing is not already in progress, per explicit cancellation or the timeout interval in (CHA-T3), then a new typing session is started. + logger.log(message: "User is not typing. Starting typing.", level: .debug) + do throws(InternalError) { + try startTyping() + continuation.resume(returning: .success(())) + } catch { + continuation.resume(returning: .failure(error)) } - } - continuation.resume() - } else { - // (CHA-T4a) If typing is not already in progress, per explicit cancellation or the timeout interval in (CHA-T3), then a new typing session is started. - logger.log(message: "User is not typing. Starting typing.", level: .debug) - do { - try startTyping() - continuation.resume() - } catch { - continuation.resume(throwing: error) } } - } + }.get() + } catch { + throw error.toARTErrorInfo() } } // (CHA-T5) Users may indicate that they have stopped typing. - internal func stop() async throws { - do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) - } catch { - logger.log(message: "Error waiting to be able to perform presence leave operation: \(error)", level: .error) - throw error - } + internal func stop() async throws(ARTErrorInfo) { + do throws(InternalError) { + do { + try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + } catch { + logger.log(message: "Error waiting to be able to perform presence leave operation: \(error)", level: .error) + throw error + } - let isUserTyping = await timerManager.hasRunningTask() - if isUserTyping { - logger.log(message: "Stopping typing indicator for client: \(clientID)", level: .debug) - // (CHA-T5b) If typing is in progress, he CHA-T3 timeout is cancelled. The client then leaves presence. - await timerManager.cancelTimer() - channel.presence.leaveClient(clientID, data: nil) - } else { - // (CHA-T5a) If typing is not in progress, this operation is no-op. - logger.log(message: "User is not typing. No need to leave presence.", level: .debug) + let isUserTyping = await timerManager.hasRunningTask() + if isUserTyping { + logger.log(message: "Stopping typing indicator for client: \(clientID)", level: .debug) + // (CHA-T5b) If typing is in progress, he CHA-T3 timeout is cancelled. The client then leaves presence. + await timerManager.cancelTimer() + channel.presence.leaveClient(clientID, data: nil) + } else { + // (CHA-T5a) If typing is not in progress, this operation is no-op. + logger.log(message: "User is not typing. No need to leave presence.", level: .debug) + } + } catch { + throw error.toARTErrorInfo() } } @@ -171,16 +179,10 @@ internal final class DefaultTyping: Typing { await featureChannel.onDiscontinuity(bufferingPolicy: bufferingPolicy) } - private func processPresenceGet(members: [ARTPresenceMessage]?, error: ARTErrorInfo?) throws -> Set { - guard let members else { - let error = error ?? ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data") - logger.log(message: error.message, level: .error) - throw error - } - - let clientIDs = try Set(members.map { member in + private func processPresenceGet(members: [PresenceMessage]) throws(InternalError) -> Set { + let clientIDs = try Set(members.map { member throws(InternalError) in guard let clientID = member.clientId else { - let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId") + let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without clientId").toInternalError() logger.log(message: error.message, level: .error) throw error } @@ -191,24 +193,21 @@ internal final class DefaultTyping: Typing { return clientIDs } - private func startTyping() throws { + private func startTyping() throws(InternalError) { // (CHA-T4a1) When a typing session is started, the client is entered into presence on the typing channel. - channel.presence.enterClient(clientID, data: nil) { [weak self] error in - guard let self else { - return + Task { + do { + try await channel.presence.enterClientAsync(clientID, data: nil) + } catch { + logger.log(message: "Error entering presence: \(error)", level: .error) + throw error } - Task { - if let error { - logger.log(message: "Error entering presence: \(error)", level: .error) - throw error - } else { - logger.log(message: "Entered presence - starting timer", level: .debug) - // (CHA-T4a2) When a typing session is started, a timeout is set according to the CHA-T3 timeout interval. When this timeout expires, the typing session is automatically ended by leaving presence. - await timerManager.setTimer(interval: timeout) { [stop] in - Task { - try await stop() - } - } + + logger.log(message: "Entered presence - starting timer", level: .debug) + // (CHA-T4a2) When a typing session is started, a timeout is set according to the CHA-T3 timeout interval. When this timeout expires, the typing session is automatically ended by leaving presence. + await timerManager.setTimer(interval: timeout) { [stop] in + Task { + try await stop() } } } diff --git a/Sources/AblyChat/Errors.swift b/Sources/AblyChat/Errors.swift index 9f3e9ef5..84c634c7 100644 --- a/Sources/AblyChat/Errors.swift +++ b/Sources/AblyChat/Errors.swift @@ -212,6 +212,7 @@ internal enum ErrorCodeAndStatusCode { This type exists in addition to ``ErrorCode`` to allow us to attach metadata which can be incorporated into the error’s `localizedDescription` and `cause`. */ internal enum ChatError { + case nonErrorInfoInternalError(InternalError.Other) case inconsistentRoomOptions(requested: RoomOptions, existing: RoomOptions) case attachmentFailed(feature: RoomFeature, underlyingError: ARTErrorInfo) case detachmentFailed(feature: RoomFeature, underlyingError: ARTErrorInfo) @@ -224,6 +225,9 @@ internal enum ChatError { internal var codeAndStatusCode: ErrorCodeAndStatusCode { switch self { + case .nonErrorInfoInternalError: + // For now we just treat all errors that are not backed by an ARTErrorInfo as non-recoverable user errors + .fixedStatusCode(.badRequest) case .inconsistentRoomOptions: .fixedStatusCode(.badRequest) case let .attachmentFailed(feature, _): @@ -307,6 +311,9 @@ internal enum ChatError { /// The ``ARTErrorInfo/localizedDescription`` that should be returned for this error. internal var localizedDescription: String { switch self { + case let .nonErrorInfoInternalError(otherInternalError): + // This will contain the name of the underlying enum case (we have a test to verify this); this will do for now + "\(otherInternalError)" 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)" case let .attachmentFailed(feature, _): @@ -337,7 +344,8 @@ internal enum ChatError { underlyingError case let .roomTransitionedToInvalidStateForPresenceOperation(cause): cause - case .inconsistentRoomOptions, + case .nonErrorInfoInternalError, + .inconsistentRoomOptions, .roomInFailedState, .roomIsReleasing, .roomIsReleased, diff --git a/Sources/AblyChat/Extensions/Dictionary+Extensions.swift b/Sources/AblyChat/Extensions/Dictionary+Extensions.swift new file mode 100644 index 00000000..81dd9743 --- /dev/null +++ b/Sources/AblyChat/Extensions/Dictionary+Extensions.swift @@ -0,0 +1,8 @@ +internal extension Dictionary { + /// Behaves like `Dictionary.mapValues`, but the thrown error has the same type as that thrown by the transform. (`mapValues` uses `rethrows`, which is always an untyped throw.) + func ablyChat_mapValuesWithTypedThrow(_ transform: (Value) throws(E) -> T) throws(E) -> [Key: T] where E: Error { + try .init(uniqueKeysWithValues: map { key, value throws(E) in + try (key, transform(value)) + }) + } +} diff --git a/Sources/AblyChat/Headers.swift b/Sources/AblyChat/Headers.swift index a961bf25..6e9c1793 100644 --- a/Sources/AblyChat/Headers.swift +++ b/Sources/AblyChat/Headers.swift @@ -1,3 +1,5 @@ +import Ably + /// A value that can be used in ``Headers``. It is the same as ``JSONValue`` except it does not have the `object` or `array` cases. public enum HeadersValue: Sendable, Equatable { case string(String) @@ -73,7 +75,7 @@ extension HeadersValue: JSONDecodable { case unsupportedJSONValue(JSONValue) } - internal init(jsonValue: JSONValue) throws { + internal init(jsonValue: JSONValue) throws(InternalError) { self = switch jsonValue { case let .string(value): .string(value) @@ -84,7 +86,7 @@ extension HeadersValue: JSONDecodable { case .null: .null default: - throw JSONDecodingError.unsupportedJSONValue(jsonValue) + throw JSONDecodingError.unsupportedJSONValue(jsonValue).toInternalError() } } } diff --git a/Sources/AblyChat/InternalError.swift b/Sources/AblyChat/InternalError.swift new file mode 100644 index 00000000..ed4b0565 --- /dev/null +++ b/Sources/AblyChat/InternalError.swift @@ -0,0 +1,61 @@ +import Ably + +/// An error thrown by the internals of the Chat SDK. +/// +/// This was originally created to represent any of the various internal types that existed at the time of converting the public API of the SDK to throw ARTErrorInfo. We may rethink this when we do a broader rethink of the errors thrown by the SDK in https://github.com/ably/ably-chat-swift/issues/32. For now, feel free to introduce further internal error types and add them to the `Other` enum. +internal enum InternalError: Error { + case errorInfo(ARTErrorInfo) + case other(Other) + + internal enum Other { + case chatAPIChatError(ChatAPI.ChatError) + case headersValueJSONDecodingError(HeadersValue.JSONDecodingError) + case jsonValueDecodingError(JSONValueDecodingError) + case paginatedResultError(PaginatedResultError) + } + + /// Returns the error that this should be converted to when exposed via the SDK's public API. + internal func toARTErrorInfo() -> ARTErrorInfo { + switch self { + case let .errorInfo(errorInfo): + errorInfo + case let .other(other): + .init(chatError: .nonErrorInfoInternalError(other)) + } + } + + // Useful for logging + internal var message: String { + toARTErrorInfo().message + } +} + +internal extension ARTErrorInfo { + func toInternalError() -> InternalError { + .errorInfo(self) + } +} + +internal extension ChatAPI.ChatError { + func toInternalError() -> InternalError { + .other(.chatAPIChatError(self)) + } +} + +internal extension HeadersValue.JSONDecodingError { + func toInternalError() -> InternalError { + .other(.headersValueJSONDecodingError(self)) + } +} + +internal extension JSONValueDecodingError { + func toInternalError() -> InternalError { + .other(.jsonValueDecodingError(self)) + } +} + +internal extension PaginatedResultError { + func toInternalError() -> InternalError { + .other(.paginatedResultError(self)) + } +} diff --git a/Sources/AblyChat/JSONCodable.swift b/Sources/AblyChat/JSONCodable.swift index cbda2661..c20bd1b6 100644 --- a/Sources/AblyChat/JSONCodable.swift +++ b/Sources/AblyChat/JSONCodable.swift @@ -1,3 +1,4 @@ +import Ably import Foundation internal protocol JSONEncodable { @@ -5,7 +6,7 @@ internal protocol JSONEncodable { } internal protocol JSONDecodable { - init(jsonValue: JSONValue) throws + init(jsonValue: JSONValue) throws(InternalError) } internal typealias JSONCodable = JSONDecodable & JSONEncodable @@ -22,7 +23,7 @@ internal extension JSONObjectEncodable { } internal protocol JSONObjectDecodable: JSONDecodable { - init(jsonObject: [String: JSONValue]) throws + init(jsonObject: [String: JSONValue]) throws(InternalError) } internal enum JSONValueDecodingError: Error { @@ -34,9 +35,9 @@ internal enum JSONValueDecodingError: Error { // Default implementation of `JSONDecodable` conformance for `JSONObjectDecodable` internal extension JSONObjectDecodable { - init(jsonValue: JSONValue) throws { + init(jsonValue: JSONValue) throws(InternalError) { guard case let .object(jsonObject) = jsonValue else { - throw JSONValueDecodingError.valueIsNotObject + throw JSONValueDecodingError.valueIsNotObject.toInternalError() } self = try .init(jsonObject: jsonObject) @@ -54,13 +55,13 @@ internal extension [String: JSONValue] { /// - Throws: /// - `JSONValueDecodingError.noValueForKey` if the key is absent /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `object` - func objectValueForKey(_ key: String) throws -> [String: JSONValue] { + func objectValueForKey(_ key: String) throws(InternalError) -> [String: JSONValue] { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key) + throw JSONValueDecodingError.noValueForKey(key).toInternalError() } guard case let .object(objectValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return objectValue @@ -69,7 +70,7 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `object`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `object` or `null` - func optionalObjectValueForKey(_ key: String) throws -> [String: JSONValue]? { + func optionalObjectValueForKey(_ key: String) throws(InternalError) -> [String: JSONValue]? { guard let value = self[key] else { return nil } @@ -79,7 +80,7 @@ internal extension [String: JSONValue] { } guard case let .object(objectValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return objectValue @@ -90,13 +91,13 @@ internal extension [String: JSONValue] { /// - Throws: /// - `JSONValueDecodingError.noValueForKey` if the key is absent /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `array` - func arrayValueForKey(_ key: String) throws -> [JSONValue] { + func arrayValueForKey(_ key: String) throws(InternalError) -> [JSONValue] { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key) + throw JSONValueDecodingError.noValueForKey(key).toInternalError() } guard case let .array(arrayValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return arrayValue @@ -105,7 +106,7 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `array`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `array` or `null` - func optionalArrayValueForKey(_ key: String) throws -> [JSONValue]? { + func optionalArrayValueForKey(_ key: String) throws(InternalError) -> [JSONValue]? { guard let value = self[key] else { return nil } @@ -115,7 +116,7 @@ internal extension [String: JSONValue] { } guard case let .array(arrayValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return arrayValue @@ -126,13 +127,13 @@ internal extension [String: JSONValue] { /// - Throws: /// - `JSONValueDecodingError.noValueForKey` if the key is absent /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` - func stringValueForKey(_ key: String) throws -> String { + func stringValueForKey(_ key: String) throws(InternalError) -> String { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key) + throw JSONValueDecodingError.noValueForKey(key).toInternalError() } guard case let .string(stringValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return stringValue @@ -141,7 +142,7 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `string`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` - func optionalStringValueForKey(_ key: String) throws -> String? { + func optionalStringValueForKey(_ key: String) throws(InternalError) -> String? { guard let value = self[key] else { return nil } @@ -151,7 +152,7 @@ internal extension [String: JSONValue] { } guard case let .string(stringValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return stringValue @@ -162,13 +163,13 @@ internal extension [String: JSONValue] { /// - Throws: /// - `JSONValueDecodingError.noValueForKey` if the key is absent /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` - func numberValueForKey(_ key: String) throws -> Double { + func numberValueForKey(_ key: String) throws(InternalError) -> Double { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key) + throw JSONValueDecodingError.noValueForKey(key).toInternalError() } guard case let .number(numberValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return numberValue @@ -177,7 +178,7 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` - func optionalNumberValueForKey(_ key: String) throws -> Double? { + func optionalNumberValueForKey(_ key: String) throws(InternalError) -> Double? { guard let value = self[key] else { return nil } @@ -187,7 +188,7 @@ internal extension [String: JSONValue] { } guard case let .number(numberValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return numberValue @@ -198,13 +199,13 @@ internal extension [String: JSONValue] { /// - Throws: /// - `JSONValueDecodingError.noValueForKey` if the key is absent /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` - func boolValueForKey(_ key: String) throws -> Bool { + func boolValueForKey(_ key: String) throws(InternalError) -> Bool { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key) + throw JSONValueDecodingError.noValueForKey(key).toInternalError() } guard case let .bool(boolValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return boolValue @@ -215,7 +216,7 @@ internal extension [String: JSONValue] { /// - Throws: /// - `JSONValueDecodingError.noValueForKey` if the key is absent /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` - func optionalBoolValueForKey(_ key: String) throws -> Bool? { + func optionalBoolValueForKey(_ key: String) throws(InternalError) -> Bool? { guard let value = self[key] else { return nil } @@ -225,7 +226,7 @@ internal extension [String: JSONValue] { } guard case let .bool(boolValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value) + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return boolValue @@ -240,7 +241,7 @@ internal extension [String: JSONValue] { /// - Throws: /// - `JSONValueDecodingError.noValueForKey` if the key is absent /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` - func ablyProtocolDateValueForKey(_ key: String) throws -> Date { + func ablyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date { let millisecondsSinceEpoch = try numberValueForKey(key) return dateFromMillisecondsSinceEpoch(millisecondsSinceEpoch) @@ -249,7 +250,7 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this returns a date created by interpreting this value as the number of milliseconds since the Unix epoch (which is the format used by Ably). If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. /// /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` - func optionalAblyProtocolDateValueForKey(_ key: String) throws -> Date? { + func optionalAblyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date? { guard let millisecondsSinceEpoch = try optionalNumberValueForKey(key) else { return nil } @@ -271,7 +272,7 @@ internal extension [String: JSONValue] { /// - `JSONValueDecodingError.noValueForKey` if the key is absent /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` /// - `JSONValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` - func rawRepresentableValueForKey(_ key: String, type: T.Type = T.self) throws -> T where T.RawValue == String { + func rawRepresentableValueForKey(_ key: String, type: T.Type = T.self) throws(InternalError) -> T where T.RawValue == String { let rawValue = try stringValueForKey(key) return try rawRepresentableValueFromRawValue(rawValue, type: T.self) @@ -282,7 +283,7 @@ internal extension [String: JSONValue] { /// - Throws: /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` /// - `JSONValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` - func optionalRawRepresentableValueForKey(_ key: String, type: T.Type = T.self) throws -> T? where T.RawValue == String { + func optionalRawRepresentableValueForKey(_ key: String, type: T.Type = T.self) throws(InternalError) -> T? where T.RawValue == String { guard let rawValue = try optionalStringValueForKey(key) else { return nil } @@ -290,9 +291,9 @@ internal extension [String: JSONValue] { return try rawRepresentableValueFromRawValue(rawValue, type: T.self) } - private func rawRepresentableValueFromRawValue(_ rawValue: String, type _: T.Type = T.self) throws -> T where T.RawValue == String { + private func rawRepresentableValueFromRawValue(_ rawValue: String, type _: T.Type = T.self) throws(InternalError) -> T where T.RawValue == String { guard let value = T(rawValue: rawValue) else { - throw JSONValueDecodingError.failedToDecodeFromRawValue(rawValue) + throw JSONValueDecodingError.failedToDecodeFromRawValue(rawValue).toInternalError() } return value diff --git a/Sources/AblyChat/Message.swift b/Sources/AblyChat/Message.swift index 4a0130f3..6be1547b 100644 --- a/Sources/AblyChat/Message.swift +++ b/Sources/AblyChat/Message.swift @@ -152,7 +152,7 @@ public struct MessageOperation: Sendable, Equatable { } extension Message: JSONObjectDecodable { - internal init(jsonObject: [String: JSONValue]) throws { + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { let operation = try? jsonObject.objectValueForKey("operation") try self.init( serial: jsonObject.stringValueForKey("serial"), @@ -162,10 +162,12 @@ extension Message: JSONObjectDecodable { text: jsonObject.stringValueForKey("text"), createdAt: jsonObject.optionalAblyProtocolDateValueForKey("createdAt"), metadata: jsonObject.objectValueForKey("metadata"), - headers: jsonObject.objectValueForKey("headers").mapValues { try .init(jsonValue: $0) }, + headers: jsonObject.objectValueForKey("headers").ablyChat_mapValuesWithTypedThrow { jsonValue throws(InternalError) in + try .init(jsonValue: jsonValue) + }, version: jsonObject.stringValueForKey("version"), timestamp: jsonObject.optionalAblyProtocolDateValueForKey("timestamp"), - operation: operation.map { op in + operation: operation.map { op throws(InternalError) in try .init( clientID: op.stringValueForKey("clientId"), description: try? op.stringValueForKey("description"), diff --git a/Sources/AblyChat/Messages.swift b/Sources/AblyChat/Messages.swift index f73c3ae4..7a71516d 100644 --- a/Sources/AblyChat/Messages.swift +++ b/Sources/AblyChat/Messages.swift @@ -15,12 +15,12 @@ public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { * * - Returns: A subscription ``MessageSubscription`` that can be used to iterate through new messages. */ - func subscribe(bufferingPolicy: BufferingPolicy) async throws -> MessageSubscription + func subscribe(bufferingPolicy: BufferingPolicy) async throws(ARTErrorInfo) -> MessageSubscription /// Same as calling ``subscribe(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. /// /// The `Messages` protocol provides a default implementation of this method. - func subscribe() async throws -> MessageSubscription + func subscribe() async throws(ARTErrorInfo) -> MessageSubscription /** * Get messages that have been previously sent to the chat room, based on the provided options. @@ -30,7 +30,7 @@ public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { * * - Returns: A paginated result object that can be used to fetch more messages if available. */ - func get(options: QueryOptions) async throws -> any PaginatedResult + func get(options: QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult /** * Send a message in the chat room. @@ -44,7 +44,7 @@ public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { * * - Note: It is possible to receive your own message via the messages subscription before this method returns. */ - func send(params: SendMessageParams) async throws -> Message + func send(params: SendMessageParams) async throws(ARTErrorInfo) -> Message /** * Updates a message in the chat room. @@ -60,7 +60,7 @@ public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { * * - Note: It is possible to receive your own message via the messages subscription before this method returns. */ - func update(newMessage: Message, description: String?, metadata: OperationMetadata?) async throws -> Message + func update(newMessage: Message, description: String?, metadata: OperationMetadata?) async throws(ARTErrorInfo) -> Message /** * Deletes a message in the chat room. @@ -75,7 +75,7 @@ public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { * * - Note: It is possible to receive your own message via the messages subscription before this method returns. */ - func delete(message: Message, params: DeleteMessageParams) async throws -> Message + func delete(message: Message, params: DeleteMessageParams) async throws(ARTErrorInfo) -> Message /** * Get the underlying Ably realtime channel used for the messages in this chat room. @@ -86,7 +86,7 @@ public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { } public extension Messages { - func subscribe() async throws -> MessageSubscription { + func subscribe() async throws(ARTErrorInfo) -> MessageSubscription { try await subscribe(bufferingPolicy: .unbounded) } } diff --git a/Sources/AblyChat/Occupancy.swift b/Sources/AblyChat/Occupancy.swift index 135e8f1a..74c599f9 100644 --- a/Sources/AblyChat/Occupancy.swift +++ b/Sources/AblyChat/Occupancy.swift @@ -27,7 +27,7 @@ public protocol Occupancy: AnyObject, Sendable, EmitsDiscontinuities { * * - Returns: A current occupancy of the chat room. */ - func get() async throws -> OccupancyEvent + func get() async throws(ARTErrorInfo) -> OccupancyEvent /** * Get underlying Ably channel for occupancy events. @@ -66,7 +66,7 @@ public struct OccupancyEvent: Sendable { } extension OccupancyEvent: JSONObjectDecodable { - internal init(jsonObject: [String: JSONValue]) throws { + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { try self.init( connections: Int(jsonObject.numberValueForKey("connections")), presenceMembers: Int(jsonObject.numberValueForKey("presenceMembers")) diff --git a/Sources/AblyChat/PaginatedResult.swift b/Sources/AblyChat/PaginatedResult.swift index ff603a17..8af83de6 100644 --- a/Sources/AblyChat/PaginatedResult.swift +++ b/Sources/AblyChat/PaginatedResult.swift @@ -7,43 +7,44 @@ public protocol PaginatedResult: AnyObject, Sendable, Equatable { var hasNext: Bool { get } var isLast: Bool { get } // TODO: (https://github.com/ably-labs/ably-chat-swift/issues/11): consider how to avoid the need for an unwrap - var next: (any PaginatedResult)? { get async throws } - var first: any PaginatedResult { get async throws } - var current: any PaginatedResult { get async throws } + // Note that there seems to be a compiler bug (https://github.com/swiftlang/swift/issues/79992) that means that the compiler does not enforce the access level of the error type for property getters. I accidentally originally wrote these as throws(InternalError), which the compiler should have rejected since InternalError is internal and this protocol is public, but it did not reject it and this mistake was only noticed in code review. + var next: (any PaginatedResult)? { get async throws(ARTErrorInfo) } + var first: any PaginatedResult { get async throws(ARTErrorInfo) } + var current: any PaginatedResult { get async throws(ARTErrorInfo) } } /// Used internally to reduce the amount of duplicate code when interacting with `ARTHTTPPaginatedCallback`'s. The wrapper takes in the callback result from the caller e.g. `realtime.request` and either throws the appropriate error, or decodes and returns the response. internal struct ARTHTTPPaginatedCallbackWrapper { internal let callbackResult: (ARTHTTPPaginatedResponse?, ARTErrorInfo?) - internal func handleResponse(continuation: CheckedContinuation, any Error>) { + internal func handleResponse(continuation: CheckedContinuation, InternalError>, Never>) { let (paginatedResponse, error) = callbackResult // (CHA-M5i) If the REST API returns an error, then the method must throw its ErrorInfo representation. // (CHA-M6b) If the REST API returns an error, then the method must throw its ErrorInfo representation. if let error { - continuation.resume(throwing: ARTErrorInfo.create(from: error)) + continuation.resume(returning: .failure(error.toInternalError())) return } guard let paginatedResponse, paginatedResponse.statusCode == 200 else { - continuation.resume(throwing: PaginatedResultError.noErrorWithInvalidResponse) + continuation.resume(returning: .failure(PaginatedResultError.noErrorWithInvalidResponse.toInternalError())) return } do { let jsonValues = paginatedResponse.items.map { JSONValue(ablyCocoaData: $0) } - let decodedResponse = try jsonValues.map { try Response(jsonValue: $0) } + let decodedResponse = try jsonValues.map { jsonValue throws(InternalError) in try Response(jsonValue: jsonValue) } let result = paginatedResponse.toPaginatedResult(items: decodedResponse) - continuation.resume(returning: result) + continuation.resume(returning: .success(result)) } catch { - continuation.resume(throwing: error) + continuation.resume(returning: .failure(error)) } } +} - internal enum PaginatedResultError: Error { - case noErrorWithInvalidResponse - } +internal enum PaginatedResultError: Error { + case noErrorWithInvalidResponse } /// `PaginatedResult` protocol implementation allowing access to the underlying items from a lower level paginated response object e.g. `ARTHTTPPaginatedResponse`, whilst succinctly handling errors through the use of `ARTHTTPPaginatedCallbackWrapper`. @@ -62,22 +63,30 @@ internal final class PaginatedResultWrapper)? { - get async throws { - try await withCheckedThrowingContinuation { continuation in - paginatedResponse.next { paginatedResponse, error in - ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) - } + get async throws(ARTErrorInfo) { + do { + return try await withCheckedContinuation { continuation in + paginatedResponse.next { paginatedResponse, error in + ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) + } + }.get() + } catch { + throw error.toARTErrorInfo() } } } /// Asynchronously fetch the first page internal var first: any PaginatedResult { - get async throws { - try await withCheckedThrowingContinuation { continuation in - paginatedResponse.first { paginatedResponse, error in - ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) - } + get async throws(ARTErrorInfo) { + do { + return try await withCheckedContinuation { continuation in + paginatedResponse.first { paginatedResponse, error in + ARTHTTPPaginatedCallbackWrapper(callbackResult: (paginatedResponse, error)).handleResponse(continuation: continuation) + } + }.get() + } catch { + throw error.toARTErrorInfo() } } } @@ -95,7 +104,7 @@ internal final class PaginatedResultWrapper(items: [T]) -> PaginatedResultWrapper { PaginatedResultWrapper(paginatedResponse: self, items: items) diff --git a/Sources/AblyChat/Presence.swift b/Sources/AblyChat/Presence.swift index bf296b94..8fe0816d 100644 --- a/Sources/AblyChat/Presence.swift +++ b/Sources/AblyChat/Presence.swift @@ -12,7 +12,7 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { /** * Same as ``get(params:)``, but with defaults params. */ - func get() async throws -> [PresenceMember] + func get() async throws(ARTErrorInfo) -> [PresenceMember] /** * Method to get list of the current online users and returns the latest presence messages associated to it. @@ -24,7 +24,7 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { * * - Throws: An `ARTErrorInfo`. */ - func get(params: PresenceQuery) async throws -> [PresenceMember] + func get(params: PresenceQuery) async throws(ARTErrorInfo) -> [PresenceMember] /** * Method to check if user with supplied clientId is online. @@ -36,7 +36,7 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { * * - Throws: An `ARTErrorInfo`. */ - func isUserPresent(clientID: String) async throws -> Bool + func isUserPresent(clientID: String) async throws(ARTErrorInfo) -> Bool /** * Method to join room presence, will emit an enter event to all subscribers. Repeat calls will trigger more enter events. @@ -46,7 +46,7 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { * * - Throws: An `ARTErrorInfo`. */ - func enter(data: PresenceData) async throws + func enter(data: PresenceData) async throws(ARTErrorInfo) /** * Method to update room presence, will emit an update event to all subscribers. If the user is not present, it will be treated as a join event. @@ -56,7 +56,7 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { * * - Throws: An `ARTErrorInfo`. */ - func update(data: PresenceData) async throws + func update(data: PresenceData) async throws(ARTErrorInfo) /** * Method to leave room presence, will emit a leave event to all subscribers. If the user is not present, it will be treated as a no-op. @@ -66,7 +66,7 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { * * - Throws: An `ARTErrorInfo`. */ - func leave(data: PresenceData) async throws + func leave(data: PresenceData) async throws(ARTErrorInfo) /** * Subscribes a given listener to a particular presence event in the chat room. @@ -96,7 +96,7 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { * * - Throws: An `ARTErrorInfo`. */ - func enter() async throws + func enter() async throws(ARTErrorInfo) /** * Method to update room presence, will emit an update event to all subscribers. If the user is not present, it will be treated as a join event. @@ -104,7 +104,7 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { * * - Throws: An `ARTErrorInfo`. */ - func update() async throws + func update() async throws(ARTErrorInfo) /** * Method to leave room presence, will emit a leave event to all subscribers. If the user is not present, it will be treated as a no-op. @@ -112,7 +112,7 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { * * - Throws: An `ARTErrorInfo`. */ - func leave() async throws + func leave() async throws(ARTErrorInfo) /// Same as calling ``subscribe(event:bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. /// diff --git a/Sources/AblyChat/PresenceDataDTO.swift b/Sources/AblyChat/PresenceDataDTO.swift index 3759343e..6b0edfc2 100644 --- a/Sources/AblyChat/PresenceDataDTO.swift +++ b/Sources/AblyChat/PresenceDataDTO.swift @@ -10,7 +10,7 @@ extension PresenceDataDTO: JSONObjectCodable { case userCustomData } - internal init(jsonObject: [String: JSONValue]) throws { + internal init(jsonObject: [String: JSONValue]) { userCustomData = jsonObject[JSONKey.userCustomData.rawValue] } diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 590345a5..47dd417d 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -88,14 +88,14 @@ public protocol Room: AnyObject, Sendable { * * - Throws: An `ARTErrorInfo`. */ - func attach() async throws + func attach() async throws(ARTErrorInfo) /** * Detaches from the room to stop receiving events in realtime. * * - Throws: An `ARTErrorInfo`. */ - func detach() async throws + func detach() async throws(ARTErrorInfo) /** * Returns the room options. @@ -139,13 +139,13 @@ public struct RoomStatusChange: Sendable, Equatable { internal protocol RoomFactory: Sendable { associatedtype Room: AblyChat.InternalRoom - func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws -> Room + func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws(InternalError) -> Room } internal final class DefaultRoomFactory: Sendable, RoomFactory { private let lifecycleManagerFactory = DefaultRoomLifecycleManagerFactory() - internal func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws -> DefaultRoom { + internal func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws(InternalError) -> DefaultRoom { try await DefaultRoom( realtime: realtime, chatAPI: chatAPI, @@ -221,7 +221,7 @@ internal actor DefaultRoom } } - internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) async throws { + internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) async throws(InternalError) { self.realtime = realtime self.roomID = roomID self.options = options @@ -229,7 +229,7 @@ internal actor DefaultRoom self.chatAPI = chatAPI guard let clientId = realtime.clientId else { - throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") + throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.").toInternalError() } let featuresWithOptions = RoomFeatureWithOptions.fromRoomOptions(options) @@ -394,12 +394,20 @@ internal actor DefaultRoom return _occupancy } - public func attach() async throws { - try await lifecycleManager.performAttachOperation() + public func attach() async throws(ARTErrorInfo) { + do { + try await lifecycleManager.performAttachOperation() + } catch { + throw error.toARTErrorInfo() + } } - public func detach() async throws { - try await lifecycleManager.performDetachOperation() + public func detach() async throws(ARTErrorInfo) { + do { + try await lifecycleManager.performDetachOperation() + } catch { + throw error.toARTErrorInfo() + } } internal func release() async { diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index 61d19bb2..510a534a 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -58,7 +58,7 @@ internal protocol FeatureChannel: Sendable, EmitsDiscontinuities { /// /// - Parameters: /// - requester: The room feature that wishes to perform a presence operation. This is only used for customising the message of the thrown error. - func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(ARTErrorInfo) + func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(InternalError) } internal struct DefaultFeatureChannel: FeatureChannel { @@ -70,7 +70,7 @@ internal struct DefaultFeatureChannel: FeatureChannel { await contributor.onDiscontinuity(bufferingPolicy: bufferingPolicy) } - internal func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(ARTErrorInfo) { + internal func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(InternalError) { try await roomLifecycleManager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: requester) } } diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index 22844473..606e59cd 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -12,8 +12,8 @@ import AsyncAlgorithms /// /// We choose to also mark the channel’s mutable state as `async`. This is a way of highlighting at the call site of accessing this state that, since `ARTRealtimeChannel` mutates this state on a separate thread, it’s possible for this state to have changed since the last time you checked it, or since the last time you performed an operation that might have mutated it, or since the last time you recieved an event informing you that it changed. To be clear, marking these as `async` doesn’t _solve_ these issues; it just makes them a bit more visible. We’ll decide how to address them in https://github.com/ably-labs/ably-chat-swift/issues/49. internal protocol RoomLifecycleContributorChannel: Sendable { - func attach() async throws(ARTErrorInfo) - func detach() async throws(ARTErrorInfo) + func attach() async throws(InternalError) + func detach() async throws(InternalError) var state: ARTRealtimeChannelState { get async } var errorReason: ARTErrorInfo? { get async } @@ -41,12 +41,12 @@ internal protocol RoomLifecycleContributor: Identifiable, Sendable { } internal protocol RoomLifecycleManager: Sendable { - func performAttachOperation() async throws - func performDetachOperation() async throws + func performAttachOperation() async throws(InternalError) + func performDetachOperation() async throws(InternalError) func performReleaseOperation() async var roomStatus: RoomStatus { get async } func onRoomStatusChange(bufferingPolicy: BufferingPolicy) async -> Subscription - func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(ARTErrorInfo) + func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(InternalError) } internal protocol RoomLifecycleManagerFactory: Sendable { @@ -586,7 +586,7 @@ internal actor DefaultRoomLifecycleManager, Never> + typealias Continuation = CheckedContinuation, Never> private var operationResultContinuationsByOperationID: [UUID: [Continuation]] = [:] @@ -652,7 +652,7 @@ internal actor DefaultRoomLifecycleManager) { + private func operationWithID(_ operationID: UUID, didCompleteWithResult result: Result) { logger.log(message: "Operation \(operationID) completed with result \(result)", level: .debug) let continuationsToResume = operationResultContinuations.removeContinuationsForResultOfOperationWithID(operationID) @@ -696,11 +696,11 @@ internal actor DefaultRoomLifecycleManager Void - ) async throws(ARTErrorInfo) { + _ body: (UUID) async throws(InternalError) -> Void + ) async throws(InternalError) { let operationID = forcedOperationID ?? UUID() logger.log(message: "Performing operation \(operationID)", level: .debug) - let result: Result + let result: Result do { // My understanding (based on what the compiler allows me to do, and a vague understanding of how actors work) is that inside this closure you can write code as if it were a method on the manager itself — i.e. with synchronous access to the manager’s state. But I currently lack the Swift concurrency vocabulary to explain exactly why this is the case. try await body(operationID) @@ -743,11 +743,11 @@ internal actor DefaultRoomLifecycleManager any Room + func get(roomID: String, options: RoomOptions) async throws(ARTErrorInfo) -> any Room /** * Release the ``Room`` object if it exists. This method only releases the reference @@ -83,9 +83,9 @@ internal actor DefaultRooms: Rooms { // The options with which the room was requested. requestedOptions: RoomOptions, // A task that will return the result of this room fetch request. - creationTask: Task, + creationTask: Task, Never>, // Calling this function will cause `creationTask` to fail with the given error. - failCreation: @Sendable (Error) -> Void + failCreation: @Sendable (InternalError) -> Void ) /// The room has been created. @@ -102,10 +102,10 @@ internal actor DefaultRooms: Rooms { } /// Returns the room which this room map entry corresponds to. If the room map entry represents a pending request, it will return or throw with the result of this request. - func waitForRoom() async throws -> RoomFactory.Room { + func waitForRoom() async throws(InternalError) -> RoomFactory.Room { switch self { case let .requestAwaitingRelease(_, _, creationTask: creationTask, _): - try await creationTask.value + try await creationTask.value.get() case let .created(room): room } @@ -151,115 +151,130 @@ internal actor DefaultRooms: Rooms { } #endif - internal func get(roomID: String, options: RoomOptions) async throws -> any Room { - if let existingRoomState = roomStates[roomID] { - switch existingRoomState { - case let .roomMapEntry(existingRoomMapEntry): - // CHA-RC1f1 - if existingRoomMapEntry.roomOptions != options { - throw ARTErrorInfo( - chatError: .inconsistentRoomOptions(requested: options, existing: existingRoomMapEntry.roomOptions) - ) - } + internal func get(roomID: String, options: RoomOptions) async throws(ARTErrorInfo) -> any Room { + do throws(InternalError) { + if let existingRoomState = roomStates[roomID] { + switch existingRoomState { + case let .roomMapEntry(existingRoomMapEntry): + // CHA-RC1f1 + if existingRoomMapEntry.roomOptions != options { + throw ARTErrorInfo( + chatError: .inconsistentRoomOptions(requested: options, existing: existingRoomMapEntry.roomOptions) + ).toInternalError() + } - // CHA-RC1f2 - logger.log(message: "Waiting for room from existing room map entry \(existingRoomMapEntry)", level: .debug) + // CHA-RC1f2 + logger.log(message: "Waiting for room from existing room map entry \(existingRoomMapEntry)", level: .debug) - #if DEBUG - emitOperationWaitEvent(waitingOperationType: .get, waitedOperationType: .get) - #endif + #if DEBUG + emitOperationWaitEvent(waitingOperationType: .get, waitedOperationType: .get) + #endif - do { - let room = try await existingRoomMapEntry.waitForRoom() - logger.log(message: "Completed waiting for room from existing room map entry \(existingRoomMapEntry)", level: .debug) - return room - } catch { - logger.log(message: "Got error \(error) waiting for room from existing room map entry \(existingRoomMapEntry)", level: .debug) - throw error - } - case let .releaseOperationInProgress(releaseTask: releaseTask): - let creationFailureFunctions = makeCreationFailureFunctions() - - let creationTask = Task { - logger.log(message: "At start of room creation task", level: .debug) - - // We wait for the first of the following events: - // - // - a creation failure is externally signalled, in which case we throw the corresponding error - // - the in-progress release operation completes - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await creationFailureFunctions.throwAnySignalledCreationFailure() - } + do { + let room = try await existingRoomMapEntry.waitForRoom() + logger.log(message: "Completed waiting for room from existing room map entry \(existingRoomMapEntry)", level: .debug) + return room + } catch { + logger.log(message: "Got error \(error) waiting for room from existing room map entry \(existingRoomMapEntry)", level: .debug) + throw error + } + case let .releaseOperationInProgress(releaseTask: releaseTask): + let creationFailureFunctions = makeCreationFailureFunctions() - group.addTask { [logger] in - // This task is rather messy but its aim can be summarised as the following: + let creationTask = Task, Never> { + do throws(InternalError) { + logger.log(message: "At start of room creation task", level: .debug) + + // We wait for the first of the following events: // - // - if releaseTask completes, then complete - // - if the task is cancelled, then do not propagate the cancellation to releaseTask (because we haven’t properly thought through whether it can handle task cancellation; see existing TODO: https://github.com/ably/ably-chat-swift/issues/29), and do not wait for releaseTask to complete (because the CHA-RC1g4 failure is meant to happen immediately, not only once the release operation completes) - - logger.log(message: "Room creation waiting for completion of release operation", level: .debug) - #if DEBUG - await self.emitOperationWaitEvent(waitingOperationType: .get, waitedOperationType: .release) - #endif - - let (stream, continuation) = AsyncStream.makeStream() - Task.detached { // detached so as not to propagate task cancellation - // CHA-RC1f4 - await releaseTask.value - continuation.yield(()) - continuation.finish() - } - - if await (stream.contains { _ in true }) { - logger.log(message: "Room creation completed waiting for completion of release operation", level: .debug) - } else { - // Task was cancelled - logger.log(message: "Room creation stopped waiting for completion of release operation", level: .debug) - } + // - a creation failure is externally signalled, in which case we throw the corresponding error + // - the in-progress release operation completes + try await withTaskGroup(of: Result.self) { group in + group.addTask { + do throws(InternalError) { + try await creationFailureFunctions.throwAnySignalledCreationFailure() + return .success(()) + } catch { + return .failure(error) + } + } + + group.addTask { [logger] in + // This task is rather messy but its aim can be summarised as the following: + // + // - if releaseTask completes, then complete + // - if the task is cancelled, then do not propagate the cancellation to releaseTask (because we haven’t properly thought through whether it can handle task cancellation; see existing TODO: https://github.com/ably/ably-chat-swift/issues/29), and do not wait for releaseTask to complete (because the CHA-RC1g4 failure is meant to happen immediately, not only once the release operation completes) + + logger.log(message: "Room creation waiting for completion of release operation", level: .debug) + #if DEBUG + await self.emitOperationWaitEvent(waitingOperationType: .get, waitedOperationType: .release) + #endif + + let (stream, continuation) = AsyncStream.makeStream() + Task.detached { // detached so as not to propagate task cancellation + // CHA-RC1f4 + await releaseTask.value + continuation.yield(()) + continuation.finish() + } + + if await (stream.contains { _ in true }) { + logger.log(message: "Room creation completed waiting for completion of release operation", level: .debug) + } else { + // Task was cancelled + logger.log(message: "Room creation stopped waiting for completion of release operation", level: .debug) + } + + return .success(()) + } + + // This pattern for waiting for the first of multiple tasks to complete is taken from here: + // https://forums.swift.org/t/accept-the-first-task-to-complete/54386 + defer { group.cancelAll() } + return await group.next() ?? .success(()) + }.get() + + return try await .success(createRoom(roomID: roomID, options: options)) + } catch { + return .failure(error) } - - // This pattern for waiting for the first of multiple tasks to complete is taken from here: - // https://forums.swift.org/t/accept-the-first-task-to-complete/54386 - defer { group.cancelAll() } - try await group.next() } - return try await createRoom(roomID: roomID, options: options) - } - - roomStates[roomID] = .roomMapEntry( - .requestAwaitingRelease( - releaseTask: releaseTask, - requestedOptions: options, - creationTask: creationTask, - failCreation: creationFailureFunctions.failCreation + roomStates[roomID] = .roomMapEntry( + .requestAwaitingRelease( + releaseTask: releaseTask, + requestedOptions: options, + creationTask: creationTask, + failCreation: creationFailureFunctions.failCreation + ) ) - ) - return try await creationTask.value + return try await creationTask.value.get() + } } - } - // CHA-RC1f3 - return try await createRoom(roomID: roomID, options: options) + // CHA-RC1f3 + return try await createRoom(roomID: roomID, options: options) + } catch { + throw error.toARTErrorInfo() + } } /// Creates two functions, `failCreation` and `throwAnySignalledCreationFailure`. The latter is an async function that waits until the former is called with an error as an argument; it then throws this error. - private func makeCreationFailureFunctions() -> (failCreation: @Sendable (Error) -> Void, throwAnySignalledCreationFailure: @Sendable () async throws -> Void) { - let (stream, continuation) = AsyncThrowingStream.makeStream(of: Void.self, throwing: Error.self) + private func makeCreationFailureFunctions() -> (failCreation: @Sendable (InternalError) -> Void, throwAnySignalledCreationFailure: @Sendable () async throws(InternalError) -> Void) { + let (stream, continuation) = AsyncStream.makeStream(of: Result.self) return ( - failCreation: { @Sendable [logger] (error: Error) in + failCreation: { @Sendable [logger] (error: InternalError) in logger.log(message: "Recieved request to fail room creation with error \(error)", level: .debug) - continuation.finish(throwing: error) + continuation.yield(.failure(error)) + continuation.finish() }, - throwAnySignalledCreationFailure: { @Sendable [logger] in + throwAnySignalledCreationFailure: { @Sendable [logger] () throws(InternalError) in logger.log(message: "Waiting for room creation failure request", level: .debug) - do { - try await stream.first { _ in true } + do throws(InternalError) { + try await stream.first { _ in true }?.get() } catch { - logger.log(message: "Wait for room creation failure request gave error \(error)", level: .debug) throw error } logger.log(message: "Wait for room creation failure request completed without error", level: .debug) @@ -276,7 +291,7 @@ internal actor DefaultRooms: Rooms { logger.log(message: "\(waitingOperationType) operation completed waiting for in-progress \(waitedOperationType) operation to complete", level: .debug) } - private func createRoom(roomID: String, options: RoomOptions) async throws -> RoomFactory.Room { + private func createRoom(roomID: String, options: RoomOptions) async throws(InternalError) -> RoomFactory.Room { logger.log(message: "Creating room with ID \(roomID), options \(options)", level: .debug) let room = try await roomFactory.createRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger) roomStates[roomID] = .roomMapEntry(.created(room: room)) @@ -317,7 +332,7 @@ internal actor DefaultRooms: Rooms { ): // CHA-RC1g4 logger.log(message: "Release operation requesting failure of in-progress room creation request", level: .debug) - failCreation(ARTErrorInfo(chatError: .roomReleasedBeforeOperationCompleted)) + failCreation(ARTErrorInfo(chatError: .roomReleasedBeforeOperationCompleted).toInternalError()) await waitForOperation(releaseTask, waitingOperationType: .release, waitedOperationType: .release) case let .roomMapEntry(.created(room: room)): let releaseTask = Task { diff --git a/Sources/AblyChat/Typing.swift b/Sources/AblyChat/Typing.swift index 5c2065fc..fcd5d48a 100644 --- a/Sources/AblyChat/Typing.swift +++ b/Sources/AblyChat/Typing.swift @@ -27,7 +27,7 @@ public protocol Typing: AnyObject, Sendable, EmitsDiscontinuities { * * - Returns: A set of clientIds that are currently typing. */ - func get() async throws -> Set + func get() async throws(ARTErrorInfo) -> Set /** * Start indicates that the current user is typing. This will emit a ``TypingEvent`` event to inform listening clients and begin a timer, @@ -39,7 +39,7 @@ public protocol Typing: AnyObject, Sendable, EmitsDiscontinuities { * * - Throws: An `ARTErrorInfo`. */ - func start() async throws + func start() async throws(ARTErrorInfo) /** * Stop indicates that the current user has stopped typing. This will emit a ``TypingEvent`` event to inform listening clients, @@ -47,7 +47,7 @@ public protocol Typing: AnyObject, Sendable, EmitsDiscontinuities { * * - Throws: An `ARTErrorInfo`. */ - func stop() async throws + func stop() async throws(ARTErrorInfo) /** * Get the Ably realtime channel underpinning typing events. diff --git a/Tests/AblyChatTests/ChatAPITests.swift b/Tests/AblyChatTests/ChatAPITests.swift index 1b80835e..fa6a8c08 100644 --- a/Tests/AblyChatTests/ChatAPITests.swift +++ b/Tests/AblyChatTests/ChatAPITests.swift @@ -20,7 +20,11 @@ struct ChatAPITests { try await chatAPI.sendMessage(roomId: roomId, params: .init(text: "hello", headers: [:])) }, throws: { error in // Then - error as? ChatAPI.ChatError == ChatAPI.ChatError.noItemInResponse + if let internalError = error as? InternalError, case .other(.chatAPIChatError(.noItemInResponse)) = internalError { + true + } else { + false + } } ) } @@ -189,7 +193,7 @@ struct ChatAPITests { try await chatAPI.getMessages(roomId: roomId, params: .init()) as? PaginatedResultWrapper }, throws: { error in // Then - error as? ARTErrorInfo == artError + isInternalErrorWrappingErrorInfo(error, artError) } ) } diff --git a/Tests/AblyChatTests/DefaultMessagesTests.swift b/Tests/AblyChatTests/DefaultMessagesTests.swift index 10664891..6aa390f5 100644 --- a/Tests/AblyChatTests/DefaultMessagesTests.swift +++ b/Tests/AblyChatTests/DefaultMessagesTests.swift @@ -15,9 +15,13 @@ struct DefaultMessagesTests { let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) // Then - await #expect(throws: ARTErrorInfo.create(withCode: 40000, status: 400, message: "channel is attached, but channelSerial is not defined"), performing: { + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { // When try await defaultMessages.subscribe() + } + await #expect(throws: ARTErrorInfo.create(withCode: 40000, status: 400, message: "channel is attached, but channelSerial is not defined"), performing: { + try await doIt() }) } @@ -33,11 +37,15 @@ struct DefaultMessagesTests { let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) // Then - await #expect(throws: Never.self, performing: { + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { // When // `_ =` is required to avoid needing iOS 16 to run this test // Error: Runtime support for parameterized protocol types is only available in iOS 16.0.0 or newer _ = try await defaultMessages.get(options: .init()) + } + await #expect(throws: Never.self, performing: { + try await doIt() }) } diff --git a/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift b/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift index ac2cc3b5..0f0d8348 100644 --- a/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift +++ b/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift @@ -2191,7 +2191,7 @@ struct DefaultRoomLifecycleManagerTests { // (Note: I wanted to use #expect(…, throws:) below, but for some reason it made the compiler _crash_! No idea why. So, gave up on that.) // When: `waitToBeAbleToPerformPresenceOperations(requestedByFeature:)` is called on the lifecycle manager - var caughtError: ARTErrorInfo? + var caughtError: Error? do { try await manager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: .messages /* arbitrary */ ) } catch { diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift index f78e4a0f..10ca59f2 100644 --- a/Tests/AblyChatTests/DefaultRoomsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomsTests.swift @@ -146,8 +146,12 @@ struct DefaultRoomsTests { // Then: It throws a `badRequest` error let differentOptions = RoomOptions(presence: .init(subscribe: false)) - await #expect { + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { try await rooms.get(roomID: roomID, options: differentOptions) + } + await #expect { + try await doIt() } throws: { error in isChatError(error, withCodeAndStatusCode: .fixedStatusCode(.badRequest)) } @@ -186,8 +190,12 @@ struct DefaultRoomsTests { // Then: The second call to get(roomID:options:) throws a `badRequest` error let differentOptions = RoomOptions(presence: .init(subscribe: false)) - await #expect { + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { try await rooms.get(roomID: roomID, options: differentOptions) + } + await #expect { + try await doIt() } throws: { error in isChatError(error, withCodeAndStatusCode: .fixedStatusCode(.badRequest)) } diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift index 289adcd1..75d228f8 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -2,10 +2,20 @@ import Ably @testable import AblyChat /** - Tests whether a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code and cause. Can optionally pass a message and it will check that it matches. + Tests whether a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code and cause, or an `InternalError` that wraps such an `ARTErrorInfo`. Can optionally pass a message and it will check that it matches. */ func isChatError(_ maybeError: (any Error)?, withCodeAndStatusCode codeAndStatusCode: AblyChat.ErrorCodeAndStatusCode, cause: ARTErrorInfo? = nil, message: String? = nil) -> Bool { - guard let ablyError = maybeError as? ARTErrorInfo else { + // Is it an ARTErrorInfo? + var ablyError = maybeError as? ARTErrorInfo + + // Is it an InternalError wrapping an ARTErrorInfo? + if ablyError == nil { + if let internalError = maybeError as? InternalError, case let .errorInfo(errorInfo) = internalError { + ablyError = errorInfo + } + } + + guard let ablyError else { return false } @@ -21,3 +31,44 @@ func isChatError(_ maybeError: (any Error)?, withCodeAndStatusCode codeAndStatus return ablyError.message == message }() } + +func isInternalErrorWrappingErrorInfo(_ error: any Error, _ expectedErrorInfo: ARTErrorInfo) -> Bool { + if let internalError = error as? InternalError, case let .errorInfo(actualErrorInfo) = internalError, expectedErrorInfo == actualErrorInfo { + true + } else { + false + } +} + +extension InternalError { + enum Case { + case errorInfo + case chatAPIChatError + case headersValueJSONDecodingError + case jsonValueDecodingError + case paginatedResultError + } + + var enumCase: Case { + switch self { + case .errorInfo: + .errorInfo + case .other(.chatAPIChatError): + .chatAPIChatError + case .other(.headersValueJSONDecodingError): + .headersValueJSONDecodingError + case .other(.jsonValueDecodingError): + .jsonValueDecodingError + case .other(.paginatedResultError): + .paginatedResultError + } + } +} + +func isInternalErrorWithCase(_ error: any Error, _ expectedCase: InternalError.Case) -> Bool { + if let internalError = error as? InternalError, internalError.enumCase == expectedCase { + true + } else { + false + } +} diff --git a/Tests/AblyChatTests/InternalErrorTests.swift b/Tests/AblyChatTests/InternalErrorTests.swift new file mode 100644 index 00000000..35fd6654 --- /dev/null +++ b/Tests/AblyChatTests/InternalErrorTests.swift @@ -0,0 +1,24 @@ +import Ably +@testable import AblyChat +import Testing + +struct InternalErrorTests { + @Test + func toARTErrorInfo_whenUnderlyingErrorIsARTErrorInfo() { + let underlyingErrorInfo = ARTErrorInfo.createUnknownError() + let internalError = InternalError.errorInfo(underlyingErrorInfo) + + let convertedToErrorInfo = internalError.toARTErrorInfo() + #expect(convertedToErrorInfo === underlyingErrorInfo) + } + + @Test + func testToARTErrorInfo_whenUnderlyingErrorIsNotARTErrorInfo() { + let internalError = InternalError.other(.chatAPIChatError(.noItemInResponse)) + + let convertedToErrorInfo = internalError.toARTErrorInfo() + #expect(isChatError(convertedToErrorInfo, withCodeAndStatusCode: .fixedStatusCode(.badRequest))) + // Just check that there's _something_ in the error message that allows us to identify the underlying error + #expect(convertedToErrorInfo.localizedDescription.contains("ChatAPI.ChatError.noItemInResponse")) + } +} diff --git a/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift b/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift index e3893e98..89b9305e 100644 --- a/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift @@ -22,11 +22,15 @@ final actor MockFeatureChannel: FeatureChannel { discontinuitySubscriptions.emit(discontinuity) } - func waitToBeAbleToPerformPresenceOperations(requestedByFeature _: RoomFeature) async throws(ARTErrorInfo) { + func waitToBeAbleToPerformPresenceOperations(requestedByFeature _: RoomFeature) async throws(InternalError) { guard let resultOfWaitToBeAbleToPerformPresenceOperations else { fatalError("resultOfWaitToBeAblePerformPresenceOperations must be set before waitToBeAbleToPerformPresenceOperations is called") } - try resultOfWaitToBeAbleToPerformPresenceOperations.get() + do { + try resultOfWaitToBeAbleToPerformPresenceOperations.get() + } catch { + throw error.toInternalError() + } } } diff --git a/Tests/AblyChatTests/Mocks/MockRoom.swift b/Tests/AblyChatTests/Mocks/MockRoom.swift index f5e539d3..645f0c78 100644 --- a/Tests/AblyChatTests/Mocks/MockRoom.swift +++ b/Tests/AblyChatTests/Mocks/MockRoom.swift @@ -1,3 +1,4 @@ +import Ably @testable import AblyChat actor MockRoom: InternalRoom { @@ -43,11 +44,11 @@ actor MockRoom: InternalRoom { fatalError("Not implemented") } - func attach() async throws { + func attach() async throws(ARTErrorInfo) { fatalError("Not implemented") } - func detach() async throws { + func detach() async throws(ARTErrorInfo) { fatalError("Not implemented") } diff --git a/Tests/AblyChatTests/Mocks/MockRoomFactory.swift b/Tests/AblyChatTests/Mocks/MockRoomFactory.swift index b7ac6acc..4019c50d 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomFactory.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomFactory.swift @@ -13,7 +13,7 @@ actor MockRoomFactory: RoomFactory { self.room = room } - func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: any InternalLogger) async throws -> MockRoom { + func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: any InternalLogger) async throws(InternalError) -> MockRoom { createRoomCallCount += 1 createRoomArguments = (realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger) diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift index 9b25c414..b7faaf71 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift @@ -61,24 +61,32 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel case addSubscriptionAndEmitStateChange(ARTChannelStateChange) } - func attach() async throws(ARTErrorInfo) { + func attach() async throws(InternalError) { attachCallCount += 1 guard let attachBehavior else { fatalError("attachBehavior must be set before attach is called") } - try await performBehavior(attachBehavior, callCount: attachCallCount) + do { + try await performBehavior(attachBehavior, callCount: attachCallCount) + } catch { + throw error.toInternalError() + } } - func detach() async throws(ARTErrorInfo) { + func detach() async throws(InternalError) { detachCallCount += 1 guard let detachBehavior else { fatalError("detachBehavior must be set before detach is called") } - try await performBehavior(detachBehavior, callCount: detachCallCount) + do { + try await performBehavior(detachBehavior, callCount: detachCallCount) + } catch { + throw error.toInternalError() + } } private func performBehavior(_ behavior: AttachOrDetachBehavior, callCount: Int) async throws(ARTErrorInfo) { diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift index e1254bd8..2bf4a961 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift @@ -16,20 +16,28 @@ actor MockRoomLifecycleManager: RoomLifecycleManager { _roomStatus = roomStatus } - func performAttachOperation() async throws { + func performAttachOperation() async throws(InternalError) { attachCallCount += 1 guard let attachResult else { fatalError("In order to call performAttachOperation, attachResult must be passed to the initializer") } - try attachResult.get() + do { + try attachResult.get() + } catch { + throw error.toInternalError() + } } - func performDetachOperation() async throws { + func performDetachOperation() async throws(InternalError) { detachCallCount += 1 guard let detachResult else { fatalError("In order to call performDetachOperation, detachResult must be passed to the initializer") } - try detachResult.get() + do { + try detachResult.get() + } catch { + throw error.toInternalError() + } } func performReleaseOperation() async { @@ -51,7 +59,7 @@ actor MockRoomLifecycleManager: RoomLifecycleManager { subscriptions.emit(statusChange) } - func waitToBeAbleToPerformPresenceOperations(requestedByFeature _: RoomFeature) async throws(ARTErrorInfo) { + func waitToBeAbleToPerformPresenceOperations(requestedByFeature _: RoomFeature) async throws(InternalError) { fatalError("Not implemented") } } diff --git a/Tests/AblyChatTests/PresenceDataDTOTests.swift b/Tests/AblyChatTests/PresenceDataDTOTests.swift index 0e937a92..087e809b 100644 --- a/Tests/AblyChatTests/PresenceDataDTOTests.swift +++ b/Tests/AblyChatTests/PresenceDataDTOTests.swift @@ -18,8 +18,10 @@ struct PresenceDataDTOTests { @Test func initWithJSONValue_failsIfNotObject() { - #expect(throws: JSONValueDecodingError.self) { + #expect { try PresenceDataDTO(jsonValue: "hello") + } throws: { error in + isInternalErrorWithCase(error, .jsonValueDecodingError) } } diff --git a/Tests/AblyChatTests/RoomReactionDTOTests.swift b/Tests/AblyChatTests/RoomReactionDTOTests.swift index d818b9bb..0ee09c17 100644 --- a/Tests/AblyChatTests/RoomReactionDTOTests.swift +++ b/Tests/AblyChatTests/RoomReactionDTOTests.swift @@ -7,15 +7,19 @@ enum RoomReactionDTOTests { @Test func initWithJSONValue_failsIfNotObject() { - #expect(throws: JSONValueDecodingError.self) { + #expect { try RoomReactionDTO.Data(jsonValue: "hello") + } throws: { error in + isInternalErrorWithCase(error, .jsonValueDecodingError) } } @Test func initWithJSONValue_withNoTypeKey() { - #expect(throws: JSONValueDecodingError.self) { + #expect { try RoomReactionDTO.Data(jsonValue: [:]) + } throws: { error in + isInternalErrorWithCase(error, .jsonValueDecodingError) } } @@ -66,8 +70,10 @@ enum RoomReactionDTOTests { @Test func initWithJSONValue_failsIfNotObject() { - #expect(throws: JSONValueDecodingError.self) { + #expect { try RoomReactionDTO.Extras(jsonValue: "hello") + } throws: { error in + isInternalErrorWithCase(error, .jsonValueDecodingError) } }