diff --git a/Package.swift b/Package.swift index 4e545bc..85a524d 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "Yorkie", - platforms: [.iOS(.v13)], + platforms: [.iOS(.v13), .macOS(.v10_15)], products: [ .library( name: "Yorkie", diff --git a/Sources/Core/Client.swift b/Sources/Core/Client.swift index db2f172..5526a40 100644 --- a/Sources/Core/Client.swift +++ b/Sources/Core/Client.swift @@ -15,3 +15,317 @@ */ import Foundation +import GRPC +import NIO + +/** + * `ClientStatus` represents the status of the client. + * @public + */ +enum ClientStatus: String { + /** + * Deactivated means that the client is not registered to the server. + */ + case deactivated + /** + * Activated means that the client is registered to the server. + * So, the client can sync documents with the server. + */ + case activated +} + +/** + * `StreamConnectionStatus` is stream connection status types + * @public + */ +enum StreamConnectionStatus { + /** + * stream connected + */ + case connected + /** + * stream disconnected + */ + case disconnected +} + +/** + * `DocumentSyncResultType` is document sync result types + * @public + */ +enum DocumentSyncResultType: String { + /** + * type when Document synced. + */ + case synced + /** + * type when Document sync failed. + */ + case syncFailed = "sync-failed" +} + +/** + * `ClientEventType` is client event types + * @public + */ +enum ClientEventType: String { + /** + * client event type when status changed. + */ + case statusChanged = "status-changed" + /** + * client event type when documents changed. + */ + case documentsChanged = "documents-changed" + /** + * client event type when peers changed. + */ + case peersChanged = "peers-changed" + /** + * client event type when stream connection changed. + */ + case streamConnectionStatusChanged = "stream-connection-status-changed" + /** + * client event type when document synced. + */ + case documentSynced = "document-synced" +} + +/** + * @internal + */ +protocol BaseClientEvent { + var type: ClientEventType { get } +} + +/** + * `StatusChangedEvent` is an event that occurs when the Client's state changes. + * + * @public + */ +struct StatusChangedEvent: BaseClientEvent { + /** + * enum {@link ClientEventType}.StatusChanged + */ + var type: ClientEventType = .statusChanged + /** + * `DocumentsChangedEvent` value + */ + var value: ClientStatus +} + +/** + * `DocumentsChangedEvent` is an event that occurs when documents attached to + * the client changes. + * + * @public + */ +struct DocumentsChangedEvent: BaseClientEvent { + /** + * enum {@link ClientEventType}.DocumentsChangedEvent + */ + var type: ClientEventType = .documentsChanged + /** + * `DocumentsChangedEvent` value + */ + var value: [String] +} + +/** + * `StreamConnectionStatusChangedEvent` is an event that occurs when + * the client's stream connection state changes. + * + * @public + */ +struct StreamConnectionStatusChangedEvent: BaseClientEvent { + /** + * `StreamConnectionStatusChangedEvent` type + * enum {@link ClientEventType}.StreamConnectionStatusChangedEvent + */ + var type: ClientEventType = .streamConnectionStatusChanged + /** + * `StreamConnectionStatusChangedEvent` value + */ + var value: StreamConnectionStatus +} + +/** + * `DocumentSyncedEvent` is an event that occurs when documents + * attached to the client are synced. + * + * @public + */ +struct DocumentSyncedEvent: BaseClientEvent { + /** + * `DocumentSyncedEvent` type + * enum {@link ClientEventType}.DocumentSyncedEvent + */ + var type: ClientEventType = .documentSynced + /** + * `DocumentSyncedEvent` value + */ + var value: DocumentSyncResultType +} + +/** + * `PresenceInfo` is presence information of this client. + * + * @public + */ +struct PresenceInfo

{ + var clock: Int + var data: P +} + +/** + * `ClientOptions` are user-settable options used when defining clients. + * + * @public + */ +struct ClientOptions { + /** + * `key` is the client key. It is used to identify the client. + * If not set, a random key is generated. + */ + var key: String? + + /** + * `apiKey` is the API key of the project. It is used to identify the project. + * If not set, API key of the default project is used. + */ + var apiKey: String? + + /** + * `token` is the authentication token of this client. It is used to identify + * the user of the client. + */ + var token: String? + + /** + * `syncLoopDuration` is the duration of the sync loop. After each sync loop, + * the client waits for the duration to next sync. The default value is + * `50`(ms). + */ + var syncLoopDuration: Int + + /** + * `reconnectStreamDelay` is the delay of the reconnect stream. If the stream + * is disconnected, the client waits for the delay to reconnect the stream. The + * default value is `1000`(ms). + */ + var reconnectStreamDelay: Int + + init(key: String? = nil, apiKey: String? = nil, token: String? = nil, syncLoopDuration: Int = 50, reconnectStreamDelay: Int = 1000) { + self.key = key + self.apiKey = apiKey + self.token = token + self.syncLoopDuration = syncLoopDuration + self.reconnectStreamDelay = reconnectStreamDelay + } +} + +struct RPCAddress { + let host: String + let port: Int +} + +/** + * `Client` is a normal client that can communicate with the server. + * It has documents and sends changes of the documents in local + * to the server to synchronize with other replicas in remote. + * + * @public + */ +final class Client { + private(set) var id: Data? // To be ActorID + let key: String + private(set) var status: ClientStatus + + public var isActive: Bool { + self.status == .activated + } + + private let syncLoopDuration: Int + private let reconnectStreamDelay: Int + private let rpcClient: Yorkie_V1_YorkieServiceAsyncClient + private let group: EventLoopGroup + + /** + * @param rpcAddr - the address of the RPC server. + * @param opts - the options of the client. + */ + init(rpcAddress: RPCAddress, options: ClientOptions) throws { + self.key = options.key ?? UUID().uuidString + self.status = .deactivated + self.syncLoopDuration = options.syncLoopDuration + self.reconnectStreamDelay = options.reconnectStreamDelay + + self.group = PlatformSupport.makeEventLoopGroup(loopCount: 1) // EventLoopGroup helpers + + let channel: GRPCChannel + do { + channel = try GRPCChannelPool.with(target: .host(rpcAddress.host, port: rpcAddress.port), + transportSecurity: .plaintext, + eventLoopGroup: self.group) + } catch { + Logger.error("Failed to initialize client", error: error) + throw error + } + + self.rpcClient = Yorkie_V1_YorkieServiceAsyncClient(channel: channel) + } + + deinit { + try? self.group.syncShutdownGracefully() + } + + /** + * `ativate` activates this client. That is, it register itself to the server + * and receives a unique ID from the server. The given ID is used to + * distinguish different clients. + */ + func activate() async throws { + guard self.isActive == false else { + return + } + + var activateRequest = Yorkie_V1_ActivateClientRequest() + activateRequest.clientKey = self.key + + let activateResponse: Yorkie_V1_ActivateClientResponse + do { + activateResponse = try await self.rpcClient.activateClient(activateRequest, callOptions: nil) + } catch { + Logger.error("Failed to request activate client(\(self.key)).", error: error) + throw error + } + + self.id = activateResponse.clientID + + self.status = .activated + + Logger.debug("Client(\(self.key)) activated") + } + + /** + * `deactivate` deactivates this client. + */ + func deactivate() async throws { + guard self.status == .activated, let clientId = self.id else { + return + } + + var deactivateRequest = Yorkie_V1_DeactivateClientRequest() + deactivateRequest.clientID = clientId + + do { + _ = try await self.rpcClient.deactivateClient(deactivateRequest) + } catch { + Logger.error("Failed to request deactivate client(\(self.key)).", error: error) + throw error + } + + self.status = .deactivated + Logger.info("Client(\(self.key) deactivated.") + } +} diff --git a/Sources/Core/Logger.swift b/Sources/Core/Logger.swift new file mode 100644 index 0000000..14abdcc --- /dev/null +++ b/Sources/Core/Logger.swift @@ -0,0 +1,48 @@ +/* + * Copyright 2022 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +enum LogLevel: String { + case debug = "DEBUG" + case info = "INFO" + case warn = "WARN" + case error = "ERROR" +} + +enum Logger { + static func debug(_ message: String, error: Error? = nil, filename: String = #file, function: String = #function, line: Int = #line) { + self.log(level: .debug, message, error: error, filename: filename, function: function, line: line) + } + + static func info(_ message: String, error: Error? = nil, filename: String = #file, function: String = #function, line: Int = #line) { + self.log(level: .info, message, error: error, filename: filename, function: function, line: line) + } + + static func warn(_ message: String, error: Error? = nil, filename: String = #file, function: String = #function, line: Int = #line) { + self.log(level: .warn, message, error: error, filename: filename, function: function, line: line) + } + + static func error(_ message: String, error: Error? = nil, filename: String = #file, function: String = #function, line: Int = #line) { + self.log(level: .error, message, error: error, filename: filename, function: function, line: line) + } + + static func log(level: LogLevel, _ message: String, error: Error? = nil, filename: String = #file, function: String = #function, line: Int = #line) { + let file = URL(fileURLWithPath: filename).lastPathComponent + let log = message + (error?.localizedDescription ?? "") + print("[\(level.rawValue)][\(file):\(line)] \(function) - \(log)") + } +} diff --git a/Tests/Core/ClientTests.swift b/Tests/Core/ClientTests.swift new file mode 100644 index 0000000..141ee6d --- /dev/null +++ b/Tests/Core/ClientTests.swift @@ -0,0 +1,71 @@ +/* + * Copyright 2022 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import XCTest +@testable import Yorkie + +class ClientTests: XCTestCase { + func test_activate_and_deactivate_client_with_key() async throws { + let clientId = UUID().uuidString + let rpcAddress = RPCAddress(host: "localhost", port: 8080) + + let options = ClientOptions(key: clientId) + let target: Client + do { + target = try Client(rpcAddress: rpcAddress, options: options) + } catch { + XCTFail(error.localizedDescription) + return + } + + do { + try await target.activate() + } catch { + XCTFail(error.localizedDescription) + } + + XCTAssertTrue(target.isActive) + XCTAssertEqual(target.key, clientId) + + do { + try await target.deactivate() + } catch { + XCTFail(error.localizedDescription) + } + XCTAssertFalse(target.isActive) + } + + func test_activate_and_deactivate_client_without_key() async throws { + let rpcAddress = RPCAddress(host: "localhost", port: 8080) + + let options = ClientOptions() + let target: Client + do { + target = try Client(rpcAddress: rpcAddress, options: options) + } catch { + XCTFail(error.localizedDescription) + return + } + + try await target.activate() + + XCTAssertTrue(target.isActive) + XCTAssertFalse(target.key.isEmpty) + + try await target.deactivate() + XCTAssertFalse(target.isActive) + } +} diff --git a/Yorkie.xcodeproj/project.pbxproj b/Yorkie.xcodeproj/project.pbxproj index 1443028..638df33 100644 --- a/Yorkie.xcodeproj/project.pbxproj +++ b/Yorkie.xcodeproj/project.pbxproj @@ -10,7 +10,9 @@ 96DA808C28C5B7B400E2C1DA /* Yorkie.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 96DA808228C5B7B400E2C1DA /* Yorkie.framework */; }; 96DA809128C5B7B400E2C1DA /* GRPCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DA809028C5B7B400E2C1DA /* GRPCTests.swift */; }; CE6071E528C5D7EE00A8783E /* CONTRIBUTING.md in Resources */ = {isa = PBXBuildFile; fileRef = CE6071E428C5D7EE00A8783E /* CONTRIBUTING.md */; }; - CE8C22ED28C9E7F200432DE5 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C22EC28C9E7F200432DE5 /* Client.swift */; }; + CE8C230528C9F1BD00432DE5 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C230428C9F1BD00432DE5 /* Client.swift */; }; + CE8C230728D1514900432DE5 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C230628D1514900432DE5 /* Logger.swift */; }; + CE8C230B28D15FF200432DE5 /* ClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C230928D15F5A00432DE5 /* ClientTests.swift */; }; CE8C22EF28C9E85900432DE5 /* Change.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C22EE28C9E85900432DE5 /* Change.swift */; }; CE8C22F128C9E86A00432DE5 /* Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C22F028C9E86A00432DE5 /* Root.swift */; }; CE8C22F328C9E87800432DE5 /* Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C22F228C9E87800432DE5 /* Object.swift */; }; @@ -42,7 +44,9 @@ 96DA809028C5B7B400E2C1DA /* GRPCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRPCTests.swift; sourceTree = ""; }; 96DA809228C5B7B400E2C1DA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CE6071E428C5D7EE00A8783E /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; - CE8C22EC28C9E7F200432DE5 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; + CE8C230428C9F1BD00432DE5 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; + CE8C230628D1514900432DE5 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Logger.swift; path = Sources/Core/Logger.swift; sourceTree = SOURCE_ROOT; }; + CE8C230928D15F5A00432DE5 /* ClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientTests.swift; sourceTree = ""; }; CE8C22EE28C9E85900432DE5 /* Change.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Change.swift; sourceTree = ""; }; CE8C22F028C9E86A00432DE5 /* Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Root.swift; sourceTree = ""; }; CE8C22F228C9E87800432DE5 /* Object.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Object.swift; sourceTree = ""; }; @@ -113,6 +117,7 @@ 96DA808F28C5B7B400E2C1DA /* Tests */ = { isa = PBXGroup; children = ( + CE8C230828D15F5200432DE5 /* Core */, CECCCB8128C96C8500544204 /* TestUtils */, CE6071EB28C5ECA900A8783E /* API */, 96DA809228C5B7B400E2C1DA /* Info.plist */, @@ -146,7 +151,7 @@ CE8C22E328C9E53400432DE5 /* Core */ = { isa = PBXGroup; children = ( - CE8C22EC28C9E7F200432DE5 /* Client.swift */, + CE8C230428C9F1BD00432DE5 /* Client.swift */, ); path = Core; sourceTree = ""; @@ -171,6 +176,14 @@ path = Util; sourceTree = ""; }; + CE8C230828D15F5200432DE5 /* Core */ = { + isa = PBXGroup; + children = ( + CE8C230928D15F5A00432DE5 /* ClientTests.swift */, + ); + path = Core; + sourceTree = ""; + }; CE8C22E628C9E55300432DE5 /* Change */ = { isa = PBXGroup; children = ( @@ -405,14 +418,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CE8C230728D1514900432DE5 /* Logger.swift in Sources */, CEEB17E428C84D26004988DD /* resources.pb.swift in Sources */, CE8C22F928C9E8CA00432DE5 /* Heap.swift in Sources */, CE8C22EF28C9E85900432DE5 /* Change.swift in Sources */, CEEB17E328C84D26004988DD /* yorkie.pb.swift in Sources */, + CE8C230528C9F1BD00432DE5 /* Client.swift in Sources */, CE8C22F528C9E88500432DE5 /* Operation.swift in Sources */, CE8C22F728C9E89100432DE5 /* Ticket.swift in Sources */, CE8C22F328C9E87800432DE5 /* Object.swift in Sources */, - CE8C22ED28C9E7F200432DE5 /* Client.swift in Sources */, CEEB17E528C84D26004988DD /* yorkie.grpc.swift in Sources */, CE8C22F128C9E86A00432DE5 /* Root.swift in Sources */, ); @@ -423,6 +437,7 @@ buildActionMask = 2147483647; files = ( CECCCB8428C96CD600544204 /* XCTestCase+Extension.swift in Sources */, + CE8C230B28D15FF200432DE5 /* ClientTests.swift in Sources */, 96DA809128C5B7B400E2C1DA /* GRPCTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;