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 = "