Skip to content

Commit 20e7f5f

Browse files
Define the public API of the SDK
Based on JS repo at 0b4b0f8. I have not performed any review or critique of the JS API, and simply copied it and mildly adapted to Swift. There are some things that I’m unsure about regarding the usability and implementability of this Swift API. We’ll be able to validate the usability when we do #4, which will create a mock implementation of this API and then build the example app around the mock. Implementability we’ll discover as we try to build the SDK. This is just a first attempt and all of the decisions can be revisited. TypeScript-to-Swift decisions: - I’ve named the entry point class DefaultChatClient instead of their ChatClient; this is so that I can have a public protocol named ChatClient. - My `throws` annotations are based on the JS docstrings (or in a few cases my assumptions). The Rooms accessors that throw in JS if a feature is not enabled instead call fatalError in Swift, which is the idiomatic thing to do for programmer errors [1]. - Skipped copying the docstrings from JS to avoid churn; created #1 to do this later. Turned off missing_docs for now. - The listener pattern in JS is instead implemented by returning an AsyncSequence. This was partly because of Umair’s architecture thoughts [2] which — at least my reading of it — indicated a desire to use some sort of reactive API, and partly because I was curious to try it out, having not used it before. I believe that we do not need an equivalent of the `off*` / `unsubscribe*` methods since iterating over an AsyncSequence is a pull rather than a push. And I believe (but am still quite shaky about the details, so may be wrong) that there are AsyncSequence lifecycle events (e.g. end of iteration, task cancellation) that we can use to manage the underlying ably-cocoa listeners. And, I’m sure that there will be things we have to consider about how to make sure that we don’t have leaks of MessageSubscriptions which cause messages to start accumulating in buffer that the user forgot exists. - RoomOptionsDefaults in JS is instead implemented here by giving the *Options types a no-args initializer that populates the default values. - I’ve copied the logging interface more or less from JS (but with LogHandler a protocol so that we can have argument labels). Will think about something more idiomatic in #8. Swift decisions and thoughts: - My decision on what should be a protocol and what should be a concrete type was fairly arbitrary; I’ve made everything a protocol (for mockability) except structs that are basically just containers for data (but this line is blurry; for example, this might introduce issues for somebody who wants to be able to mock Message’s isBefore(_:) method). One downside of using protocols is that you can’t nest types inside them (this would be nice for e.g. related enums) but it’s alright. - I’ve annotated all of the protocols that feel like they represent some sort of client with AnyObject; I don’t have a great explanation of why but intuitively it felt right (seemed like they should be reference types). - Having not yet used Swift concurrency much, I didn’t have a good intuition for what things should be Sendable, so I’ve put it on pretty much everything. Similarly, I don’t have a good sense of what should be annotated as `async` (for some of this our hand will probably also end up being forced by the implementation). I also am not sure whether the `current` / `error` properties for connection and room status make sense in a world where most things are async (especially if the intention is that, for example, the user check `current` before deciding whether to call a certain method, and this method will throw an error if they get it wrong, but the state of the world might have changed since they checked it and that’s not their fault), but I’ve kept them for now. - Chose to use existential types when one protocol returns another (i.e. `-> any MyProtocol`) instead of associated types, because it’s more readable (you can’t use opaque types in a protocol declaration) and I don’t think that we need to worry about the performance implications. - I’ve deferred adding helpful protocol conformances (Equatable, Hashable, Codable etc) to #10. - I’ve turned on the explicit_acl SwiftLint rule, which forces us to add an access control modifier to all declarations. This makes it less likely that we forget to make something `public` instead of the default `internal`. Resolves #7. [1] https://www.douggregor.net/posts/swift-for-cxx-practitioners-error-handling/ [2] https://ably.atlassian.net/wiki/spaces/SDK/pages/3261202438/Swift+Architecture+Thoughts
1 parent cb48dda commit 20e7f5f

30 files changed

+599
-10
lines changed

.swiftlint.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ opt_in_rules:
7777
# Opt-in rules of type "lint" that we’ve decided we want:
7878
- array_init
7979
- empty_xctest_method
80-
- missing_docs
8180
- override_in_extension
8281
- yoda_condition
8382
- private_swiftui_state

CONTRIBUTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with `
2525
## Development guidelines
2626

2727
- The aim of the [example app](README.md#example-app) is that it demonstrate all of the core functionality of the SDK. So if you add a new feature, try to add something to the example app to demonstrate this feature.
28+
- We should aim to make it easy for consumers of the SDK to be able to mock out the SDK in the tests for their own code. A couple of things that will aid with this:
29+
- Describe the SDK’s functionality via protocols (when doing so would still be sufficiently idiomatic to Swift).
30+
- 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.)
2831

2932
## Building for Swift 6
3033

Example/AblyChatExample.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
212F95A72C6CAD9300420287 /* MockRealtime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212F95A62C6CAD9300420287 /* MockRealtime.swift */; };
1011
21971DFF2C60D89C0074B8AE /* AblyChat in Frameworks */ = {isa = PBXBuildFile; productRef = 21971DFE2C60D89C0074B8AE /* AblyChat */; };
1112
21F09AA02C60CAF00025AF73 /* AblyChatExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21F09A9F2C60CAF00025AF73 /* AblyChatExampleApp.swift */; };
1213
21F09AA22C60CAF00025AF73 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21F09AA12C60CAF00025AF73 /* ContentView.swift */; };
@@ -15,6 +16,7 @@
1516
/* End PBXBuildFile section */
1617

1718
/* Begin PBXFileReference section */
19+
212F95A62C6CAD9300420287 /* MockRealtime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRealtime.swift; sourceTree = "<group>"; };
1820
21F09A9C2C60CAF00025AF73 /* AblyChatExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AblyChatExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
1921
21F09A9F2C60CAF00025AF73 /* AblyChatExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AblyChatExampleApp.swift; sourceTree = "<group>"; };
2022
21F09AA12C60CAF00025AF73 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -35,6 +37,14 @@
3537
/* End PBXFrameworksBuildPhase section */
3638

3739
/* Begin PBXGroup section */
40+
212F95A52C6CAD7E00420287 /* Mocks */ = {
41+
isa = PBXGroup;
42+
children = (
43+
212F95A62C6CAD9300420287 /* MockRealtime.swift */,
44+
);
45+
path = Mocks;
46+
sourceTree = "<group>";
47+
};
3848
21971DFD2C60D89C0074B8AE /* Frameworks */ = {
3949
isa = PBXGroup;
4050
children = (
@@ -62,6 +72,7 @@
6272
21F09A9E2C60CAF00025AF73 /* AblyChatExample */ = {
6373
isa = PBXGroup;
6474
children = (
75+
212F95A52C6CAD7E00420287 /* Mocks */,
6576
21F09A9F2C60CAF00025AF73 /* AblyChatExampleApp.swift */,
6677
21F09AA12C60CAF00025AF73 /* ContentView.swift */,
6778
21F09AA32C60CAF20025AF73 /* Assets.xcassets */,
@@ -152,6 +163,7 @@
152163
isa = PBXSourcesBuildPhase;
153164
buildActionMask = 2147483647;
154165
files = (
166+
212F95A72C6CAD9300420287 /* MockRealtime.swift in Sources */,
155167
21F09AA22C60CAF00025AF73 /* ContentView.swift in Sources */,
156168
21F09AA02C60CAF00025AF73 /* AblyChatExampleApp.swift in Sources */,
157169
);

Example/AblyChatExample/ContentView.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import SwiftUI
33

44
struct ContentView: View {
55
/// Just used to check that we can successfully import and use the AblyChat library. TODO remove this once we start building the library
6-
@State private var ablyChatClient = AblyChatClient()
6+
@State private var ablyChatClient = DefaultChatClient(
7+
realtime: MockRealtime(key: ""),
8+
clientOptions: ClientOptions()
9+
)
710

811
var body: some View {
912
VStack {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Ably
2+
3+
/// A mock implementation of `ARTRealtimeProtocol`. It only exists so that we can construct an instance of `DefaultChatClient` without needing to create a proper `ARTRealtime` instance (which we can’t yet do because we don’t have a method for inserting an API key into the example app). TODO remove this once we start building the example app
4+
class MockRealtime: NSObject, ARTRealtimeProtocol {
5+
var device: ARTLocalDevice {
6+
fatalError("Not implemented")
7+
}
8+
9+
var clientId: String?
10+
11+
required init(options _: ARTClientOptions) {}
12+
13+
required init(key _: String) {}
14+
15+
required init(token _: String) {}
16+
17+
func time(_: @escaping ARTDateTimeCallback) {
18+
fatalError("Not implemented")
19+
}
20+
21+
func ping(_: @escaping ARTCallback) {
22+
fatalError("Not implemented")
23+
}
24+
25+
func stats(_: @escaping ARTPaginatedStatsCallback) -> Bool {
26+
fatalError("Not implemented")
27+
}
28+
29+
func stats(_: ARTStatsQuery?, callback _: @escaping ARTPaginatedStatsCallback) throws {
30+
fatalError("Not implemented")
31+
}
32+
33+
func connect() {
34+
fatalError("Not implemented")
35+
}
36+
37+
func close() {
38+
fatalError("Not implemented")
39+
}
40+
}

Sources/AblyChat/.swiftformat

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# To avoid clash with SwiftLint’s explicit_acl rule
2+
--disable redundantInternal

Sources/AblyChat/.swiftlint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
opt_in_rules:
2+
# Opt-in rules of type "idiomatic" that we’ve decided we want:
3+
- explicit_acl

Sources/AblyChat/AblyChat.swift

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Describes what to do with realtime events that come in faster than the consumer of an `AsyncSequence` can handle them.
2+
// (This is the same as `AsyncStream<T>.Continuation.BufferingPolicy` but with the generic type parameter `T` removed.)
3+
public enum BufferingPolicy: Sendable {
4+
case unbounded
5+
case bufferingOldest(Int)
6+
case bufferingNewest(Int)
7+
}

Sources/AblyChat/ChatClient.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Ably
2+
3+
public protocol ChatClient: AnyObject, Sendable {
4+
var rooms: any Rooms { get }
5+
var connection: any Connection { get }
6+
var clientID: String { get }
7+
var realtime: any ARTRealtimeProtocol { get }
8+
var clientOptions: ClientOptions { get }
9+
}
10+
11+
public final class DefaultChatClient: ChatClient {
12+
public init(realtime _: ARTRealtimeProtocol, clientOptions _: ClientOptions?) {
13+
// This one doesn’t do `fatalError`, so that I can call it in the example app
14+
}
15+
16+
public var rooms: any Rooms {
17+
fatalError("Not yet implemented")
18+
}
19+
20+
public var connection: any Connection {
21+
fatalError("Not yet implemented")
22+
}
23+
24+
public var clientID: String {
25+
fatalError("Not yet implemented")
26+
}
27+
28+
public var realtime: any ARTRealtimeProtocol {
29+
fatalError("Not yet implemented")
30+
}
31+
32+
public var clientOptions: ClientOptions {
33+
fatalError("Not yet implemented")
34+
}
35+
}
36+
37+
public struct ClientOptions: Sendable {
38+
public var logHandler: LogHandler?
39+
public var logLevel: LogLevel?
40+
41+
public init(logHandler: (any LogHandler)? = nil, logLevel: LogLevel? = nil) {
42+
self.logHandler = logHandler
43+
self.logLevel = logLevel
44+
}
45+
}

Sources/AblyChat/Connection.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Ably
2+
3+
public protocol Connection: AnyObject, Sendable {
4+
var status: any ConnectionStatus { get }
5+
}
6+
7+
public protocol ConnectionStatus: AnyObject, Sendable {
8+
var current: ConnectionLifecycle { get }
9+
// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/12): consider how to avoid the need for an unwrap
10+
var error: ARTErrorInfo? { get }
11+
func onChange(bufferingPolicy: BufferingPolicy) -> Subscription<ConnectionStatusChange>
12+
}
13+
14+
public enum ConnectionLifecycle: Sendable {
15+
case initialized
16+
case connecting
17+
case connected
18+
case disconnected
19+
case suspended
20+
case failed
21+
}
22+
23+
public struct ConnectionStatusChange: Sendable {
24+
public var current: ConnectionLifecycle
25+
public var previous: ConnectionLifecycle
26+
// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/12): consider how to avoid the need for an unwrap
27+
public var error: ARTErrorInfo?
28+
public var retryIn: TimeInterval
29+
30+
public init(current: ConnectionLifecycle, previous: ConnectionLifecycle, error: ARTErrorInfo? = nil, retryIn: TimeInterval) {
31+
self.current = current
32+
self.previous = previous
33+
self.error = error
34+
self.retryIn = retryIn
35+
}
36+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Ably
2+
3+
public protocol EmitsDiscontinuities {
4+
func subscribeToDiscontinuities() -> Subscription<ARTErrorInfo>
5+
}

Sources/AblyChat/Headers.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Foundation
2+
3+
public enum HeadersValue: Sendable {
4+
case string(String)
5+
case number(NSNumber)
6+
case bool(Bool)
7+
case null
8+
}
9+
10+
// The corresponding type in TypeScript is
11+
// Record<string, number | string | boolean | null | undefined>
12+
// There may be a better way to represent it in Swift; this will do for now. Have omitted `undefined` because I don’t know how that would occur.
13+
public typealias Headers = [String: HeadersValue]

Sources/AblyChat/Logging.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
public typealias LogContext = [String: any Sendable]
2+
3+
public protocol LogHandler: AnyObject, Sendable {
4+
func log(message: String, level: LogLevel, context: LogContext?)
5+
}
6+
7+
public enum LogLevel: Sendable {
8+
case trace
9+
case debug
10+
case info
11+
case warn
12+
case error
13+
case silent
14+
}

Sources/AblyChat/Message.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Foundation
2+
3+
public typealias MessageHeaders = Headers
4+
public typealias MessageMetadata = Metadata
5+
6+
public struct Message: Sendable {
7+
public var timeserial: String
8+
public var clientID: String
9+
public var roomID: String
10+
public var text: String
11+
public var createdAt: Date
12+
public var metadata: MessageMetadata
13+
public var headers: MessageHeaders
14+
15+
public init(timeserial: String, clientID: String, roomID: String, text: String, createdAt: Date, metadata: MessageMetadata, headers: MessageHeaders) {
16+
self.timeserial = timeserial
17+
self.clientID = clientID
18+
self.roomID = roomID
19+
self.text = text
20+
self.createdAt = createdAt
21+
self.metadata = metadata
22+
self.headers = headers
23+
}
24+
25+
public func isBefore(_: Message) -> Bool {
26+
fatalError("Not yet implemented")
27+
}
28+
29+
public func isAfter(_: Message) -> Bool {
30+
fatalError("Not yet implemented")
31+
}
32+
33+
public func isEqual(_: Message) -> Bool {
34+
fatalError("Not yet implemented")
35+
}
36+
}

Sources/AblyChat/Messages.swift

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import Ably
2+
3+
public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities {
4+
func subscribe(bufferingPolicy: BufferingPolicy) -> MessageSubscription
5+
func get(options: QueryOptions) async throws -> any PaginatedResult<Message>
6+
func send(params: SendMessageParams) async throws -> Message
7+
var channel: ARTRealtimeChannelProtocol { get }
8+
}
9+
10+
public struct SendMessageParams: Sendable {
11+
public var text: String
12+
public var metadata: MessageMetadata?
13+
public var headers: MessageHeaders?
14+
15+
public init(text: String, metadata: MessageMetadata? = nil, headers: MessageHeaders? = nil) {
16+
self.text = text
17+
self.metadata = metadata
18+
self.headers = headers
19+
}
20+
}
21+
22+
public struct QueryOptions: Sendable {
23+
public enum Direction: Sendable {
24+
case forwards
25+
case backwards
26+
}
27+
28+
public var start: Date?
29+
public var end: Date?
30+
public var limit: Int?
31+
public var direction: Direction?
32+
33+
public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil, direction: QueryOptions.Direction? = nil) {
34+
self.start = start
35+
self.end = end
36+
self.limit = limit
37+
self.direction = direction
38+
}
39+
}
40+
41+
public struct QueryOptionsWithoutDirection: Sendable {
42+
public var start: Date?
43+
public var end: Date?
44+
public var limit: Int?
45+
46+
public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil) {
47+
self.start = start
48+
self.end = end
49+
self.limit = limit
50+
}
51+
}
52+
53+
// Currently a copy-and-paste of `Subscription`; see notes on that one. For `MessageSubscription`, my intention is that the `BufferingPolicy` passed to `subscribe(bufferingPolicy:)` will also define what the `MessageSubscription` does with messages that are received _before_ the user starts iterating over the sequence (this buffering will allow us to implement the requirement that there be no discontinuity between the the last message returned by `getPreviousMessages` and the first element you get when you iterate).
54+
public struct MessageSubscription: Sendable, AsyncSequence {
55+
public typealias Element = Message
56+
57+
public init<T: AsyncSequence>(mockAsyncSequence _: T) where T.Element == Element {
58+
fatalError("Not yet implemented")
59+
}
60+
61+
public func getPreviousMessages(params _: QueryOptionsWithoutDirection) async throws -> any PaginatedResult<Message> {
62+
fatalError("Not yet implemented")
63+
}
64+
65+
public struct AsyncIterator: AsyncIteratorProtocol {
66+
public mutating func next() async -> Element? {
67+
fatalError("Not implemented")
68+
}
69+
}
70+
71+
public func makeAsyncIterator() -> AsyncIterator {
72+
fatalError("Not implemented")
73+
}
74+
}

Sources/AblyChat/Metadata.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/13): try to improve this type
2+
public typealias Metadata = [String: (any Sendable)?]

Sources/AblyChat/Occupancy.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Ably
2+
3+
public protocol Occupancy: AnyObject, Sendable, EmitsDiscontinuities {
4+
func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription<OccupancyEvent>
5+
func get() async throws -> OccupancyEvent
6+
var channel: ARTRealtimeChannelProtocol { get }
7+
}
8+
9+
public struct OccupancyEvent: Sendable {
10+
public var connections: Int
11+
public var presenceMembers: Int
12+
13+
public init(connections: Int, presenceMembers: Int) {
14+
self.connections = connections
15+
self.presenceMembers = presenceMembers
16+
}
17+
}

0 commit comments

Comments
 (0)