diff --git a/AblyChat.xcworkspace/contents.xcworkspacedata b/AblyChat.xcworkspace/contents.xcworkspacedata index 56985fe1..806958d1 100644 --- a/AblyChat.xcworkspace/contents.xcworkspacedata +++ b/AblyChat.xcworkspace/contents.xcworkspacedata @@ -7,4 +7,7 @@ + + diff --git a/UTSChatAdapter/UTSChatAdapter.xcodeproj/project.pbxproj b/UTSChatAdapter/UTSChatAdapter.xcodeproj/project.pbxproj new file mode 100644 index 00000000..6f22e97d --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter.xcodeproj/project.pbxproj @@ -0,0 +1,323 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 84E1054E2CBEEC9800244C8F /* AblyChat in Frameworks */ = {isa = PBXBuildFile; productRef = 84E1054D2CBEEC9800244C8F /* AblyChat */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 847DD5D92CBC34E5000F89AE /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = /usr/share/man/man1/; + dstSubfolderSpec = 0; + files = ( + ); + runOnlyForDeploymentPostprocessing = 1; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 847DD5DB2CBC34E5000F89AE /* UTSChatAdapter */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = UTSChatAdapter; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 847DD5DD2CBC34E5000F89AE /* UTSChatAdapter */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = UTSChatAdapter; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 847DD5D82CBC34E5000F89AE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 84E1054E2CBEEC9800244C8F /* AblyChat in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 847DD5D22CBC34E5000F89AE = { + isa = PBXGroup; + children = ( + 847DD5DD2CBC34E5000F89AE /* UTSChatAdapter */, + 847DD5ED2CBC3674000F89AE /* Frameworks */, + 847DD5DC2CBC34E5000F89AE /* Products */, + ); + sourceTree = ""; + }; + 847DD5DC2CBC34E5000F89AE /* Products */ = { + isa = PBXGroup; + children = ( + 847DD5DB2CBC34E5000F89AE /* UTSChatAdapter */, + ); + name = Products; + sourceTree = ""; + }; + 847DD5ED2CBC3674000F89AE /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 847DD5DA2CBC34E5000F89AE /* UTSChatAdapter */ = { + isa = PBXNativeTarget; + buildConfigurationList = 847DD5E22CBC34E5000F89AE /* Build configuration list for PBXNativeTarget "UTSChatAdapter" */; + buildPhases = ( + 847DD5D72CBC34E5000F89AE /* Sources */, + 847DD5D82CBC34E5000F89AE /* Frameworks */, + 847DD5D92CBC34E5000F89AE /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 847DD5DD2CBC34E5000F89AE /* UTSChatAdapter */, + ); + name = UTSChatAdapter; + packageProductDependencies = ( + 84E1054D2CBEEC9800244C8F /* AblyChat */, + ); + productName = UTSChatAdapter; + productReference = 847DD5DB2CBC34E5000F89AE /* UTSChatAdapter */; + productType = "com.apple.product-type.tool"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 847DD5D32CBC34E5000F89AE /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + 847DD5DA2CBC34E5000F89AE = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 847DD5D62CBC34E5000F89AE /* Build configuration list for PBXProject "UTSChatAdapter" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 847DD5D22CBC34E5000F89AE; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 84E1054C2CBEEC9800244C8F /* XCLocalSwiftPackageReference "../../ably-chat-swift" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 847DD5DC2CBC34E5000F89AE /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 847DD5DA2CBC34E5000F89AE /* UTSChatAdapter */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 847DD5D72CBC34E5000F89AE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 847DD5E02CBC34E5000F89AE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 847DD5E12CBC34E5000F89AE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 847DD5E32CBC34E5000F89AE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + MACOSX_DEPLOYMENT_TARGET = 13; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 847DD5E42CBC34E5000F89AE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + MACOSX_DEPLOYMENT_TARGET = 13; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 847DD5D62CBC34E5000F89AE /* Build configuration list for PBXProject "UTSChatAdapter" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 847DD5E02CBC34E5000F89AE /* Debug */, + 847DD5E12CBC34E5000F89AE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 847DD5E22CBC34E5000F89AE /* Build configuration list for PBXNativeTarget "UTSChatAdapter" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 847DD5E32CBC34E5000F89AE /* Debug */, + 847DD5E42CBC34E5000F89AE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 84E1054C2CBEEC9800244C8F /* XCLocalSwiftPackageReference "../../ably-chat-swift" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../ably-chat-swift"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 84E1054D2CBEEC9800244C8F /* AblyChat */ = { + isa = XCSwiftPackageProductDependency; + productName = AblyChat; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 847DD5D32CBC34E5000F89AE /* Project object */; +} diff --git a/UTSChatAdapter/UTSChatAdapter.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/UTSChatAdapter/UTSChatAdapter.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/UTSChatAdapter/UTSChatAdapter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/UTSChatAdapter/UTSChatAdapter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..605b7911 --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,51 @@ +{ + "originHash" : "901dc16707a50f161b9ca480d3ab40ad69e36f382e77f0862a0e8f857b7206be", + "pins" : [ + { + "identity" : "ably-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa", + "state" : { + "branch" : "main", + "revision" : "4856ba6a423788902a6ef680793e7f404ceb4a51" + } + }, + { + "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/UTSChatAdapter/ChatAdapter.swift b/UTSChatAdapter/UTSChatAdapter/ChatAdapter.swift new file mode 100644 index 00000000..8c9677de --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter/ChatAdapter.swift @@ -0,0 +1,58 @@ +import Ably +import AblyChat + +/** + * Unified Test Suite adapter for swift Chat SDK + */ +class 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 idToPaginatedResult = [String: any PaginatedResult]() + 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 + } + + func handleRpcCall(rpcParams: JSON) async throws -> String? { + guard let method = rpcParams["method"] as? String else { + print("Method not found.") + return nil + } + + switch method { + + // GENERATED CONTENT BEGIN + // ... + // GENERATED CONTENT END + + default: + return jsonRpcResult(rpcParams.requestId, "{\"refId\":\"Unknown method provided.\"}") + } + } +} diff --git a/UTSChatAdapter/UTSChatAdapter/ChatAdapterGenerator.swift b/UTSChatAdapter/UTSChatAdapter/ChatAdapterGenerator.swift new file mode 100644 index 00000000..b390169d --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter/ChatAdapterGenerator.swift @@ -0,0 +1,250 @@ +import Foundation + +/** + * Unified Test Suite adapter generator for swift Chat SDK + */ +class ChatAdapterGenerator { + + var generatedFileContent = "// GENERATED CONTENT BEGIN\n\n" + + func generate() { + 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: true) + } + } + + 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 { + let argSchema = $0.value as! JSON + return " let \($0.key.bigD()) = \(altTypeName(argSchema.type!)).from(rpcParams.methodArgs[\"\($0.key)\"])" + } + let callParams = methodArgs.map { "\($0.key.bigD()): \($0.key.bigD())" }.joined(separator: ", ") + generatedFileContent += + """ + case "\(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 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 { + let argSchema = $0.value as! JSON + return " let \($0.key.bigD()) = \(altTypeName(argSchema.type!)).from(rpcParams.methodArgs[\"\($0.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 "\(objectType).\(methodName)":\n + """ + if !paramsDeclarations.isEmpty { + generatedFileContent += paramsDeclarations.joined(separator: "\n") + "\n" + } + generatedFileContent += + """ + guard let \(altTypeName(objectType).firstLowercased())Ref = idTo\(altTypeName(objectType))[rpcParams.refId] else { + print("\(altTypeName(objectType)) with `refId == \\(rpcParams.refId)` doesn't exist.") + return nil + } + \(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 jsonRpcResult(rpcParams.requestId, "{\\"response\\": \\"\\(\(resultType.firstLowercased()))\\"}") + \n + """ + } else if methodSchema.result.isSerializable { + generatedFileContent += + """ + return jsonRpcResult(rpcParams.requestId, "{\\"response\\": \\(jsonString(\(resultType.firstLowercased())))}") + \n + """ + } else { + generatedFileContent += + """ + let resultRefId = generateId() + idTo\(altTypeName(methodSchema.result.type!))[resultRefId] = \(resultType.firstLowercased()) + return jsonRpcResult(rpcParams.requestId, "{\\"refId\\":\\"\\(resultRefId)\\"}") + \n + """ + } + } + else { + generatedFileContent += + """ + return 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 "\(implPath)": + guard let \(altTypeName(objectType).firstLowercased())Ref = idTo\(altTypeName(objectType))[rpcParams.refId] else { + print("\(altTypeName(objectType)) with `refId == \\(rpcParams.refId)` doesn't exist.") + return nil + } + let \(fieldName.bigD()) = \(altTypeName(objectType).firstLowercased())Ref.\(fieldName.bigD()) // \(fieldType)\n + """ + + if fieldSchema.isSerializable { + if isJsonPrimitiveType(fieldType) { + generatedFileContent += + """ + return jsonRpcResult(rpcParams.requestId, "{\\"response\\": \\"\\(\(fieldName.bigD()))\\"}") + \n + """ + } else { + generatedFileContent += + """ + return jsonRpcResult(rpcParams.requestId, "{\\"response\\": \\(jsonString(\(fieldName.bigD())))}") + \n + """ + } + } else { + generatedFileContent += + """ + let fieldRefId = generateId() + idTo\(fieldType)[fieldRefId] = \(fieldName.bigD()) + return 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 { + let argName = $0.key + let argType = ($0.value as! JSON).type! + if argType != "callback" { + return (declaration: " let \(argName.bigD()) = \(altTypeName(argType)).from(rpcParams.methodArgs[\"\(argName)\"])", + usage: "\(argName.bigD()): \(argName.bigD())") + } else { + return nil + } + } + let callParams = (paramsSignatures.map { $0.usage } + ["bufferingPolicy: .unbounded"]).joined(separator: ", ") + generatedFileContent += + """ + case "\(objectType).\(methodName)":\n + """ + if !paramsSignatures.isEmpty { + generatedFileContent += paramsSignatures.map { $0.declaration }.joined(separator: "\n") + "\n" + } + generatedFileContent += + """ + guard let \(altTypeName(objectType).firstLowercased())Ref = idTo\(altTypeName(objectType))[rpcParams.refId] else { + print("\(altTypeName(objectType)) with `refId == \\(rpcParams.refId)` doesn't exist.") + return nil + } + 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 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 { // code below simplifies it to just one callback parameter + let argName = $0.key + let argType = ($0.value as! JSON).type! + let isOptional = ($0.value as! JSON).isOptional + return (declaration: "\(altTypeName(argType))" + (isOptional ? "?" : ""), usage: "\(argName.bigD())") + } + let paramsDeclaration = paramsSignatures.map { $0.declaration }.joined(separator: ", ") + let paramsUsage = paramsSignatures.map { $0.usage }.joined(separator: ", ") + var result = + """ + let callback: (\(paramsDeclaration)) -> \(altTypeName(callbackSchema.result.type!)) = {\n + """ + if (callbackArgs.first?.value as? JSON)?.isOptional ?? false { + result += + """ + if let param = $0 { + self.webSocket.send(text: jsonRpcCallback(rpcParams.callbackId, "\\(jsonString(param))")) + } else { + self.webSocket.send(text: jsonRpcCallback(rpcParams.callbackId, "{}")) + }\n + """ + } else { + result += + """ + self.webSocket.send(text: jsonRpcCallback(rpcParams.callbackId, "\\(jsonString($0))"))\n + """ + } + result += + """ + } + Task { + for await \(paramsUsage) in subscription { + callback(\(paramsUsage)) + } + }\n + """ + return result + } +} diff --git a/UTSChatAdapter/UTSChatAdapter/NanoID.swift b/UTSChatAdapter/UTSChatAdapter/NanoID.swift new file mode 100644 index 00000000..55a184f6 --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter/NanoID.swift @@ -0,0 +1,125 @@ +// +// 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 { + return 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 { + return 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 { + return NanoIDHelper.generate(from: NanoID.defaultAphabet, of: size) + } +} + +fileprivate 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.. 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: + return self.chars() + case .urlSafe: + return ("\(NanoIDAlphabet.uppercasedLatinLetters.chars())\(NanoIDAlphabet.lowercasedLatinLetters.chars())\(NanoIDAlphabet.numbers.chars())~_") + } + } + + private func chars() -> String { + switch self { + case .uppercasedLatinLetters: + return "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + case .lowercasedLatinLetters: + return "abcdefghijklmnopqrstuvwxyz" + case .numbers: + return "1234567890" + default: + return "" + } + } +} diff --git a/UTSChatAdapter/UTSChatAdapter/Schema+Adjustments.swift b/UTSChatAdapter/UTSChatAdapter/Schema+Adjustments.swift new file mode 100644 index 00000000..ce066b1d --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter/Schema+Adjustments.swift @@ -0,0 +1,138 @@ +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 { } + +fileprivate let altTypesMap = [ + "void": "Void", + "PresenceData": "\(PresenceDataWrapper.self)", + "MessageSubscriptionResponse": "\(MessageSubscription.self)", + "OnConnectionStatusChangeResponse": "OnConnectionStatusChange", + "OccupancySubscriptionResponse": "OccupancySubscription", + "RoomReactionsSubscriptionResponse": "RoomReactionsSubscription", + "OnDiscontinuitySubscriptionResponse": "OnDiscontinuitySubscription", + "OnRoomStatusChangeResponse": "OnRoomStatusChange", + "TypingSubscriptionResponse": "TypingSubscription", + "PresenceSubscriptionResponse": "PresenceSubscription", + "MessageEventPayload": "\(Message.self)" +] + +fileprivate let jsonPrimitiveTypesMap = [ + "string": "\(String.self)", + "boolean": "\(Bool.self)", + "number": "\(Int.self)" +] + +fileprivate 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 Message { + public func before(message: Message) throws -> Bool { + try isBefore(message) + } + + public func after(message: Message) throws -> Bool { + try isAfter(message) + } + + public func equal(message: Message) throws -> Bool { + try isEqual(message) + } +} + +extension Messages { + func send(options: SendMessageParams) async throws -> Message { + try await send(params: options) + } +} + +extension String { + func bigD() -> String { + replacingOccurrences(of: "Id", with: "ID") + } +} + +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]) + } +} + +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 + "ConnectionStatus#error", // optional + "Presence#channel", // not implemented + "RoomStatus#error", // not available directly (via lifecycle object) + "Message#createdAt", // optional + "Presence.subscribe_eventsAndListener", // impossible to infer param type from `string` + + "ChatClient.addReactAgent", + + "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", + ] +} diff --git a/UTSChatAdapter/UTSChatAdapter/Schema.swift b/UTSChatAdapter/UTSChatAdapter/Schema.swift new file mode 100644 index 00000000..ccbd5fda --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter/Schema.swift @@ -0,0 +1,994 @@ +import Foundation + +struct Schema { + static var json: [JSON] = { + do { + return try JSONSerialization.jsonObject(with: Self.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": { + "options": { + "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/UTSChatAdapter/Utils.swift b/UTSChatAdapter/UTSChatAdapter/Utils.swift new file mode 100644 index 00000000..da5f7c97 --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter/Utils.swift @@ -0,0 +1,276 @@ +import Ably +import AblyChat + +typealias JSON = [String: Any] + +extension JSON { + var name: String? { self["name"] as? String } + var type: String? { self["type"] as? String } + var args: JSON? { self["args"] as? JSON } + var params: JSON? { self["params"] 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 } + + var methodArgs: JSON { params!.args! } + var refId: String { params!["refId"] as! String } + var callbackId: String { self["callbackId"] as! String } + var requestId: String { self["id"] as! String } +} + +func jsonRpcResult(_ id: String, _ result: String) -> String { + "{\"jsonrpc\":\"2.0\",\"id\":\"\(id)\",\"result\":\(result)}" +} + +func jsonRpcCallback(_ callbackId: String, _ message: String) -> String { + "{\"jsonrpc\":\"2.0\",\"id\":\"\(UUID().uuidString)\",\"method\":\"callback\",\"params\":{\"callbackId\":\"\(callbackId)\",\"args\":[\(message)]}}" +} + +func jsonFromWebSocketMessage(_ message: URLSessionWebSocketTask.Message) -> JSON? { + var json: [String: Any]? + + do { + switch message { + case .data(let data): + json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + case .string(let string): + json = try JSONSerialization.jsonObject(with: string.data(using: .utf8)!) as? JSON + @unknown default: + print("Unknown Websocket data.") + return nil + } + } catch { + print("Error parsing JSON: \(error)") + return nil + } + + guard let json else { + print("Data provided is not a valid JSON dictionary.") + return nil + } + + print("Received: \(json)") + + if json["method"] == nil || json["jsonrpc"] == nil { + print("No valid fields in the provided JSON were found.") + return nil + } + return json +} + +func generateId() -> String { NanoID.new() } + +protocol JsonSerialisable { + func jsonString() -> String +} + +extension CommandLine { + static func hasParam(_ name: String) -> Bool { + arguments.contains(where: { $0 == name }) + } +} + +extension StringProtocol { + func firstLowercased() -> String { prefix(1).lowercased() + dropFirst() } + func firstUppercased() -> String { prefix(1).uppercased() + dropFirst() } +} + +extension JSON { + func sortedByKey() -> Array { + sorted { + $0.key > $1.key + } + } +} + +extension ClientOptions: JsonSerialisable { + func jsonString() -> String { + "{\"logLevel\": \"\(logLevel ?? .info)\"}" + } +} + +extension RoomOptions: JsonSerialisable { + func jsonString() -> String { + fatalError("Not implemented") + } +} + +extension ErrorInfo: JsonSerialisable { + func jsonString() -> String { + "{\"error\": \"\(description())\"}" + } +} + +extension OccupancyEvent: JsonSerialisable { + func jsonString() -> String { + fatalError("Not implemented") + } +} + +extension Message: JsonSerialisable { + func jsonString() -> String { + fatalError("Not implemented") + } +} + +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) -> String { + if value is JsonSerialisable { + return (value as! JsonSerialisable).jsonString() + } + else if value is any Sequence { + return "[" + (value as! any Sequence).map { $0.jsonString() }.joined(separator: ",\n") + "]" + } + fatalError("Not implemented") +} + +extension Message { + static func from(_ value: Any?) -> Self { + fatalError("Not implemented") + } +} + +extension QueryOptions { + static func from(_ value: Any?) -> Self { + fatalError("Not implemented") + } +} + +extension SendMessageParams { + static func from(_ value: Any?) -> Self { + fatalError("Not implemented") + } +} + +extension String { + static func from(_ value: Any?) -> Self { + value as! String + } +} + +extension RealtimePresenceParams { + static func from(_ value: Any?) -> Self { + fatalError("Not implemented") + } +} + +extension SendReactionParams { + static func from(_ value: 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() + presence.enter = (json["presence"] as! JSON)["enter"] as? Bool ?? false + presence.enter = (json["presence"] as! JSON)["subscribe"] as? Bool ?? false + var typing = TypingOptions() + if let timeoutMs = (json["typing"] as! JSON)["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": return .trace + case "debug": return .debug + case "info": return .info + case "warn": return .warn + case "error": return .error + case "silent": return .silent + default: return .debug + } + } +} + +extension ClientOptions { + static func from(_ value: Any?) -> Self { + guard let json = value as? JSON else { + fatalError("Not compatible data for creating ClientOptions. Expected JSON.") + } + var options = ClientOptions() + options.logLevel = .from(string: json["logLevel"] as! String) + 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 + options.key = json["key"] as? String + options.logLevel = .init(rawValue: json["logLevel"] as! UInt) ?? .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 { + fatalError("Not implemented") + } +} + +extension PresenceEventType { + static func from(_ value: Any?) -> Self { + fatalError("Not implemented") + } +} diff --git a/UTSChatAdapter/UTSChatAdapter/WebSocketWrapper.swift b/UTSChatAdapter/UTSChatAdapter/WebSocketWrapper.swift new file mode 100644 index 00000000..9d2495b8 --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter/WebSocketWrapper.swift @@ -0,0 +1,41 @@ +import Foundation + +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")! + + self.webSocket = session.webSocketTask(with: url) + self.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 + } + } + } + + func send(text: String) { + print("Send: \(text)") + webSocket.send(URLSessionWebSocketTask.Message.string(text)) { error in + print(error == nil ? "Message sent" : "Error sending message: \(error!)") + } + } + + // MARK: URLSessionWebSocketDelegate + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + print("Connected to server") + send(text: "{\"role\":\"ADAPTER\"}") + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + print("Disconnected from server") + } +} diff --git a/UTSChatAdapter/UTSChatAdapter/main.swift b/UTSChatAdapter/UTSChatAdapter/main.swift new file mode 100644 index 00000000..a9c71d3e --- /dev/null +++ b/UTSChatAdapter/UTSChatAdapter/main.swift @@ -0,0 +1,39 @@ +import Foundation + +func serve() async throws { + let webSocket = WebSocketWrapper() + let adapter = ChatAdapter(webSocket: webSocket) + + try await webSocket.start { message in + var result: String? = nil + + guard let params = jsonFromWebSocketMessage(message) else { + print("Websocket message can't be processed.") + return + } + result = try await adapter.handleRpcCall(rpcParams: params) + + if result != nil { + webSocket.send(text: result!) + } + } +} + +if CommandLine.hasParam("generate") { + print("Generating swift code...") + ChatAdapterGenerator().generate() +} +else { + let task = Task { + do { + try await serve() + } catch { + print("Exiting due to fatal error: \(error)") // TODO: replace with logger + } + } + print("Waiting adapter to connect...") + print("Type 0 to exit:") + if readLine() == "0" { + task.cancel() + } +}