Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 24 additions & 24 deletions Example/AblyChatExample/Mocks/MockClients.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -66,11 +66,11 @@ actor MockRoom: Room {

private let mockSubscriptions = MockSubscriptionStorage<RoomStatusChange>()

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")
}

Expand Down Expand Up @@ -122,11 +122,11 @@ actor MockMessages: Messages {
}
}

func get(options _: QueryOptions) async throws -> any PaginatedResult<Message> {
func get(options _: QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult<Message> {
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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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: [:],
Expand Down Expand Up @@ -258,15 +258,15 @@ actor MockTyping: Typing {
.init(mockAsyncSequence: createSubscription())
}

func get() async throws -> Set<String> {
func get() async throws(ARTErrorInfo) -> Set<String> {
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: []))
}

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}

Expand Down
186 changes: 164 additions & 22 deletions Sources/AblyChat/AblyCocoaExtensions/Ably+Concurrency.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<ARTHTTPPaginatedResponse, ARTErrorInfo>, _>) 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<Result<Void, ARTErrorInfo>, _>) 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<Result<Void, ARTErrorInfo>, _>) 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<Result<Void, ARTErrorInfo>, _>) 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<Result<Void, ARTErrorInfo>, _>) 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<Result<[PresenceMessage], ARTErrorInfo>, _>) 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<Result<[PresenceMessage], ARTErrorInfo>, _>) 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<Result<Void, ARTErrorInfo>, _>) 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<Result<Void, ARTErrorInfo>, _>) 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<Result<Void, ARTErrorInfo>, _>) in
update(data?.toAblyCocoaData) { error in
if let error {
continuation.resume(returning: .failure(error))
} else {
continuation.resume(returning: .success(()))
}
}
}.get()
} catch {
throw error.toInternalError()
}
}
}
Loading