Skip to content

Commit

Permalink
Define the public API of the SDK
Browse files Browse the repository at this point in the history
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
  • Loading branch information
lawrence-forooghian committed Aug 19, 2024
1 parent cb48dda commit 9c0cad1
Show file tree
Hide file tree
Showing 29 changed files with 597 additions and 10 deletions.
1 change: 0 additions & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ opt_in_rules:
# Opt-in rules of type "lint" that we’ve decided we want:
- array_init
- empty_xctest_method
- missing_docs
- override_in_extension
- yoda_condition
- private_swiftui_state
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with `
## Development guidelines

- 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.
- 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:
- Describe the SDK’s functionality via protocols (when doing so would still be sufficiently idiomatic to Swift).
- 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.)

## Building for Swift 6

Expand Down
12 changes: 12 additions & 0 deletions Example/AblyChatExample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

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

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

/* Begin PBXGroup section */
212F95A52C6CAD7E00420287 /* Mocks */ = {
isa = PBXGroup;
children = (
212F95A62C6CAD9300420287 /* MockRealtime.swift */,
);
path = Mocks;
sourceTree = "<group>";
};
21971DFD2C60D89C0074B8AE /* Frameworks */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -62,6 +72,7 @@
21F09A9E2C60CAF00025AF73 /* AblyChatExample */ = {
isa = PBXGroup;
children = (
212F95A52C6CAD7E00420287 /* Mocks */,
21F09A9F2C60CAF00025AF73 /* AblyChatExampleApp.swift */,
21F09AA12C60CAF00025AF73 /* ContentView.swift */,
21F09AA32C60CAF20025AF73 /* Assets.xcassets */,
Expand Down Expand Up @@ -152,6 +163,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
212F95A72C6CAD9300420287 /* MockRealtime.swift in Sources */,
21F09AA22C60CAF00025AF73 /* ContentView.swift in Sources */,
21F09AA02C60CAF00025AF73 /* AblyChatExampleApp.swift in Sources */,
);
Expand Down
5 changes: 4 additions & 1 deletion Example/AblyChatExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import SwiftUI

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

var body: some View {
VStack {
Expand Down
40 changes: 40 additions & 0 deletions Example/AblyChatExample/Mocks/MockRealtime.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Ably

/// 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
class MockRealtime: NSObject, ARTRealtimeProtocol {
var device: ARTLocalDevice {
fatalError("Not implemented")
}

var clientId: String?

required init(options _: ARTClientOptions) {}

required init(key _: String) {}

required init(token _: String) {}

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

func ping(_: @escaping ARTCallback) {
fatalError("Not implemented")
}

func stats(_: @escaping ARTPaginatedStatsCallback) -> Bool {
fatalError("Not implemented")
}

func stats(_: ARTStatsQuery?, callback _: @escaping ARTPaginatedStatsCallback) throws {
fatalError("Not implemented")
}

func connect() {
fatalError("Not implemented")
}

func close() {
fatalError("Not implemented")
}
}
3 changes: 3 additions & 0 deletions Sources/AblyChat/.swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
opt_in_rules:
# Opt-in rules of type "idiomatic" that we’ve decided we want:
- explicit_acl
7 changes: 0 additions & 7 deletions Sources/AblyChat/AblyChat.swift

This file was deleted.

7 changes: 7 additions & 0 deletions Sources/AblyChat/BufferingPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Describes what to do with realtime events that come in faster than the consumer of an `AsyncSequence` can handle them.
// (This is the same as `AsyncStream<T>.Continuation.BufferingPolicy` but with the generic type parameter `T` removed.)
public enum BufferingPolicy {
case unbounded
case bufferingOldest(Int)
case bufferingNewest(Int)
}
45 changes: 45 additions & 0 deletions Sources/AblyChat/ChatClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Ably

public protocol ChatClient: AnyObject, Sendable {
var rooms: any Rooms { get }
var connection: any Connection { get }
var clientID: String { get }
var realtime: any ARTRealtimeProtocol { get }
var clientOptions: ClientOptions { get }
}

public final class DefaultChatClient: ChatClient {
public init(realtime _: ARTRealtimeProtocol, clientOptions _: ClientOptions?) {
// This one doesn’t do `fatalError`, so that I can call it in the example app
}

public var rooms: any Rooms {
fatalError("Not yet implemented")
}

public var connection: any Connection {
fatalError("Not yet implemented")
}

public var clientID: String {
fatalError("Not yet implemented")
}

public var realtime: any ARTRealtimeProtocol {
fatalError("Not yet implemented")
}

public var clientOptions: ClientOptions {
fatalError("Not yet implemented")
}
}

public struct ClientOptions: Sendable {
public var logHandler: LogHandler?
public var logLevel: LogLevel?

public init(logHandler: (any LogHandler)? = nil, logLevel: LogLevel? = nil) {
self.logHandler = logHandler
self.logLevel = logLevel
}
}
36 changes: 36 additions & 0 deletions Sources/AblyChat/Connection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Ably

public protocol Connection: AnyObject, Sendable {
var status: any ConnectionStatus { get }
}

public protocol ConnectionStatus: AnyObject, Sendable {
var current: ConnectionLifecycle { get }
// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/12): consider how to avoid the need for an unwrap
var error: ARTErrorInfo? { get }
func onChange(bufferingPolicy: BufferingPolicy) -> Subscription<ConnectionStatusChange>
}

public enum ConnectionLifecycle: Sendable {
case initialized
case connecting
case connected
case disconnected
case suspended
case failed
}

public struct ConnectionStatusChange: Sendable {
public var current: ConnectionLifecycle
public var previous: ConnectionLifecycle
// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/12): consider how to avoid the need for an unwrap
public var error: ARTErrorInfo?
public var retryIn: TimeInterval

public init(current: ConnectionLifecycle, previous: ConnectionLifecycle, error: ARTErrorInfo? = nil, retryIn: TimeInterval) {
self.current = current
self.previous = previous
self.error = error
self.retryIn = retryIn
}
}
5 changes: 5 additions & 0 deletions Sources/AblyChat/EmitsDiscontinuities.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Ably

public protocol EmitsDiscontinuities {
func subscribeToDiscontinuities() -> Subscription<ARTErrorInfo>
}
13 changes: 13 additions & 0 deletions Sources/AblyChat/Headers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

public enum HeadersValue: Sendable {
case string(String)
case number(NSNumber)
case bool(Bool)
case null
}

// The corresponding type in TypeScript is
// Record<string, number | string | boolean | null | undefined>
// 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.
public typealias Headers = [String: HeadersValue]
14 changes: 14 additions & 0 deletions Sources/AblyChat/Logging.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
public typealias LogContext = [String: any Sendable]

public protocol LogHandler: AnyObject, Sendable {
func log(message: String, level: LogLevel, context: LogContext?)
}

public enum LogLevel: Sendable {
case trace
case debug
case info
case warn
case error
case silent
}
36 changes: 36 additions & 0 deletions Sources/AblyChat/Message.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

public typealias MessageHeaders = Headers
public typealias MessageMetadata = Metadata

public struct Message: Sendable {
public var timeserial: String
public var clientID: String
public var roomID: String
public var text: String
public var createdAt: Date
public var metadata: MessageMetadata
public var headers: MessageHeaders

public init(timeserial: String, clientID: String, roomID: String, text: String, createdAt: Date, metadata: MessageMetadata, headers: MessageHeaders) {
self.timeserial = timeserial
self.clientID = clientID
self.roomID = roomID
self.text = text
self.createdAt = createdAt
self.metadata = metadata
self.headers = headers
}

public func isBefore(_: Message) -> Bool {
fatalError("Not yet implemented")
}

public func isAfter(_: Message) -> Bool {
fatalError("Not yet implemented")
}

public func isEqual(_: Message) -> Bool {
fatalError("Not yet implemented")
}
}
74 changes: 74 additions & 0 deletions Sources/AblyChat/Messages.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Ably

public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities {
func subscribe(bufferingPolicy: BufferingPolicy) -> MessageSubscription
func get(options: QueryOptions) async throws -> any PaginatedResult<Message>
func send(params: SendMessageParams) async throws -> Message
var channel: ARTRealtimeChannelProtocol { get }
}

public struct SendMessageParams: Sendable {
public var text: String
public var metadata: MessageMetadata?
public var headers: MessageHeaders?

public init(text: String, metadata: MessageMetadata? = nil, headers: MessageHeaders? = nil) {
self.text = text
self.metadata = metadata
self.headers = headers
}
}

public struct QueryOptions: Sendable {
public enum Direction: Sendable {
case forwards
case backwards
}

public var start: Date?
public var end: Date?
public var limit: Int?
public var direction: Direction?

public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil, direction: QueryOptions.Direction? = nil) {
self.start = start
self.end = end
self.limit = limit
self.direction = direction
}
}

public struct QueryOptionsWithoutDirection: Sendable {
public var start: Date?
public var end: Date?
public var limit: Int?

public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil) {
self.start = start
self.end = end
self.limit = limit
}
}

// 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).
public struct MessageSubscription: Sendable, AsyncSequence {
public typealias Element = Message

public init<T: AsyncSequence>(mockAsyncSequence _: T) where T.Element == Element {
fatalError("Not yet implemented")
}

public func getPreviousMessages(params _: QueryOptionsWithoutDirection) async throws -> any PaginatedResult<Message> {
fatalError("Not yet implemented")
}

public struct AsyncIterator: AsyncIteratorProtocol {
public mutating func next() async -> Element? {
fatalError("Not implemented")
}
}

public func makeAsyncIterator() -> AsyncIterator {
fatalError("Not implemented")
}
}
2 changes: 2 additions & 0 deletions Sources/AblyChat/Metadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/13): try to improve this type
public typealias Metadata = [String: (any Sendable)?]
17 changes: 17 additions & 0 deletions Sources/AblyChat/Occupancy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Ably

public protocol Occupancy: AnyObject, Sendable, EmitsDiscontinuities {
func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription<OccupancyEvent>
func get() async throws -> OccupancyEvent
var channel: ARTRealtimeChannelProtocol { get }
}

public struct OccupancyEvent {
public var connections: Int
public var presenceMembers: Int

public init(connections: Int, presenceMembers: Int) {
self.connections = connections
self.presenceMembers = presenceMembers
}
}
Loading

0 comments on commit 9c0cad1

Please sign in to comment.