diff --git a/.github/workflows/unified-test-suite.yml b/.github/workflows/unified-test-suite.yml new file mode 100644 index 00000000..aa2e0ae1 --- /dev/null +++ b/.github/workflows/unified-test-suite.yml @@ -0,0 +1,39 @@ +name: Unified Test Suite + +on: + pull_request: + push: + branches: + - main + +jobs: + unified-test-suite: + runs-on: macos-15 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: npm ci + + - name: Install uts-chat globally + run: npm install -g @ably-labs/uts-chat + + # This step can be removed once the runners' default version of Xcode is 16 or above + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16 + + - name: Prepare Adapter + working-directory: UTSChatAdapter + run: swift build --target Adapter + + - name: Run uts-chat with ADAPTER_EXECUTABLE + env: + ADAPTER_EXECUTABLE: cd UTSChatAdapter && swift run UTSChatAdapter + run: uts-chat diff --git a/.swiftlint.yml b/.swiftlint.yml index e50be7c2..4aa54a7e 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,5 +1,6 @@ excluded: - .build + - UTSChatAdapter/Sources/Adapter/NanoID.swift strict: true diff --git a/AblyChat.xcworkspace/contents.xcworkspacedata b/AblyChat.xcworkspace/contents.xcworkspacedata index 56985fe1..d62df892 100644 --- a/AblyChat.xcworkspace/contents.xcworkspacedata +++ b/AblyChat.xcworkspace/contents.xcworkspacedata @@ -7,4 +7,7 @@ + + diff --git a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9bf42bea..2f7b1cc6 100644 --- a/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyChat.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a296396707b7685153f4cf548f6281f483d562002fe11235f1fc3bb053be91d7", + "originHash" : "1ad2d7338668d15feccbf564582941161acd47349bfca8f34374e11c69677ae8", "pins" : [ { "identity" : "ably-cocoa", diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift index 3827deb6..5a5fb0f0 100644 --- a/Sources/AblyChat/ChatClient.swift +++ b/Sources/AblyChat/ChatClient.swift @@ -29,7 +29,7 @@ public actor DefaultChatClient: ChatClient { } public nonisolated var clientID: String { - fatalError("Not yet implemented") + realtime.clientId ?? "" } } diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift index 23074557..f53958a2 100644 --- a/Sources/AblyChat/DefaultMessages.swift +++ b/Sources/AblyChat/DefaultMessages.swift @@ -188,6 +188,10 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { } } + if channel.state == .initialized { + channel.attach() + } + // (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 timeserialOnChannelAttach() } diff --git a/Sources/AblyChat/Errors.swift b/Sources/AblyChat/Errors.swift index d4d153c8..d49b068d 100644 --- a/Sources/AblyChat/Errors.swift +++ b/Sources/AblyChat/Errors.swift @@ -14,7 +14,7 @@ public enum ErrorCode: Int { /// ``Rooms.get(roomID:options:)`` was called with a different set of room options than was used on a previous call. You must first release the existing room instance using ``Rooms.release(roomID:)``. /// /// TODO this code is a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 - case inconsistentRoomOptions = 1 + case inconsistentRoomOptions = 40000 case messagesAttachmentFailed = 102_001 case presenceAttachmentFailed = 102_002 diff --git a/UTSChatAdapter/.gitignore b/UTSChatAdapter/.gitignore new file mode 100644 index 00000000..67d3f4a7 --- /dev/null +++ b/UTSChatAdapter/.gitignore @@ -0,0 +1,13 @@ +# Start of .gitignore created by Swift Package Manager +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +# End of .gitignore created by Swift Package Manager + +/node_modules +/.mint diff --git a/UTSChatAdapter/Package.resolved b/UTSChatAdapter/Package.resolved new file mode 100644 index 00000000..76e8850d --- /dev/null +++ b/UTSChatAdapter/Package.resolved @@ -0,0 +1,51 @@ +{ + "originHash" : "1e440d9500a61178defdb136b0459b00cd00a3c57aa142d698fd712060f7b673", + "pins" : [ + { + "identity" : "ably-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa", + "state" : { + "branch" : "main", + "revision" : "f7bff4b1c941b4c7b952b9224a33674e2302e19f" + } + }, + { + "identity" : "delta-codec-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/delta-codec-cocoa", + "state" : { + "revision" : "3ee62ea40a63996b55818d44b3f0e56d8753be88", + "version" : "1.3.3" + } + }, + { + "identity" : "msgpack-objective-c", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rvi/msgpack-objective-C", + "state" : { + "revision" : "3e36b48e04ecd756cb927bd5f5b9bf6d45e475f9", + "version" : "0.4.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "5c8bd186f48c16af0775972700626f0b74588278", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" + } + } + ], + "version" : 3 +} diff --git a/UTSChatAdapter/Package.swift b/UTSChatAdapter/Package.swift new file mode 100644 index 00000000..12139a43 --- /dev/null +++ b/UTSChatAdapter/Package.swift @@ -0,0 +1,45 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "UTSChatAdapter", + platforms: [ + .macOS(.v13), + .iOS(.v14), + .tvOS(.v14), + ], + products: [ + .executable( + name: "UTSChatAdapter", + targets: [ + "Adapter", + ] + ), + .executable( + name: "UTSChatAdapterGenerator", + targets: [ + "Generator", + ] + ), + ], + dependencies: [ + .package( + path: "../" // AblyChat + ), + ], + targets: [ + .executableTarget( + name: "Adapter", + dependencies: [ + .product( + name: "AblyChat", + package: "ably-chat-swift" + ), + ] + ), + .executableTarget( + name: "Generator" + ), + ] +) diff --git a/UTSChatAdapter/Sources/Adapter/ChatAdapter.swift b/UTSChatAdapter/Sources/Adapter/ChatAdapter.swift new file mode 100644 index 00000000..257f33de --- /dev/null +++ b/UTSChatAdapter/Sources/Adapter/ChatAdapter.swift @@ -0,0 +1,831 @@ +import Ably +import AblyChat + +/** + * Unified Test Suite adapter for swift Chat SDK + */ +@MainActor +struct ChatAdapter { + // Runtime SDK objects storage + private var idToChannel = [String: ARTRealtimeChannel]() + private var idToChannels = [String: ARTRealtimeChannels]() + private var idToChatClient = [String: ChatClient]() + private var idToConnection = [String: Connection]() + private var idToConnectionStatus = [String: ConnectionStatus]() + private var idToMessage = [String: Message]() + private var idToMessages = [String: Messages]() + private var idToOccupancy = [String: Occupancy]() + private var idToPresence = [String: Presence]() + private var idToRealtime = [String: RealtimeClient]() + private var idToRealtimeChannel = [String: RealtimeChannelProtocol]() + private var idToRoom = [String: Room]() + private var idToRoomReactions = [String: RoomReactions]() + private var idToRooms = [String: Rooms]() + private var idToRoomStatus = [String: RoomStatus]() + private var idToTyping = [String: Typing]() + private var idToPaginatedResultMessage = [String: any PaginatedResultMessage]() + private var idToMessageSubscription = [String: MessageSubscription]() + private var idToOnConnectionStatusChange = [String: OnConnectionStatusChange]() + private var idToOnDiscontinuitySubscription = [String: OnDiscontinuitySubscription]() + private var idToOccupancySubscription = [String: OccupancySubscription]() + private var idToRoomReactionsSubscription = [String: RoomReactionsSubscription]() + private var idToOnRoomStatusChange = [String: OnRoomStatusChange]() + private var idToTypingSubscription = [String: TypingSubscription]() + private var idToPresenceSubscription = [String: PresenceSubscription]() + + private var webSocket: WebSocketWrapper + + init(webSocket: WebSocketWrapper) { + self.webSocket = webSocket + } + + mutating func handleRpcCall(rpcParams: JSON) async throws -> String { + do { + switch try rpcParams.method() { + // Disabling this for generated content since it simplifies generator code: + // swiftlint:disable anonymous_argument_in_multiline_closure + + // GENERATED CONTENT BEGIN + + case "ChatClient#rooms": + let refId = try rpcParams.refId() + guard let chatClientRef = idToChatClient[refId] else { + throw AdapterError.objectNotFound(type: "ChatClient", refId: refId) + } + let rooms = chatClientRef.rooms // Rooms + let fieldRefId = generateId() + idToRooms[fieldRefId] = rooms + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "ChatClient#realtime": + let refId = try rpcParams.refId() + guard let chatClientRef = idToChatClient[refId] else { + throw AdapterError.objectNotFound(type: "ChatClient", refId: refId) + } + let realtime = chatClientRef.realtime // Realtime + let fieldRefId = generateId() + idToRealtime[fieldRefId] = realtime + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "~ChatClient#connection": + let refId = try rpcParams.refId() + guard let chatClientRef = idToChatClient[refId] else { + throw AdapterError.objectNotFound(type: "ChatClient", refId: refId) + } + let connection = chatClientRef.connection // Connection + let fieldRefId = generateId() + idToConnection[fieldRefId] = connection + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "ChatClient#clientOptions": + let refId = try rpcParams.refId() + guard let chatClientRef = idToChatClient[refId] else { + throw AdapterError.objectNotFound(type: "ChatClient", refId: refId) + } + let clientOptions = chatClientRef.clientOptions // ClientOptions + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \(jsonString(clientOptions))}") + + case "ChatClient#clientId": + let refId = try rpcParams.refId() + guard let chatClientRef = idToChatClient[refId] else { + throw AdapterError.objectNotFound(type: "ChatClient", refId: refId) + } + let clientID = chatClientRef.clientID // string + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(clientID)\"}") + + case "~Connection#status": + let refId = try rpcParams.refId() + guard let connectionRef = idToConnection[refId] else { + throw AdapterError.objectNotFound(type: "Connection", refId: refId) + } + let status = connectionRef.status // ConnectionStatus + let fieldRefId = generateId() + idToConnectionStatus[fieldRefId] = status + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "Message.equal": + let message = try Message.from(rpcParams.methodArg("message")) + let refId = try rpcParams.refId() + guard let messageRef = idToMessage[refId] else { + throw AdapterError.objectNotFound(type: "Message", refId: refId) + } + let bool = try messageRef.equal(message: message) // Bool + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(bool)\"}") + + case "Message.before": + let message = try Message.from(rpcParams.methodArg("message")) + let refId = try rpcParams.refId() + guard let messageRef = idToMessage[refId] else { + throw AdapterError.objectNotFound(type: "Message", refId: refId) + } + let bool = try messageRef.before(message: message) // Bool + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(bool)\"}") + + case "Message.after": + let message = try Message.from(rpcParams.methodArg("message")) + let refId = try rpcParams.refId() + guard let messageRef = idToMessage[refId] else { + throw AdapterError.objectNotFound(type: "Message", refId: refId) + } + let bool = try messageRef.after(message: message) // Bool + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(bool)\"}") + + case "Message#timeserial": + let refId = try rpcParams.refId() + guard let messageRef = idToMessage[refId] else { + throw AdapterError.objectNotFound(type: "Message", refId: refId) + } + let timeserial = messageRef.timeserial // string + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(timeserial)\"}") + + case "Message#text": + let refId = try rpcParams.refId() + guard let messageRef = idToMessage[refId] else { + throw AdapterError.objectNotFound(type: "Message", refId: refId) + } + let text = messageRef.text // string + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(text)\"}") + + case "Message#roomId": + let refId = try rpcParams.refId() + guard let messageRef = idToMessage[refId] else { + throw AdapterError.objectNotFound(type: "Message", refId: refId) + } + let roomID = messageRef.roomID // string + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(roomID)\"}") + + case "Message#metadata": + let refId = try rpcParams.refId() + guard let messageRef = idToMessage[refId] else { + throw AdapterError.objectNotFound(type: "Message", refId: refId) + } + let metadata = messageRef.metadata // object + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \(jsonString(metadata))}") + + case "Message#headers": + let refId = try rpcParams.refId() + guard let messageRef = idToMessage[refId] else { + throw AdapterError.objectNotFound(type: "Message", refId: refId) + } + let headers = messageRef.headers // object + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \(jsonString(headers))}") + + case "Message#clientId": + let refId = try rpcParams.refId() + guard let messageRef = idToMessage[refId] else { + throw AdapterError.objectNotFound(type: "Message", refId: refId) + } + let clientID = messageRef.clientID // string + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(clientID)\"}") + + case "Messages.send": + let params = try SendMessageParams.from(rpcParams.methodArg("params")) + let refId = try rpcParams.refId() + guard let messagesRef = idToMessages[refId] else { + throw AdapterError.objectNotFound(type: "Messages", refId: refId) + } + let message = try await messagesRef.send(params: params) // Message + let resultRefId = generateId() + idToMessage[resultRefId] = message + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "Messages.get": + let options = try QueryOptions.from(rpcParams.methodArg("options")) + let refId = try rpcParams.refId() + guard let messagesRef = idToMessages[refId] else { + throw AdapterError.objectNotFound(type: "Messages", refId: refId) + } + let paginatedResultMessage = try await messagesRef.get(options: options) // PaginatedResultMessage + let resultRefId = generateId() + idToPaginatedResultMessage[resultRefId] = paginatedResultMessage + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "Messages#channel": + let refId = try rpcParams.refId() + guard let messagesRef = idToMessages[refId] else { + throw AdapterError.objectNotFound(type: "Messages", refId: refId) + } + let channel = messagesRef.channel // RealtimeChannel + let fieldRefId = generateId() + idToRealtimeChannel[fieldRefId] = channel + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "~Messages.subscribe": + let refId = try rpcParams.refId() + guard let messagesRef = idToMessages[refId] else { + throw AdapterError.objectNotFound(type: "Messages", refId: refId) + } + let subscription = try await messagesRef.subscribe(bufferingPolicy: .unbounded) + let webSocket = webSocket + let callback: (Message) async throws -> Void = { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString($0))")) + } + Task { + for await event in subscription { + try await callback(event) + } + } + let resultRefId = generateId() + idToMessageSubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "~Messages.onDiscontinuity": + let refId = try rpcParams.refId() + guard let messagesRef = idToMessages[refId] else { + throw AdapterError.objectNotFound(type: "Messages", refId: refId) + } + let subscription = await messagesRef.subscribeToDiscontinuities() + let webSocket = webSocket + let callback: (AblyErrorInfo?) async throws -> Void = { + if let param = $0 { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString(param))")) + } else { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "{}")) + } + } + Task { + for await reason in subscription { + try await callback(reason) + } + } + let resultRefId = generateId() + idToOnDiscontinuitySubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "MessageSubscriptionResponse.getPreviousMessages": + let params = try QueryOptions.from(rpcParams.methodArg("params")) + let refId = try rpcParams.refId() + guard let messageSubscriptionRef = idToMessageSubscription[refId] else { + throw AdapterError.objectNotFound(type: "MessageSubscriptionResponse", refId: refId) + } + let paginatedResultMessage = try await messageSubscriptionRef.getPreviousMessages(params: params) // PaginatedResultMessage + let resultRefId = generateId() + idToPaginatedResultMessage[resultRefId] = paginatedResultMessage + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "~Occupancy.get": + let refId = try rpcParams.refId() + guard let occupancyRef = idToOccupancy[refId] else { + throw AdapterError.objectNotFound(type: "Occupancy", refId: refId) + } + let occupancyEvent = try await occupancyRef.get() // OccupancyEvent + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \(jsonString(occupancyEvent))}") + + case "~Occupancy#channel": + let refId = try rpcParams.refId() + guard let occupancyRef = idToOccupancy[refId] else { + throw AdapterError.objectNotFound(type: "Occupancy", refId: refId) + } + let channel = occupancyRef.channel // RealtimeChannel + let fieldRefId = generateId() + idToRealtimeChannel[fieldRefId] = channel + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "~Occupancy.subscribe": + let refId = try rpcParams.refId() + guard let occupancyRef = idToOccupancy[refId] else { + throw AdapterError.objectNotFound(type: "Occupancy", refId: refId) + } + let subscription = await occupancyRef.subscribe(bufferingPolicy: .unbounded) + let webSocket = webSocket + let callback: (OccupancyEvent) async throws -> Void = { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString($0))")) + } + Task { + for await event in subscription { + try await callback(event) + } + } + let resultRefId = generateId() + idToOccupancySubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "~Occupancy.onDiscontinuity": + let refId = try rpcParams.refId() + guard let occupancyRef = idToOccupancy[refId] else { + throw AdapterError.objectNotFound(type: "Occupancy", refId: refId) + } + let subscription = await occupancyRef.subscribeToDiscontinuities() + let webSocket = webSocket + let callback: (AblyErrorInfo?) async throws -> Void = { + if let param = $0 { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString(param))")) + } else { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "{}")) + } + } + Task { + for await reason in subscription { + try await callback(reason) + } + } + let resultRefId = generateId() + idToOnDiscontinuitySubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "PaginatedResult.isLast": + let refId = try rpcParams.refId() + guard let paginatedResultMessageRef = idToPaginatedResultMessage[refId] else { + throw AdapterError.objectNotFound(type: "PaginatedResult", refId: refId) + } + let bool = paginatedResultMessageRef.isLast() // Bool + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(bool)\"}") + + case "PaginatedResult.hasNext": + let refId = try rpcParams.refId() + guard let paginatedResultMessageRef = idToPaginatedResultMessage[refId] else { + throw AdapterError.objectNotFound(type: "PaginatedResult", refId: refId) + } + let bool = paginatedResultMessageRef.hasNext() // Bool + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(bool)\"}") + + case "PaginatedResult.next": + let refId = try rpcParams.refId() + guard let paginatedResultMessageRef = idToPaginatedResultMessage[refId] else { + throw AdapterError.objectNotFound(type: "PaginatedResult", refId: refId) + } + let paginatedResultMessage = try await paginatedResultMessageRef.next() // PaginatedResultMessage + let resultRefId = generateId() + idToPaginatedResultMessage[resultRefId] = paginatedResultMessage + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "PaginatedResult.first": + let refId = try rpcParams.refId() + guard let paginatedResultMessageRef = idToPaginatedResultMessage[refId] else { + throw AdapterError.objectNotFound(type: "PaginatedResult", refId: refId) + } + let paginatedResultMessage = try await paginatedResultMessageRef.first() // PaginatedResultMessage + let resultRefId = generateId() + idToPaginatedResultMessage[resultRefId] = paginatedResultMessage + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "PaginatedResult.current": + let refId = try rpcParams.refId() + guard let paginatedResultMessageRef = idToPaginatedResultMessage[refId] else { + throw AdapterError.objectNotFound(type: "PaginatedResult", refId: refId) + } + let paginatedResultMessage = try await paginatedResultMessageRef.current() // PaginatedResultMessage + let resultRefId = generateId() + idToPaginatedResultMessage[resultRefId] = paginatedResultMessage + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "PaginatedResult#items": + let refId = try rpcParams.refId() + guard let paginatedResultMessageRef = idToPaginatedResultMessage[refId] else { + throw AdapterError.objectNotFound(type: "PaginatedResult", refId: refId) + } + let items = paginatedResultMessageRef.items // object + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \(jsonString(items))}") + + case "~Presence.update": + let data = try PresenceDataWrapper.from(rpcParams.methodArg("data")) + let refId = try rpcParams.refId() + guard let presenceRef = idToPresence[refId] else { + throw AdapterError.objectNotFound(type: "Presence", refId: refId) + } + try await presenceRef.update(data: data) // Void + return try jsonRpcResult(rpcParams.requestId(), "{}") + + case "~Presence.leave": + let data = try PresenceDataWrapper.from(rpcParams.methodArg("data")) + let refId = try rpcParams.refId() + guard let presenceRef = idToPresence[refId] else { + throw AdapterError.objectNotFound(type: "Presence", refId: refId) + } + try await presenceRef.leave(data: data) // Void + return try jsonRpcResult(rpcParams.requestId(), "{}") + + case "~Presence.isUserPresent": + let clientID = try String.from(rpcParams.methodArg("clientId")) + let refId = try rpcParams.refId() + guard let presenceRef = idToPresence[refId] else { + throw AdapterError.objectNotFound(type: "Presence", refId: refId) + } + let bool = try await presenceRef.isUserPresent(clientID: clientID) // Bool + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(bool)\"}") + + case "Presence.get": + let params = try RealtimePresenceParams.from(rpcParams.methodArg("params")) + let refId = try rpcParams.refId() + guard let presenceRef = idToPresence[refId] else { + throw AdapterError.objectNotFound(type: "Presence", refId: refId) + } + let presenceMember = try await presenceRef.get(params: params) // PresenceMember + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \(jsonString(presenceMember))}") + + case "~Presence.enter": + let data = try PresenceDataWrapper.from(rpcParams.methodArg("data")) + let refId = try rpcParams.refId() + guard let presenceRef = idToPresence[refId] else { + throw AdapterError.objectNotFound(type: "Presence", refId: refId) + } + try await presenceRef.enter(data: data) // Void + return try jsonRpcResult(rpcParams.requestId(), "{}") + + case "~Presence.subscribe_listener": + let refId = try rpcParams.refId() + guard let presenceRef = idToPresence[refId] else { + throw AdapterError.objectNotFound(type: "Presence", refId: refId) + } + let subscription = await presenceRef.subscribeAll() + let webSocket = webSocket + let callback: (PresenceEvent) async throws -> Void = { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString($0))")) + } + Task { + for await event in subscription { + try await callback(event) + } + } + let resultRefId = generateId() + idToPresenceSubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "~Presence.onDiscontinuity": + let refId = try rpcParams.refId() + guard let presenceRef = idToPresence[refId] else { + throw AdapterError.objectNotFound(type: "Presence", refId: refId) + } + let subscription = await presenceRef.subscribeToDiscontinuities() + let webSocket = webSocket + let callback: (AblyErrorInfo?) async throws -> Void = { + if let param = $0 { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString(param))")) + } else { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "{}")) + } + } + Task { + for await reason in subscription { + try await callback(reason) + } + } + let resultRefId = generateId() + idToOnDiscontinuitySubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "~RoomReactions.send": + let params = try SendReactionParams.from(rpcParams.methodArg("params")) + let refId = try rpcParams.refId() + guard let roomReactionsRef = idToRoomReactions[refId] else { + throw AdapterError.objectNotFound(type: "RoomReactions", refId: refId) + } + try await roomReactionsRef.send(params: params) // Void + return try jsonRpcResult(rpcParams.requestId(), "{}") + + case "~RoomReactions#channel": + let refId = try rpcParams.refId() + guard let roomReactionsRef = idToRoomReactions[refId] else { + throw AdapterError.objectNotFound(type: "RoomReactions", refId: refId) + } + let channel = roomReactionsRef.channel // RealtimeChannel + let fieldRefId = generateId() + idToRealtimeChannel[fieldRefId] = channel + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "~RoomReactions.subscribe": + let refId = try rpcParams.refId() + guard let roomReactionsRef = idToRoomReactions[refId] else { + throw AdapterError.objectNotFound(type: "RoomReactions", refId: refId) + } + let subscription = await roomReactionsRef.subscribe(bufferingPolicy: .unbounded) + let webSocket = webSocket + let callback: (Reaction) async throws -> Void = { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString($0))")) + } + Task { + for await reaction in subscription { + try await callback(reaction) + } + } + let resultRefId = generateId() + idToRoomReactionsSubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "~RoomReactions.onDiscontinuity": + let refId = try rpcParams.refId() + guard let roomReactionsRef = idToRoomReactions[refId] else { + throw AdapterError.objectNotFound(type: "RoomReactions", refId: refId) + } + let subscription = await roomReactionsRef.subscribeToDiscontinuities() + let webSocket = webSocket + let callback: (AblyErrorInfo?) async throws -> Void = { + if let param = $0 { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString(param))")) + } else { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "{}")) + } + } + Task { + for await reason in subscription { + try await callback(reason) + } + } + let resultRefId = generateId() + idToOnDiscontinuitySubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "Room.options": + let refId = try rpcParams.refId() + guard let roomRef = idToRoom[refId] else { + throw AdapterError.objectNotFound(type: "Room", refId: refId) + } + let roomOptions = roomRef.options() // RoomOptions + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \(jsonString(roomOptions))}") + + case "Room.detach": + let refId = try rpcParams.refId() + guard let roomRef = idToRoom[refId] else { + throw AdapterError.objectNotFound(type: "Room", refId: refId) + } + try await roomRef.detach() // Void + return try jsonRpcResult(rpcParams.requestId(), "{}") + + case "Room.attach": + let refId = try rpcParams.refId() + guard let roomRef = idToRoom[refId] else { + throw AdapterError.objectNotFound(type: "Room", refId: refId) + } + try await roomRef.attach() // Void + return try jsonRpcResult(rpcParams.requestId(), "{}") + + case "~Room#typing": + let refId = try rpcParams.refId() + guard let roomRef = idToRoom[refId] else { + throw AdapterError.objectNotFound(type: "Room", refId: refId) + } + let typing = roomRef.typing // Typing + let fieldRefId = generateId() + idToTyping[fieldRefId] = typing + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "Room#status": + let refId = try rpcParams.refId() + guard let roomRef = idToRoom[refId] else { + throw AdapterError.objectNotFound(type: "Room", refId: refId) + } + let status = await roomRef.status // RoomStatus + let fieldRefId = generateId() + idToRoomStatus[fieldRefId] = status + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "Room#roomId": + let refId = try rpcParams.refId() + guard let roomRef = idToRoom[refId] else { + throw AdapterError.objectNotFound(type: "Room", refId: refId) + } + let roomID = roomRef.roomID // string + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(roomID)\"}") + + case "~Room#reactions": + let refId = try rpcParams.refId() + guard let roomRef = idToRoom[refId] else { + throw AdapterError.objectNotFound(type: "Room", refId: refId) + } + let reactions = roomRef.reactions // RoomReactions + let fieldRefId = generateId() + idToRoomReactions[fieldRefId] = reactions + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "~Room#presence": + let refId = try rpcParams.refId() + guard let roomRef = idToRoom[refId] else { + throw AdapterError.objectNotFound(type: "Room", refId: refId) + } + let presence = roomRef.presence // Presence + let fieldRefId = generateId() + idToPresence[fieldRefId] = presence + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "~Room#occupancy": + let refId = try rpcParams.refId() + guard let roomRef = idToRoom[refId] else { + throw AdapterError.objectNotFound(type: "Room", refId: refId) + } + let occupancy = roomRef.occupancy // Occupancy + let fieldRefId = generateId() + idToOccupancy[fieldRefId] = occupancy + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "Room#messages": + let refId = try rpcParams.refId() + guard let roomRef = idToRoom[refId] else { + throw AdapterError.objectNotFound(type: "Room", refId: refId) + } + let messages = roomRef.messages // Messages + let fieldRefId = generateId() + idToMessages[fieldRefId] = messages + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "~Rooms.release": + let roomID = try String.from(rpcParams.methodArg("roomId")) + let refId = try rpcParams.refId() + guard let roomsRef = idToRooms[refId] else { + throw AdapterError.objectNotFound(type: "Rooms", refId: refId) + } + try await roomsRef.release(roomID: roomID) // Void + return try jsonRpcResult(rpcParams.requestId(), "{}") + + case "Rooms#clientOptions": + let refId = try rpcParams.refId() + guard let roomsRef = idToRooms[refId] else { + throw AdapterError.objectNotFound(type: "Rooms", refId: refId) + } + let clientOptions = roomsRef.clientOptions // ClientOptions + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \(jsonString(clientOptions))}") + + case "~Typing.stop": + let refId = try rpcParams.refId() + guard let typingRef = idToTyping[refId] else { + throw AdapterError.objectNotFound(type: "Typing", refId: refId) + } + try await typingRef.stop() // Void + return try jsonRpcResult(rpcParams.requestId(), "{}") + + case "~Typing.start": + let refId = try rpcParams.refId() + guard let typingRef = idToTyping[refId] else { + throw AdapterError.objectNotFound(type: "Typing", refId: refId) + } + try await typingRef.start() // Void + return try jsonRpcResult(rpcParams.requestId(), "{}") + + case "~Typing.get": + let refId = try rpcParams.refId() + guard let typingRef = idToTyping[refId] else { + throw AdapterError.objectNotFound(type: "Typing", refId: refId) + } + let string = try await typingRef.get() // String + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(string)\"}") + + case "~Typing#channel": + let refId = try rpcParams.refId() + guard let typingRef = idToTyping[refId] else { + throw AdapterError.objectNotFound(type: "Typing", refId: refId) + } + let channel = typingRef.channel // RealtimeChannel + let fieldRefId = generateId() + idToRealtimeChannel[fieldRefId] = channel + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(fieldRefId)\"}") + + case "~Typing.subscribe": + let refId = try rpcParams.refId() + guard let typingRef = idToTyping[refId] else { + throw AdapterError.objectNotFound(type: "Typing", refId: refId) + } + let subscription = await typingRef.subscribe(bufferingPolicy: .unbounded) + let webSocket = webSocket + let callback: (TypingEvent) async throws -> Void = { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString($0))")) + } + Task { + for await event in subscription { + try await callback(event) + } + } + let resultRefId = generateId() + idToTypingSubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + case "~Typing.onDiscontinuity": + let refId = try rpcParams.refId() + guard let typingRef = idToTyping[refId] else { + throw AdapterError.objectNotFound(type: "Typing", refId: refId) + } + let subscription = await typingRef.subscribeToDiscontinuities() + let webSocket = webSocket + let callback: (AblyErrorInfo?) async throws -> Void = { + if let param = $0 { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString(param))")) + } else { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "{}")) + } + } + Task { + for await reason in subscription { + try await callback(reason) + } + } + let resultRefId = generateId() + idToOnDiscontinuitySubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + // GENERATED CONTENT END + // swiftlint:enable anonymous_argument_in_multiline_closure + + // Custom fields implementation (see `Schema.skipPaths` for reasons): + + case "ChatClient": + let chatOptions = try ClientOptions.from(rpcParams.methodArg("clientOptions")) + let realtimeOptions = try ARTClientOptions.from(rpcParams.methodArg("realtimeClientOptions")) + let realtime = ARTRealtime(options: realtimeOptions) + let chatClient = DefaultChatClient(realtime: realtime, clientOptions: chatOptions) + let instanceId = generateId() + idToChatClient[instanceId] = chatClient + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(instanceId)\"}") + + // This field is optional and should be included in a corresponding json schema for automatic generation + case "Message#createdAt": + guard let message = try idToMessage[rpcParams.refId()] else { + throw try AdapterError.objectNotFound(type: "Message", refId: rpcParams.refId()) + } + if let createdAt = message.createdAt { // number + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \"\(createdAt)\"}") + } else { + return try jsonRpcResult(rpcParams.requestId(), "{\"response\": \(NSNull()) }") + } + + // Here is a custom getter (by "roomId", not with `generateId()`) + case "Rooms.get": + let options = try RoomOptions.from(rpcParams.methodArg("options")) + let roomID = try String.from(rpcParams.methodArg("roomId")) + let refId = try rpcParams.refId() + guard let roomsRef = idToRooms[refId] else { + throw AdapterError.objectNotFound(type: "Rooms", refId: refId) + } + let room = try await roomsRef.get(roomID: roomID, options: options) // Room + idToRoom[roomID] = room + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(roomID)\"}") + + // `events` is an array of strings in schema file which is not enougth for param auto-generation (should be `PresenceEventType`) + case "~Presence.subscribe_eventsAndListener": + let refId = try rpcParams.refId() + guard let events = try rpcParams.methodArgs()["events"] as? [String] else { + throw AdapterError.jsonValueNotFound("events") + } + guard let presenceRef = idToPresence[refId] else { + throw AdapterError.objectNotFound(type: "Presence", refId: refId) + } + let subscription = await presenceRef.subscribe(events: events.map { PresenceEventType.from($0) }) + let webSocket = webSocket + let callback: (PresenceEvent) async throws -> Void = { event in + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\(jsonString(event))")) + } + Task { + for await event in subscription { + try await callback(event) + } + } + let resultRefId = generateId() + idToPresenceSubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + // Temporarily fix until chat v2 implemented (ECO-5116) + case "Messages.subscribe": + let refId = try rpcParams.refId() + guard let messagesRef = idToMessages[refId] else { + throw AdapterError.objectNotFound(type: "Messages", refId: refId) + } + let subscription = try await messagesRef.subscribe(bufferingPolicy: .unbounded) + let webSocket = webSocket + let callback: (Message) async throws -> Void = { message in + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "{ \"type\": \"message.created\", \"message\": \(jsonString(message))}")) + } + Task { + for await event in subscription { + try await callback(event) + } + } + let resultRefId = generateId() + idToMessageSubscription[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\"refId\":\"\(resultRefId)\"}") + + default: + let method = try rpcParams.method() + print("Warning: method `\(method)` was not found") // TODO: use logger + return try jsonRpcError(rpcParams.requestId(), error: AdapterError.methodNotFound(method)) + } + } catch { + print("Error: \(error)") // TODO: use logger + return try jsonRpcError(rpcParams.requestId(), error: error) + } + } +} + +extension ChatAdapter { + enum AdapterError: Error, CustomStringConvertible { + case methodNotFound(_ method: String) + case objectNotFound(type: String, refId: String) + case jsonValueNotFound(_ key: String) + + var description: String { + switch self { + case let .objectNotFound(type: type, refId: refId): + "Object of type '\(type)' with tne refId '\(refId)' was not found." + case let .jsonValueNotFound(key): + "JSON value for key '\(key)' was not found." + case let .methodNotFound(method): + "Method '\(method)' was not found." + } + } + } +} + +private extension JSON { + func method() throws -> String { try stringValue("method") } + func methodArgs() throws -> JSON { try jsonValue("params").jsonValue("args") } + func methodArg(_ name: String) throws -> Any { try methodArgs().anyValue(name) } + func refId() throws -> String { try jsonValue("params").stringValue("refId") } + func callbackId() throws -> String { try jsonValue("params").stringValue("callbackId") } + func requestId() throws -> String { try stringValue("id") } +} diff --git a/UTSChatAdapter/Sources/Adapter/NanoID.swift b/UTSChatAdapter/Sources/Adapter/NanoID.swift new file mode 100644 index 00000000..4628656f --- /dev/null +++ b/UTSChatAdapter/Sources/Adapter/NanoID.swift @@ -0,0 +1,122 @@ +// +// NanoID.swift +// +// Created by Anton Lovchikov on 05/07/2018. +// Copyright © 2018 Anton Lovchikov. All rights reserved. +// + +import Foundation + +/// USAGE +/// +/// Nano ID with default alphabet (0-9a-zA-Z_~) and length (21 chars) +/// let id = NanoID.new() +/// +/// Nano ID with default alphabet and given length +/// let id = NanoID.new(12) +/// +/// Nano ID with given alphabet and length +/// let id = NanoID.new(alphabet: .uppercasedLatinLetters, size: 15) +/// +/// Nano ID with preset custom parameters +/// let nanoID = NanoID(alphabet: .lowercasedLatinLetters,.numbers, size:10) +/// let idFirst = nanoID.new() +/// let idSecond = nanoID.new() + +class NanoID { + // Shared Parameters + private var size: Int + private var alphabet: String + + /// Inits an instance with Shared Parameters + init(alphabet: NanoIDAlphabet..., size: Int) { + self.size = size + self.alphabet = NanoIDHelper.parse(alphabet) + } + + /// Generates a Nano ID using Shared Parameters + func new() -> String { + NanoIDHelper.generate(from: alphabet, of: size) + } + + // Default Parameters + private static let defaultSize = 21 + private static let defaultAphabet = NanoIDAlphabet.urlSafe.toString() + + /// Generates a Nano ID using Default Parameters + static func new() -> String { + NanoIDHelper.generate(from: defaultAphabet, of: defaultSize) + } + + /// Generates a Nano ID using given occasional parameters + static func new(alphabet: NanoIDAlphabet..., size: Int) -> String { + let charactersString = NanoIDHelper.parse(alphabet) + return NanoIDHelper.generate(from: charactersString, of: size) + } + + /// Generates a Nano ID using Default Alphabet and given size + static func new(_ size: Int) -> String { + NanoIDHelper.generate(from: NanoID.defaultAphabet, of: size) + } +} + +private class NanoIDHelper { + /// Parses input alphabets into a string + static func parse(_ alphabets: [NanoIDAlphabet]) -> String { + var stringCharacters = "" + + for alphabet in alphabets { + stringCharacters.append(alphabet.toString()) + } + + return stringCharacters + } + + /// Generates a Nano ID using given parameters + static func generate(from alphabet: String, of length: Int) -> String { + var nanoID = "" + + for _ in 0 ..< length { + let randomCharacter = NanoIDHelper.randomCharacter(from: alphabet) + nanoID.append(randomCharacter) + } + + return nanoID + } + + /// Returns a random character from a given string + static func randomCharacter(from string: String) -> Character { + let randomNum = Int(arc4random_uniform(UInt32(string.count))) + let randomIndex = string.index(string.startIndex, offsetBy: randomNum) + return string[randomIndex] + } +} + +enum NanoIDAlphabet { + case urlSafe + case uppercasedLatinLetters + case lowercasedLatinLetters + case numbers + + func toString() -> String { + switch self { + case .uppercasedLatinLetters, .lowercasedLatinLetters, .numbers: + chars() + case .urlSafe: + "\(NanoIDAlphabet.uppercasedLatinLetters.chars())\(NanoIDAlphabet.lowercasedLatinLetters.chars())\(NanoIDAlphabet.numbers.chars())~_" + } + } + + private func chars() -> String { + switch self { + case .uppercasedLatinLetters: + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + case .lowercasedLatinLetters: + "abcdefghijklmnopqrstuvwxyz" + case .numbers: + "1234567890" + default: + "" + } + } +} diff --git a/UTSChatAdapter/Sources/Adapter/Schema+Adapter.swift b/UTSChatAdapter/Sources/Adapter/Schema+Adapter.swift new file mode 100644 index 00000000..df874220 --- /dev/null +++ b/UTSChatAdapter/Sources/Adapter/Schema+Adapter.swift @@ -0,0 +1,48 @@ +import Ably +import AblyChat + +typealias ErrorInfo = ARTErrorInfo +typealias AblyErrorInfo = ARTErrorInfo +typealias RealtimePresenceParams = PresenceQuery +typealias PaginatedResultMessage = PaginatedResult +typealias OnConnectionStatusChange = Subscription +typealias OnDiscontinuitySubscription = Subscription +typealias OccupancySubscription = Subscription +typealias RoomReactionsSubscription = Subscription +typealias OnRoomStatusChange = Subscription +typealias TypingSubscription = Subscription +typealias PresenceSubscription = Subscription + +struct PresenceDataWrapper {} + +public extension Message { + func before(message: Message) throws -> Bool { + try isBefore(message) + } + + func after(message: Message) throws -> Bool { + try isAfter(message) + } + + func equal(message: Message) throws -> Bool { + try isEqual(message) + } +} + +extension Room { + func options() -> RoomOptions { options } +} + +extension PaginatedResult { + func hasNext() -> Bool { hasNext } + func isLast() -> Bool { isLast } + func next() async throws -> (any PaginatedResult)? { try await next } + func first() async throws -> (any PaginatedResult)? { try await first } + func current() async throws -> (any PaginatedResult)? { try await current } +} + +extension Presence { + func subscribeAll() async -> Subscription { + await subscribe(events: [.enter, .leave, .present, .update]) + } +} diff --git a/UTSChatAdapter/Sources/Adapter/Utils.swift b/UTSChatAdapter/Sources/Adapter/Utils.swift new file mode 100644 index 00000000..7ba8454d --- /dev/null +++ b/UTSChatAdapter/Sources/Adapter/Utils.swift @@ -0,0 +1,317 @@ +import Ably +import AblyChat + +typealias JSON = [String: Any] + +extension JSON { + func stringValue(_ name: String) throws -> String { + guard let value = self[name] as? String else { + throw ChatAdapter.AdapterError.jsonValueNotFound(name) + } + return value + } + + func jsonValue(_ name: String) throws -> JSON { + guard let value = self[name] as? JSON else { + throw ChatAdapter.AdapterError.jsonValueNotFound(name) + } + return value + } + + func anyValue(_ name: String) throws -> Any { + guard let value = self[name] else { + throw ChatAdapter.AdapterError.jsonValueNotFound(name) + } + return value + } +} + +func jsonRpcResult(_ requestId: String, _ result: String) -> String { + "{\"jsonrpc\":\"2.0\",\"id\":\"\(requestId)\",\"result\":\(result)}" +} + +func jsonRpcCallback(_ callbackId: String, _ message: String) -> String { + "{\"jsonrpc\":\"2.0\",\"id\":\"\(UUID().uuidString)\",\"method\":\"callback\",\"params\":{\"callbackId\":\"\(callbackId)\",\"args\":[\(message)]}}" +} + +func jsonRpcError(_ requestId: String, error: Error) -> String { + if let error = error as? ARTErrorInfo { + "{\"jsonrpc\":\"2.0\",\"id\":\"\(requestId)\",\"error\":{\"message\": \"\(error.description)\", \"data\": {\"ablyError\": true, \"errorInfo\": \(error.jsonString())}}}" + } else { + "{\"jsonrpc\":\"2.0\",\"id\":\"\(requestId)\",\"error\":{\"message\": \"\(error)\", \"data\": {\"ablyError\": false}}}" + } +} + +enum RPCError: Error, CustomStringConvertible { + case unknownWebsocketData + case invalidWebsocketString + case invalidJSON + case invalidCallParams + + var description: String { + switch self { + case .invalidCallParams: + "No valid RPC call fields in the provided JSON were found." + case .invalidJSON: + "Data provided is not a valid JSON dictionary." + case .unknownWebsocketData: + "Unknown websocket message (should be `String` or `Data`)." + case .invalidWebsocketString: + "Couldn't create data from string provided (utf8)." + } + } +} + +extension URLSessionWebSocketTask.Message { + func json() throws -> JSON { + var json: JSON? + + switch self { + case let .data(data): + json = try JSONSerialization.jsonObject(with: data) as? JSON + case let .string(string): + guard let data = string.data(using: .utf8) else { + throw RPCError.invalidWebsocketString + } + json = try JSONSerialization.jsonObject(with: data) as? JSON + @unknown default: + throw RPCError.unknownWebsocketData + } + + guard let json else { + throw RPCError.invalidJSON + } + if json["method"] == nil || json["jsonrpc"] == nil { + throw RPCError.invalidCallParams + } + return json + } +} + +func generateId() -> String { NanoID.new() } + +protocol JsonSerialisable { + func jsonString() throws -> String +} + +extension ClientOptions: JsonSerialisable { + func jsonString() -> String { + "{\"logLevel\": \"\(logLevel ?? .info)\"}" + } +} + +extension RoomOptions: JsonSerialisable { + func jsonString() -> String { + fatalError("Not implemented") + } +} + +extension ErrorInfo: JsonSerialisable { + func jsonString() -> String { + "{\"code\": \(code), \"statusCode\": \(statusCode), \"reason\": \"\(reason ?? description)\"}" + } +} + +extension OccupancyEvent: JsonSerialisable { + func jsonString() -> String { + fatalError("Not implemented") + } +} + +extension Message: JsonSerialisable { + func jsonString() throws -> String { + let data = try JSONEncoder().encode(self) + guard let string = String(data: data, encoding: .utf8) else { + fatalError("Failed to create string from data.") + } + return string + } +} + +extension ConnectionStatusChange: JsonSerialisable { + func jsonString() -> String { + fatalError("Not implemented") + } +} + +extension RoomStatusChange: JsonSerialisable { + func jsonString() -> String { + fatalError("Not implemented") + } +} + +extension TypingEvent: JsonSerialisable { + func jsonString() -> String { + fatalError("Not implemented") + } +} + +extension Reaction: JsonSerialisable { + func jsonString() -> String { + fatalError("Not implemented") + } +} + +extension PresenceEvent: JsonSerialisable { + func jsonString() -> String { + fatalError("Not implemented") + } +} + +extension String: JsonSerialisable { + func jsonString() -> String { + self + } +} + +func jsonString(_ value: Any) throws -> String { + // swiftlint:disable force_cast + if value is JsonSerialisable { + return try (value as! JsonSerialisable).jsonString() + } else if value is [JsonSerialisable] { + return try "[" + (value as! [JsonSerialisable]).map { try $0.jsonString() }.joined(separator: ",\n") + "]" + } + // swiftlint:enable force_cast + fatalError("Not implemented") +} + +extension Message { + static func from(_: Any?) -> Self { + fatalError("Not implemented") + } +} + +extension QueryOptions { + static func from(_ value: Any?) -> Self { + guard let json = value as? JSON else { + fatalError("Not compatible data for creating QueryOptions. Expected JSON.") + } + return QueryOptions( + limit: json["limit"] as? Int, + orderBy: (json["direction"] as? String ?? "forwards") == "forwards" ? .newestFirst : .oldestFirst + ) + } +} + +extension SendMessageParams { + static func from(_ value: Any?) throws -> Self { + guard let json = value as? JSON, let text = json["text"] as? String else { + fatalError("Not compatible data for creating SendMessageParams. Expected JSON with string `text` value.") + } + return SendMessageParams(text: text) + } +} + +extension String { + static func from(_ value: Any?) -> Self { + guard let string = value as? String else { + fatalError("Value is not a string.") + } + return string + } +} + +extension RealtimePresenceParams { + static func from(_: Any?) -> Self { + fatalError("Not implemented") + } +} + +extension SendReactionParams { + static func from(_: Any?) -> Self { + fatalError("Not implemented") + } +} + +extension RoomOptions { + static func from(_ value: Any?) -> Self { + guard let json = value as? JSON else { + fatalError("Not compatible data for creating RoomOptions. Expected JSON.") + } + var presence = PresenceOptions() + if let presenceJson = json["presence"] as? JSON { + presence.enter = presenceJson["enter"] as? Bool ?? false + presence.subscribe = presenceJson["subscribe"] as? Bool ?? false + } + var typing = TypingOptions() + if let typingJson = json["typing"] as? JSON, let timeoutMs = typingJson["timeoutMs"] as? Double { + typing.timeout = timeoutMs / 1000 + } + let reactions = RoomReactionsOptions() + let occupancy = OccupancyOptions() + return RoomOptions(presence: presence, typing: typing, reactions: reactions, occupancy: occupancy) + } +} + +// This should be replaced with `LogLevel` conforming to `String`. +extension LogLevel { + static func from(string: String) -> Self { + switch string { + case "trace": + .trace + case "debug": + .debug + case "info": + .info + case "warn": + .warn + case "error": + .error + case "silent": + .silent + default: + .debug + } + } +} + +extension ClientOptions { + static func from(_ value: Any?) -> Self { + guard let json = value as? JSON, let logLevel = json["logLevel"] as? String else { + fatalError("Not compatible data for creating ClientOptions. Expected JSON with `logLevel` string.") + } + var options = ClientOptions() + options.logLevel = .from(string: logLevel) + return options + } +} + +extension ARTClientOptions { + static func from(_ value: Any?) -> ARTClientOptions { + guard let json = value as? JSON else { + fatalError("Not compatible data for creating ClientOptions. Expected JSON.") + } + let options = ARTClientOptions() + options.clientId = json["clientId"] as? String + options.environment = json["environment"] as? String ?? "production" + options.key = json["key"] as? String + options.logLevel = .init(rawValue: json["logLevel"] as? UInt ?? ARTLogLevel.debug.rawValue) ?? .debug + options.token = json["token"] as? String + options.useBinaryProtocol = json["useBinaryProtocol"] as? Bool ?? false + options.useTokenAuth = json["useTokenAuth"] as? Bool ?? false + return options + } +} + +extension PresenceDataWrapper { + static func from(_ value: Any?) -> PresenceData { + // swiftlint:disable force_cast + if value is [String: Int64] { + return value as! [String: Int64] + } + if value is [String: String] { + return value as! [String: String] + } + if value is String { + return value as! String + } + // swiftlint:enable force_cast + fatalError("Not implemented") + } +} + +extension PresenceEventType { + static func from(_: Any?) -> Self { + fatalError("Not implemented") + } +} diff --git a/UTSChatAdapter/Sources/Adapter/WebSocketWrapper.swift b/UTSChatAdapter/Sources/Adapter/WebSocketWrapper.swift new file mode 100644 index 00000000..f11e0661 --- /dev/null +++ b/UTSChatAdapter/Sources/Adapter/WebSocketWrapper.swift @@ -0,0 +1,43 @@ +import Foundation + +@MainActor +final class WebSocketWrapper: NSObject, URLSessionWebSocketDelegate { + private var webSocket: URLSessionWebSocketTask! + + func start(onMessage: @escaping (URLSessionWebSocketTask.Message) async throws -> Void) async throws { + let session = URLSession(configuration: .default, delegate: self, delegateQueue: .current) + let url = URL(string: "ws://localhost:3000")! + + webSocket = session.webSocketTask(with: url) + webSocket.resume() + + while !Task.isCancelled { + do { + try await onMessage(webSocket.receive()) + } catch { + print("Can't connect to \(url): \(error.localizedDescription)") + sleep(5) // try again in 5 seconds + webSocket = session.webSocketTask(with: url) // without recreating the task it doesn't work + webSocket.resume() + } + } + } + + func send(text: String) async throws { + print("Sending: \(text)") + try await webSocket.send(URLSessionWebSocketTask.Message.string(text)) + } + + // MARK: URLSessionWebSocketDelegate + + nonisolated func urlSession(_: URLSession, webSocketTask _: URLSessionWebSocketTask, didOpenWithProtocol _: String?) { + print("Connected to server") + Task { + try await send(text: "{\"role\":\"ADAPTER\"}") + } + } + + nonisolated func urlSession(_: URLSession, webSocketTask _: URLSessionWebSocketTask, didCloseWith _: URLSessionWebSocketTask.CloseCode, reason _: Data?) { + print("Disconnected from server") + } +} diff --git a/UTSChatAdapter/Sources/Adapter/main.swift b/UTSChatAdapter/Sources/Adapter/main.swift new file mode 100644 index 00000000..c5344aa0 --- /dev/null +++ b/UTSChatAdapter/Sources/Adapter/main.swift @@ -0,0 +1,25 @@ +import Foundation + +func serve() async throws { + let webSocket = await WebSocketWrapper() + var adapter = await ChatAdapter(webSocket: webSocket) + + try await webSocket.start { message in + do { + let params = try message.json() + print("RPC params: \(params)") + + let rpcResponse = try await adapter.handleRpcCall(rpcParams: params) + try await webSocket.send(text: rpcResponse) + } catch { + print("Unhandled exception occured: \(error)") // TODO: replace with logger + } + } +} + +do { + try await serve() +} catch { + print("Exiting due to fatal error: \(error)") // TODO: replace with logger + exit(1) +} diff --git a/UTSChatAdapter/Sources/Generator/ChatAdapterGenerator.swift b/UTSChatAdapter/Sources/Generator/ChatAdapterGenerator.swift new file mode 100644 index 00000000..ced87a76 --- /dev/null +++ b/UTSChatAdapter/Sources/Generator/ChatAdapterGenerator.swift @@ -0,0 +1,271 @@ +import Foundation + +// Generator utility is for internal use only, so force_cast is fine: +// swiftlint:disable force_cast + +/** + * Unified Test Suite adapter generator for swift Chat SDK + */ +class ChatAdapterGenerator { + var generatedFileContent = "// GENERATED CONTENT BEGIN\n\n" + + func generate() { + print("Generating swift code...") + Schema.json.forEach { generateSchema($0) } + generatedFileContent += "// GENERATED CONTENT END" + print(generatedFileContent) + } + + func generateSchema(_ schema: JSON) { + guard let objectType = schema.name else { + return print("Schema should have a name.") + } + if let constructor = schema.constructor { + generateConstructorForType(objectType, schema: constructor, isAsync: false, throwing: false) + } + for method in schema.syncMethods?.sortedByKey() ?? [] { + generateMethodForType(objectType, methodName: method.key, methodSchema: method.value as! JSON, isAsync: false, throwing: true) + } + for method in schema.asyncMethods?.sortedByKey() ?? [] { + generateMethodForType(objectType, methodName: method.key, methodSchema: method.value as! JSON, isAsync: true, throwing: true) + } + for field in schema.fields?.sortedByKey() ?? [] { + generateFieldForType(objectType, fieldName: field.key, fieldSchema: field.value as! JSON) + } + for method in schema.listeners?.sortedByKey() ?? [] { + generateMethodWithCallbackForType(objectType, methodName: method.key, methodSchema: method.value as! JSON, isAsync: true, throwing: false) + } + } + + func generateConstructorForType(_ objectType: String, schema: JSON, isAsync _: Bool, throwing _: Bool) { + let implPath = "\(objectType)" + if Schema.skipPaths.contains([implPath]) { + return print("\(implPath) was not yet implemented or requires custom implementation.") + } + let methodArgs = schema.args ?? [:] + let paramsDeclarations = methodArgs.map { element in + let argSchema = element.value as! JSON + return " let \(element.key.bigD()) = try \(altTypeName(argSchema.type!)).from(rpcParams.methodArg(\"\(element.key)\"))" + } + let callParams = methodArgs.map { "\($0.key.bigD()): \($0.key.bigD())" }.joined(separator: ", ") + generatedFileContent += + """ + case "\(Schema.noCallPaths.contains([implPath]) ? "~" : "")\(objectType)": + """ + if !paramsDeclarations.isEmpty { + generatedFileContent += paramsDeclarations.joined(separator: "\n") + "\n" + } + generatedFileContent += + """ + let \(altTypeName(objectType).firstLowercased()) = \(altTypeName(objectType))(\(callParams)) + let instanceId = generateId() + idTo\(altTypeName(objectType))[instanceId] = \(altTypeName(objectType).firstLowercased()) + return try jsonRpcResult(rpcParams.requestId(), "{\\"instanceId\\":\\"\\(instanceId)\\"}")\n + + """ + } + + func generateMethodForType(_ objectType: String, methodName: String, methodSchema: JSON, isAsync: Bool, throwing: Bool) { + let implPath = "\(objectType).\(methodName)" + if Schema.skipPaths.contains([implPath]) { + return print("\(implPath) was not yet implemented or requires custom implementation.") + } + let methodArgs = methodSchema.args ?? [:] + let paramsDeclarations = methodArgs.map { element in + let argSchema = element.value as! JSON + return " let \(element.key.bigD()) = try \(altTypeName(argSchema.type!)).from(rpcParams.methodArg(\"\(element.key)\"))" + } + let callParams = methodArgs.map { "\($0.key.bigD()): \($0.key.bigD())" }.joined(separator: ", ") + let hasResult = methodSchema.result.type != nil && methodSchema.result.type != "void" + let resultType = altTypeName(methodSchema.result.type ?? "void") + generatedFileContent += + """ + case "\(Schema.noCallPaths.contains([implPath]) ? "~" : "")\(objectType).\(methodName)":\n + """ + if !paramsDeclarations.isEmpty { + generatedFileContent += paramsDeclarations.joined(separator: "\n") + "\n" + } + generatedFileContent += + """ + let refId = try rpcParams.refId() + guard let \(altTypeName(objectType).firstLowercased())Ref = idTo\(altTypeName(objectType))[refId] else { + throw AdapterError.objectNotFound(type: "\(objectType)", refId: refId) + } + \(hasResult ? "let \(resultType.firstLowercased()) = " : "")\(throwing ? "try " : "")\(isAsync ? "await " : "")\(altTypeName(objectType).firstLowercased())Ref.\(methodName)(\(callParams)) // \(resultType)\n + """ + if hasResult { + if isJsonPrimitiveType(methodSchema.result.type!) { + generatedFileContent += + """ + return try jsonRpcResult(rpcParams.requestId(), "{\\"response\\": \\"\\(\(resultType.firstLowercased()))\\"}")\n + + """ + } else if methodSchema.result.isSerializable { + generatedFileContent += + """ + return try jsonRpcResult(rpcParams.requestId(), "{\\"response\\": \\(jsonString(\(resultType.firstLowercased())))}")\n + + """ + } else { + generatedFileContent += + """ + let resultRefId = generateId() + idTo\(altTypeName(methodSchema.result.type!))[resultRefId] = \(resultType.firstLowercased()) + return try jsonRpcResult(rpcParams.requestId(), "{\\"refId\\":\\"\\(resultRefId)\\"}")\n + + """ + } + } else { + generatedFileContent += + """ + return try jsonRpcResult(rpcParams.requestId(), "{}")\n + + """ + } + } + + func generateFieldForType(_ objectType: String, fieldName: String, fieldSchema: JSON) { + guard let fieldType = fieldSchema.type else { + return print("Type information for '\(fieldName)' field is incorrect.") + } + let implPath = "\(objectType)#\(fieldName)" + if Schema.skipPaths.contains([implPath]) { + return print("\(implPath) was not yet implemented or requires custom implementation.") + } + generatedFileContent += + """ + case "\(Schema.noCallPaths.contains([implPath]) ? "~" : "")\(implPath)": + let refId = try rpcParams.refId() + guard let \(altTypeName(objectType).firstLowercased())Ref = idTo\(altTypeName(objectType))[refId] else { + throw AdapterError.objectNotFound(type: "\(objectType)", refId: refId) + } + let \(fieldName.bigD()) = \(altTypeName(objectType).firstLowercased())Ref.\(fieldName.bigD()) // \(fieldType)\n + """ + + if fieldSchema.isSerializable { + if isJsonPrimitiveType(fieldType) { + generatedFileContent += + """ + return try jsonRpcResult(rpcParams.requestId(), "{\\"response\\": \\"\\(\(fieldName.bigD()))\\"}")\n + + """ + } else { + generatedFileContent += + """ + return try jsonRpcResult(rpcParams.requestId(), "{\\"response\\": \\(jsonString(\(fieldName.bigD())))}")\n + + """ + } + } else { + generatedFileContent += + """ + let fieldRefId = generateId() + idTo\(fieldType)[fieldRefId] = \(fieldName.bigD()) + return try jsonRpcResult(rpcParams.requestId(), "{\\"refId\\":\\"\\(fieldRefId)\\"}")\n + + """ + } + } + + func generateMethodWithCallbackForType(_ objectType: String, methodName: String, methodSchema: JSON, isAsync: Bool, throwing: Bool) { + let implPath = "\(objectType).\(methodName)" + if Schema.skipPaths.contains([implPath]) { + return print("\(implPath) was not yet implemented or requires custom implementation.") + } + let methodArgs = methodSchema.args ?? [:] + let paramsSignatures = methodArgs.compactMap { element in + let argName = element.key + let argType = (element.value as! JSON).type! + if argType != "callback" { + return (declaration: " let \(argName.bigD()) = try \(altTypeName(argType)).from(rpcParams.methodArg(\"\(argName)\"))", + usage: "\(argName.bigD()): \(argName.bigD())") + } else { + return nil + } + } + let callParams = (paramsSignatures.map(\.usage) + ["bufferingPolicy: .unbounded"]).joined(separator: ", ") + generatedFileContent += + """ + case "\(Schema.noCallPaths.contains([implPath]) ? "~" : "")\(objectType).\(methodName)":\n + """ + if !paramsSignatures.isEmpty { + generatedFileContent += paramsSignatures.map(\.declaration).joined(separator: "\n") + "\n" + } + generatedFileContent += + """ + let refId = try rpcParams.refId() + guard let \(altTypeName(objectType).firstLowercased())Ref = idTo\(altTypeName(objectType))[refId] else { + throw AdapterError.objectNotFound(type: "\(objectType)", refId: refId) + } + let subscription = \(throwing ? "try " : "")\(isAsync ? "await " : "")\(altTypeName(objectType).firstLowercased())Ref.\(altMethodName(methodName))(\(callParams))\n + """ + generatedFileContent += generateCallback(methodSchema.callback, isAsync: false, throwing: false) + generatedFileContent += + """ + let resultRefId = generateId() + idTo\(altTypeName(methodSchema.result.type!))[resultRefId] = subscription + return try jsonRpcResult(rpcParams.requestId(), "{\\"refId\\":\\"\\(resultRefId)\\"}")\n + + """ + } + + func generateCallback(_ callbackSchema: JSON, isAsync _: Bool, throwing _: Bool) -> String { + let callbackArgs = callbackSchema.args ?? [:] + let paramsSignatures = callbackArgs.prefix(1).compactMap { element in // code below simplifies it to just one callback parameter + let argName = element.key + let argType = (element.value as! JSON).type! + let isOptional = (element.value as! JSON).isOptional + return (declaration: "\(altTypeName(argType))" + (isOptional ? "?" : ""), usage: "\(argName.bigD())") + } + let paramsDeclaration = paramsSignatures.map(\.declaration).joined(separator: ", ") + let paramsUsage = paramsSignatures.map(\.usage).joined(separator: ", ") + var result = + """ + let webSocket = webSocket + let callback: (\(paramsDeclaration)) async throws -> \(altTypeName(callbackSchema.result.type!)) = {\n + """ + if (callbackArgs.first?.value as? JSON)?.isOptional ?? false { + result += + """ + if let param = $0 { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\\(jsonString(param))")) + } else { + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "{}")) + }\n + """ + } else { + result += + """ + try await webSocket.send(text: jsonRpcCallback(rpcParams.callbackId(), "\\(jsonString($0))"))\n + """ + } + result += + """ + } + Task { + for await \(paramsUsage) in subscription { + try await callback(\(paramsUsage)) + } + }\n + """ + return result + } +} + +private extension JSON { + var name: String? { self["name"] as? String } + var type: String? { self["type"] as? String } + var args: JSON? { self["args"] as? JSON } + var result: JSON { self["result"] as! JSON } + var isSerializable: Bool { self["serializable"] as? Bool ?? false } + var isOptional: Bool { self["optional"] as? Bool ?? false } + var constructor: JSON? { self["konstructor"] as? JSON } + var fields: JSON? { self["fields"] as? JSON } + var syncMethods: JSON? { self["syncMethods"] as? JSON } + var asyncMethods: JSON? { self["asyncMethods"] as? JSON } + var listeners: JSON? { self["listeners"] as? JSON } + var listener: JSON? { self["listener"] as? JSON } + var callback: JSON { args!.listener! } +} + +// swiftlint:enable force_cast diff --git a/UTSChatAdapter/Sources/Generator/Schema+Generator.swift b/UTSChatAdapter/Sources/Generator/Schema+Generator.swift new file mode 100644 index 00000000..c93e3514 --- /dev/null +++ b/UTSChatAdapter/Sources/Generator/Schema+Generator.swift @@ -0,0 +1,129 @@ +import Foundation + +private let altTypesMap = [ + "void": "Void", + "PresenceData": "PresenceDataWrapper", + "MessageSubscriptionResponse": "MessageSubscription", + "OnConnectionStatusChangeResponse": "OnConnectionStatusChange", + "OccupancySubscriptionResponse": "OccupancySubscription", + "RoomReactionsSubscriptionResponse": "RoomReactionsSubscription", + "OnDiscontinuitySubscriptionResponse": "OnDiscontinuitySubscription", + "OnRoomStatusChangeResponse": "OnRoomStatusChange", + "TypingSubscriptionResponse": "TypingSubscription", + "PresenceSubscriptionResponse": "PresenceSubscription", + "MessageEventPayload": "Message", + "PaginatedResult": "PaginatedResultMessage", +] + +private let jsonPrimitiveTypesMap = [ + "string": "\(String.self)", + "boolean": "\(Bool.self)", + "number": "\(Int.self)", +] + +private let altMethodsMap = [ + "onDiscontinuity": "subscribeToDiscontinuities", + "subscribe_listener": "subscribeAll", +] + +func isJsonPrimitiveType(_ typeName: String) -> Bool { + jsonPrimitiveTypesMap.keys.contains([typeName]) +} + +func altTypeName(_ typeName: String) -> String { + (altTypesMap[typeName] ?? jsonPrimitiveTypesMap[typeName]) ?? typeName +} + +func altMethodName(_ methodName: String) -> String { + altMethodsMap[methodName] ?? methodName +} + +extension String { + func bigD() -> String { + replacingOccurrences(of: "Id", with: "ID") + } +} + +extension Schema { + // These paths were not yet implemented in SDK or require custom implementation: + static let skipPaths = [ + "ChatClient", // custom constructor with realtime instance + "ChatClient#logger", // not exposed + "RoomStatus#error", // not available directly (via lifecycle object) + "Message#createdAt", // optional + "Presence.subscribe_eventsAndListener", // impossible to infer param type from `string` + "Rooms.get", // custom getter (by "roomId", not with `generateId()`) + "ChatClient.addReactAgent", // ? + + // Not implemented: + + "Presence#channel", + + "Messages.unsubscribeAll", + "Presence.unsubscribeAll", + "Occupancy.unsubscribeAll", + "RoomReactions.unsubscribeAll", + "Typing.unsubscribeAll", + + "TypingSubscriptionResponse.unsubscribe", + "MessageSubscriptionResponse.unsubscribe", + "OccupancySubscriptionResponse.unsubscribe", + "PresenceSubscriptionResponse.unsubscribe", + "PresenceSubscriptionResponse.unsubscribe", + "RoomReactionsSubscriptionResponse.unsubscribe", + + "OnConnectionStatusChangeResponse.off", + "OnDiscontinuitySubscriptionResponse.off", + "OnRoomStatusChangeResponse.off", + + "ConnectionStatus.offAll", + "RoomStatus.offAll", + + "Logger.error", + "Logger.trace", + "Logger.info", + "Logger.debug", + "Logger.warn", + + // Removed/changed but not reflected in schema file: + + "ConnectionStatus#current", + "ConnectionStatus.onChange", + "RoomStatus#current", + "RoomStatus.onChange", + "ConnectionStatus#error", + ] + + // These paths have dummy implementation in the SDK and will not be called due to tilda prefix (once implemented - remove "~"): + static let noCallPaths = [ + "ChatClient#connection", + "Connection#status", + "ConnectionStatus.onChange", + "Rooms.release", + "Room#occupancy", + "Occupancy.get", + "Occupancy#channel", + "Occupancy.subscribe", + "Occupancy.onDiscontinuity", + "Room#presence", + "Presence.enter", + "Presence.leave", + "Presence.isUserPresent", + "Presence.update", + "Presence.subscribe_listener", + "Presence.onDiscontinuity", + "Room#typing", + "Typing.subscribe", + "Typing.onDiscontinuity", + "Typing#channel", + "Typing.get", + "Typing.start", + "Typing.stop", + "Room#reactions", + "RoomReactions.subscribe", + "RoomReactions#channel", + "RoomReactions.send", + "RoomReactions.onDiscontinuity", + "Messages.onDiscontinuity", + ] +} diff --git a/UTSChatAdapter/Sources/Generator/Schema.swift b/UTSChatAdapter/Sources/Generator/Schema.swift new file mode 100644 index 00000000..3a054bc8 --- /dev/null +++ b/UTSChatAdapter/Sources/Generator/Schema.swift @@ -0,0 +1,995 @@ +import Foundation + +enum Schema { + static var json: [JSON] { + do { + // swiftlint:disable:next force_cast + return try JSONSerialization.jsonObject(with: content.data(using: .utf8)!) as! [JSON] + } catch { + print("Couldn't parse schema JSON.") + return [] + } + } +} + +extension Schema { + static let content = + """ + [ + { + "name": "ChatClient", + "konstructor": { + "args": { + "realtimeClientOptions": { + "type": "RealtimeClientOptions", + "serializable": true + }, + "clientOptions": { + "type": "ClientOptions", + "serializable": true, + "optional": true + } + } + }, + "fields": { + "rooms": { + "type": "Rooms", + "serializable": false + }, + "connection": { + "type": "Connection", + "serializable": false + }, + "clientId": { + "type": "string", + "serializable": true + }, + "realtime": { + "type": "Realtime", + "serializable": false + }, + "clientOptions": { + "type": "ClientOptions", + "serializable": true + }, + "logger": { + "type": "Logger", + "serializable": false + } + }, + "syncMethods": { + "addReactAgent": { + "result": { + "type": "void" + } + } + } + }, + { + "name": "ConnectionStatus", + "fields": { + "current": { + "type": "string", + "serializable": true + }, + "error": { + "type": "ErrorInfo", + "serializable": true + } + }, + "syncMethods": { + "offAll": { + "result": { + "type": "void" + } + } + }, + "listeners": { + "onChange": { + "args": { + "listener": { + "type": "callback", + "args": { + "change": { + "type": "ConnectionStatusChange", + "serializable": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "OnConnectionStatusChangeResponse", + "serializable": false + } + } + } + }, + { + "name": "OnConnectionStatusChangeResponse", + "syncMethods": { + "off": { + "result": { + "type": "void" + } + } + } + }, + { + "name": "Connection", + "fields": { + "status": { + "type": "ConnectionStatus", + "serializable": false + } + } + }, + { + "name": "Logger", + "syncMethods": { + "trace": { + "args": { + "message": { + "type": "string", + "serializable": true + }, + "context": { + "type": "object", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + }, + "debug": { + "args": { + "message": { + "type": "string", + "serializable": true + }, + "context": { + "type": "object", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + }, + "info": { + "args": { + "message": { + "type": "string", + "serializable": true + }, + "context": { + "type": "object", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + }, + "warn": { + "args": { + "message": { + "type": "string", + "serializable": true + }, + "context": { + "type": "object", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + }, + "error": { + "args": { + "message": { + "type": "string", + "serializable": true + }, + "context": { + "type": "object", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + } + } + }, + { + "name": "Message", + "fields": { + "timeserial": { + "type": "string", + "serializable": true + }, + "clientId": { + "type": "string", + "serializable": true + }, + "roomId": { + "type": "string", + "serializable": true + }, + "text": { + "type": "string", + "serializable": true + }, + "createdAt": { + "type": "number", + "serializable": true + }, + "metadata": { + "type": "object", + "serializable": true + }, + "headers": { + "type": "object", + "serializable": true + } + }, + "syncMethods": { + "before": { + "args": { + "message": { + "type": "Message", + "serializable": false + } + }, + "result": { + "type": "boolean" + } + }, + "after": { + "args": { + "message": { + "type": "Message", + "serializable": false + } + }, + "result": { + "type": "boolean" + } + }, + "equal": { + "args": { + "message": { + "type": "Message", + "serializable": false + } + }, + "result": { + "type": "boolean" + } + } + } + }, + { + "name": "Messages", + "fields": { + "channel": { + "type": "RealtimeChannel", + "serializable": false + } + }, + "syncMethods": { + "unsubscribeAll": { + "result": { + "type": "void" + } + } + }, + "asyncMethods": { + "get": { + "args": { + "options": { + "type": "QueryOptions", + "serializable": true + } + }, + "result": { + "type": "PaginatedResultMessage", + "serializable": false + } + }, + "send": { + "args": { + "params": { + "type": "SendMessageParams", + "serializable": true + } + }, + "result": { + "type": "Message", + "serializable": false + } + } + }, + "listeners": { + "subscribe": { + "args": { + "listener": { + "type": "callback", + "args": { + "event": { + "type": "MessageEventPayload", + "serializable": false + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "MessageSubscriptionResponse", + "serializable": false + } + }, + "onDiscontinuity": { + "args": { + "listener": { + "type": "callback", + "args": { + "reason": { + "type": "AblyErrorInfo", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "OnDiscontinuitySubscriptionResponse", + "serializable": false + } + } + } + }, + { + "name": "MessageSubscriptionResponse", + "syncMethods": { + "unsubscribe": { + "result": { + "type": "void" + } + } + }, + "asyncMethods": { + "getPreviousMessages": { + "args": { + "params": { + "type": "QueryOptions", + "serializable": true + } + }, + "result": { + "type": "PaginatedResultMessage", + "serializable": false + } + } + } + }, + { + "name": "Occupancy", + "fields": { + "channel": { + "type": "RealtimeChannel", + "serializable": false + } + }, + "syncMethods": { + "unsubscribeAll": { + "result": { + "type": "void" + } + } + }, + "asyncMethods": { + "get": { + "result": { + "type": "OccupancyEvent", + "serializable": true + } + } + }, + "listeners": { + "subscribe": { + "args": { + "listener": { + "type": "callback", + "args": { + "event": { + "type": "OccupancyEvent", + "serializable": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "OccupancySubscriptionResponse", + "serializable": false + } + }, + "onDiscontinuity": { + "args": { + "listener": { + "type": "callback", + "args": { + "reason": { + "type": "AblyErrorInfo", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "OnDiscontinuitySubscriptionResponse", + "serializable": false + } + } + } + }, + { + "name": "OccupancySubscriptionResponse", + "syncMethods": { + "unsubscribe": { + "result": { + "type": "void" + } + } + } + }, + { + "name": "OnDiscontinuitySubscriptionResponse", + "syncMethods": { + "off": { + "result": { + "type": "void" + } + } + } + }, + { + "name": "PaginatedResult", + "fields": { + "items": { + "type": "object", + "serializable": true, + "array": true + } + }, + "syncMethods": { + "hasNext": { + "result": { + "type": "boolean" + } + }, + "isLast": { + "result": { + "type": "boolean" + } + } + }, + "asyncMethods": { + "next": { + "result": { + "type": "PaginatedResult", + "serializable": false + } + }, + "first": { + "result": { + "type": "PaginatedResult", + "serializable": false + } + }, + "current": { + "result": { + "type": "PaginatedResult", + "serializable": false + } + } + } + }, + { + "name": "Presence", + "fields": { + "channel": { + "type": "RealtimeChannel", + "serializable": false + } + }, + "syncMethods": { + "unsubscribeAll": { + "result": { + "type": "void" + } + } + }, + "asyncMethods": { + "get": { + "args": { + "params": { + "type": "RealtimePresenceParams", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "PresenceMember", + "serializable": true, + "array": true + } + }, + "isUserPresent": { + "args": { + "clientId": { + "type": "string", + "serializable": true + } + }, + "result": { + "type": "boolean", + "serializable": true + } + }, + "enter": { + "args": { + "data": { + "type": "PresenceData", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + }, + "update": { + "args": { + "data": { + "type": "PresenceData", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + }, + "leave": { + "args": { + "data": { + "type": "PresenceData", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + } + }, + "listeners": { + "subscribe_listener": { + "args": { + "listener": { + "type": "callback", + "args": { + "event": { + "type": "PresenceEvent", + "serializable": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "PresenceSubscriptionResponse", + "serializable": false + } + }, + "subscribe_eventsAndListener": { + "args": { + "events": { + "type": "string", + "array": true, + "serializable": true + }, + "listener": { + "type": "callback", + "args": { + "event": { + "type": "PresenceEvent", + "serializable": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "PresenceSubscriptionResponse", + "serializable": false + } + }, + "onDiscontinuity": { + "args": { + "listener": { + "type": "callback", + "args": { + "reason": { + "type": "AblyErrorInfo", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "OnDiscontinuitySubscriptionResponse", + "serializable": false + } + } + } + }, + { + "name": "PresenceSubscriptionResponse", + "syncMethods": { + "unsubscribe": { + "result": { + "type": "void" + } + } + } + }, + { + "name": "RoomReactions", + "fields": { + "channel": { + "type": "RealtimeChannel", + "serializable": false + } + }, + "syncMethods": { + "unsubscribeAll": { + "result": { + "type": "void" + } + } + }, + "asyncMethods": { + "send": { + "args": { + "params": { + "type": "SendReactionParams", + "serializable": true + } + }, + "result": { + "type": "void" + } + } + }, + "listeners": { + "subscribe": { + "args": { + "listener": { + "type": "callback", + "args": { + "reaction": { + "type": "Reaction", + "serializable": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "RoomReactionsSubscriptionResponse", + "serializable": false + } + }, + "onDiscontinuity": { + "args": { + "listener": { + "type": "callback", + "args": { + "reason": { + "type": "AblyErrorInfo", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "OnDiscontinuitySubscriptionResponse", + "serializable": false + } + } + } + }, + { + "name": "RoomReactionsSubscriptionResponse", + "syncMethods": { + "unsubscribe": { + "result": { + "type": "void" + } + } + } + }, + { + "name": "RoomStatus", + "fields": { + "current": { + "type": "string", + "serializable": true + }, + "error": { + "type": "ErrorInfo", + "serializable": true + } + }, + "syncMethods": { + "offAll": { + "result": { + "type": "void" + } + } + }, + "listeners": { + "onChange": { + "args": { + "listener": { + "type": "callback", + "args": { + "change": { + "type": "RoomStatusChange", + "serializable": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "OnRoomStatusChangeResponse", + "serializable": false + } + } + } + }, + { + "name": "OnRoomStatusChangeResponse", + "syncMethods": { + "off": { + "result": { + "type": "void" + } + } + } + }, + { + "name": "Room", + "fields": { + "roomId": { + "type": "string", + "serializable": true + }, + "messages": { + "type": "Messages", + "serializable": false + }, + "presence": { + "type": "Presence", + "serializable": false + }, + "reactions": { + "type": "RoomReactions", + "serializable": false + }, + "typing": { + "type": "Typing", + "serializable": false + }, + "occupancy": { + "type": "Occupancy", + "serializable": false + }, + "status": { + "type": "RoomStatus", + "serializable": false + } + }, + "syncMethods": { + "options": { + "result": { + "type": "RoomOptions", + "serializable": true + } + } + }, + "asyncMethods": { + "attach": { + "result": { + "type": "void" + } + }, + "detach": { + "result": { + "type": "void" + } + } + } + }, + { + "name": "Rooms", + "fields": { + "clientOptions": { + "type": "ClientOptions", + "serializable": true + } + }, + "syncMethods": { + "get": { + "args": { + "roomId": { + "type": "string", + "serializable": true + }, + "options": { + "type": "RoomOptions", + "serializable": true + } + }, + "result": { + "type": "Room", + "serializable": false + } + } + }, + "asyncMethods": { + "release": { + "args": { + "roomId": { + "type": "string", + "serializable": true + } + }, + "result": { + "type": "void" + } + } + } + }, + { + "name": "Typing", + "fields": { + "channel": { + "type": "RealtimeChannel", + "serializable": false + } + }, + "syncMethods": { + "unsubscribeAll": { + "result": { + "type": "void" + } + } + }, + "asyncMethods": { + "get": { + "result": { + "type": "string", + "serializable": true, + "array": true + } + }, + "start": { + "result": { + "type": "void" + } + }, + "stop": { + "result": { + "type": "void" + } + } + }, + "listeners": { + "subscribe": { + "args": { + "listener": { + "type": "callback", + "args": { + "event": { + "type": "TypingEvent", + "serializable": false + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "TypingSubscriptionResponse", + "serializable": false + } + }, + "onDiscontinuity": { + "args": { + "listener": { + "type": "callback", + "args": { + "reason": { + "type": "AblyErrorInfo", + "serializable": true, + "optional": true + } + }, + "result": { + "type": "void" + } + } + }, + "result": { + "type": "OnDiscontinuitySubscriptionResponse", + "serializable": false + } + } + } + }, + { + "name": "TypingSubscriptionResponse", + "syncMethods": { + "unsubscribe": { + "result": { + "type": "void" + } + } + } + } + ] + """ +} diff --git a/UTSChatAdapter/Sources/Generator/Utils.swift b/UTSChatAdapter/Sources/Generator/Utils.swift new file mode 100644 index 00000000..60e69cb7 --- /dev/null +++ b/UTSChatAdapter/Sources/Generator/Utils.swift @@ -0,0 +1,16 @@ +import Foundation + +typealias JSON = [String: Any] + +extension StringProtocol { + func firstLowercased() -> String { prefix(1).lowercased() + dropFirst() } + func firstUppercased() -> String { prefix(1).uppercased() + dropFirst() } +} + +extension JSON { + func sortedByKey() -> [Element] { + sorted { element1, element2 in + element1.key > element2.key + } + } +} diff --git a/UTSChatAdapter/Sources/Generator/main.swift b/UTSChatAdapter/Sources/Generator/main.swift new file mode 100644 index 00000000..0bf4670b --- /dev/null +++ b/UTSChatAdapter/Sources/Generator/main.swift @@ -0,0 +1 @@ +ChatAdapterGenerator().generate()