From 2f81e398b17798719df7d4a156b614957c2bb80e Mon Sep 17 00:00:00 2001 From: ened Date: Mon, 21 Aug 2023 23:13:17 +0900 Subject: [PATCH] Implement subscribe/unsubscribe for ContractPriceWebSocket --- KissMe/Sources/Common/GeneralError.swift | 2 + KissMe/Sources/Common/WebSocket.swift | 71 +++++++-- .../Domestic.AskingPriceWebSocket.swift | 5 +- .../Domestic.ContractNoticeWebSocket.swift | 3 - .../Domestic.ContractPriceWebSocket.swift | 142 ++++++++++++++---- .../Domestic/Stock/DomesticStockResult.swift | 2 +- KissMe/Sources/KissProfile.swift | 30 +++- KissMe/Sources/Login/Login.swift | 12 +- KissMeConsole/Sources/main.swift | 14 +- 9 files changed, 224 insertions(+), 57 deletions(-) diff --git a/KissMe/Sources/Common/GeneralError.swift b/KissMe/Sources/Common/GeneralError.swift index 521ee9b..e04fe49 100644 --- a/KissMe/Sources/Common/GeneralError.swift +++ b/KissMe/Sources/Common/GeneralError.swift @@ -13,6 +13,8 @@ public enum GeneralError: Error { case invalidInstance case invalidAccessToken case invalidAccountNo + case invalidWebSocket + case webSocketError(_ code: String, _ messageCode: String, _ message: String) case unsupportedQueryAtMockServer case emptyData(String) case cannotReadFile diff --git a/KissMe/Sources/Common/WebSocket.swift b/KissMe/Sources/Common/WebSocket.swift index bab782e..6b9cb8c 100644 --- a/KissMe/Sources/Common/WebSocket.swift +++ b/KissMe/Sources/Common/WebSocket.swift @@ -9,19 +9,15 @@ import Foundation public protocol WebSocket { - associatedtype KResult: Decodable - var isMockAvailable: Bool { get } var domain: String { get } var url: String { get } var userAgent: String { get } - var header: [String: String?] { get } - var body: [String: Any] { get } - var result: KResult? { get set } var timeout: TimeInterval { get } var session: URLSession { get } var socket: URLSessionWebSocketTask? { get set } + var credential: WebSocketCredential { get } } @@ -45,25 +41,27 @@ extension WebSocket { URL(string: domain + url) } - public mutating func connect() async throws { - return try await withUnsafeThrowingContinuation { continuation in - + /* + mutating func connect() async throws -> URLSessionWebSocketTask { + return try await withUnsafeThrowingContinuation { continuation in guard let queryUrl = queryUrl else { continuation.resume(throwing: QueryError.invalidUrl) return } - socket = session.webSocketTask(with: queryUrl) - socket?.resume() + let socket = session.webSocketTask(with: queryUrl) + socket.resume() + self.socket = socket - continuation.resume(returning: ()) + continuation.resume(returning: socket) } } - public mutating func disconnect() { + mutating func disconnect() { socket?.cancel(with: .normalClosure, reason: nil) socket = nil } + */ } @@ -83,6 +81,41 @@ extension AuthWebSocket { "ws://ops.koreainvestment.com:31000": "ws://ops.koreainvestment.com:21000" } + + public mutating func connect() async throws { + return try await withUnsafeThrowingContinuation { continuation in + guard let queryUrl = queryUrl else { + continuation.resume(throwing: QueryError.invalidUrl) + return + } + + let socket = session.webSocketTask(with: queryUrl) + socket.resume() + self.socket = socket + + continuation.resume(returning: ()) + } + } + + public mutating func disconnect() { + socket?.cancel(with: .normalClosure, reason: nil) + socket = nil + } + + func receive() async throws -> T where T: Decodable { + guard let socket = socket else { + throw GeneralError.invalidWebSocket + } + + let message = try await socket.receive() + guard let data = message.data else { + throw GeneralError.emptyData("subscribe()") + } + print(message) + + let r = try JSONDecoder().decode(T.self, from: data) + return r + } } @@ -104,3 +137,17 @@ public struct KissWebSocketCredential: WebSocketCredential, Codable { self.approvalKey = approvalKey } } + + +extension URLSessionWebSocketTask.Message { + var data: Data? { + switch self { + case .string(let str): + return Data(str.utf8) + case .data(let data): + return data + @unknown default: + return nil + } + } +} diff --git a/KissMe/Sources/Domestic/Realtime/Domestic.AskingPriceWebSocket.swift b/KissMe/Sources/Domestic/Realtime/Domestic.AskingPriceWebSocket.swift index db827a6..8bfbfad 100644 --- a/KissMe/Sources/Domestic/Realtime/Domestic.AskingPriceWebSocket.swift +++ b/KissMe/Sources/Domestic/Realtime/Domestic.AskingPriceWebSocket.swift @@ -14,9 +14,7 @@ extension Domestic { /// 국내주식 실시간호가[실시간-004] /// - public struct AskingPriceWebSocket: AuthWebSocket { - public typealias KResult = String - + public class AskingPriceWebSocket: AuthWebSocket { public var url: String { "/tryitout/H0STASP0" } @@ -36,7 +34,6 @@ extension Domestic { } public var socket: URLSessionWebSocketTask? - public var result: KResult? = nil public var credential: WebSocketCredential var event: Event! let productCode: String diff --git a/KissMe/Sources/Domestic/Realtime/Domestic.ContractNoticeWebSocket.swift b/KissMe/Sources/Domestic/Realtime/Domestic.ContractNoticeWebSocket.swift index 7af8c1f..786faa6 100644 --- a/KissMe/Sources/Domestic/Realtime/Domestic.ContractNoticeWebSocket.swift +++ b/KissMe/Sources/Domestic/Realtime/Domestic.ContractNoticeWebSocket.swift @@ -15,8 +15,6 @@ extension Domestic { /// 국내주식 실시간체결통보[실시간-005] /// public struct ContractNoticeWebSocket: AuthWebSocket { - public typealias KResult = String - public var url: String { "/tryitout/H0STCNI0" } @@ -36,7 +34,6 @@ extension Domestic { } public var socket: URLSessionWebSocketTask? - public var result: KResult? = nil public var credential: WebSocketCredential var event: Event! let htsID: String diff --git a/KissMe/Sources/Domestic/Realtime/Domestic.ContractPriceWebSocket.swift b/KissMe/Sources/Domestic/Realtime/Domestic.ContractPriceWebSocket.swift index 443e2a5..b6c4136 100644 --- a/KissMe/Sources/Domestic/Realtime/Domestic.ContractPriceWebSocket.swift +++ b/KissMe/Sources/Domestic/Realtime/Domestic.ContractPriceWebSocket.swift @@ -8,7 +8,7 @@ import Foundation -enum KissWebSocketSubscription: String { +enum KissWebSocketSubscription: String, Codable { case subscribed = "1" case unsubscribed = "2" } @@ -26,28 +26,11 @@ extension Domestic { /// 국내주식 실시간체결가[실시간-003] /// public class ContractPriceWebSocket: AuthWebSocket { - public typealias KResult = String - public var url: String { "/tryitout/H0STCNT0" } - public var header: [String: String?] { - [ - "approval_key": credential.approvalKey, - "custtype": "P", - "tr_type": KissWebSocketSubscription.subscribed.rawValue, - "content-type": "utf-8" - ] - } - public var body: [String: Any] { - [ - "tr_id": "H0STASP0", - "tr_key": productCode, - ] - } public var socket: URLSessionWebSocketTask? - public var result: KResult? = nil public var credential: WebSocketCredential var event: Event! let productCode: String @@ -59,25 +42,41 @@ extension Domestic { } - - struct Message: KissWebSocketMessage { - - let subscription: KissWebSocketSubscription - - init(subscription: KissWebSocketSubscription) { - self.subscription = subscription + public func subscribe() async throws -> Bool { + guard let socket = socket else { + throw GeneralError.invalidWebSocket } - } - - - func subscribe() { - // json 포맷으로 데이터를 송수신 + let request = SubscriptionRequest(approvalKey: credential.approvalKey, type: .subscribed, productCode: productCode) + let requestData = try JSONEncoder().encode(request) + let requestJson = String(data: requestData, encoding: .utf8)! + print(requestJson) + + try await socket.send(.string(requestJson)) + let r: SubscriptionResult = try await receive() + guard r.body.output != nil, r.body.resultCode == "0" else { + throw GeneralError.webSocketError(r.body.resultCode, r.body.messageCode, r.body.message) + } + return true } - func unsubscribe() { - // json 포맷으로 데이터를 송수신 - + + public func unsubscribe() async throws -> Bool { + guard let socket = socket else { + throw GeneralError.invalidWebSocket + } + + let request = SubscriptionRequest(approvalKey: credential.approvalKey, type: .unsubscribed, productCode: productCode) + let requestData = try JSONEncoder().encode(request) + let requestJson = String(data: requestData, encoding: .utf8)! + print(requestJson) + + try await socket.send(.string(requestJson)) + let r: SubscriptionResult = try await receive() + guard r.body.output != nil, r.body.resultCode == "0" else { + throw GeneralError.webSocketError(r.body.resultCode, r.body.messageCode, r.body.message) + } + return true } } } @@ -92,6 +91,83 @@ extension Domestic.ContractPriceWebSocket { self.socket = socket } } + + + struct SubscriptionRequest: Codable { + let header: Header + let body: Body + + struct Header: Codable { + let approvalKey: String + let customerType: CustomerType + let trType: KissWebSocketSubscription + let contentType: String + + private enum CodingKeys: String, CodingKey { + case approvalKey = "approval_key" + case customerType = "custtype" + case trType = "tr_type" + case contentType = "content-type" + } + } + + struct Body: Codable { + let input: Input + } + + struct Input: Codable { + let trId: String + let trKey: String + + private enum CodingKeys: String, CodingKey { + case trId = "tr_id" + case trKey = "tr_key" + } + } + + init(approvalKey: String, type: KissWebSocketSubscription, productCode: String) { + + header = Header(approvalKey: approvalKey, customerType: .personal, trType: type, contentType: "utf-8") + body = Body(input: Input(trId: "H0STASP0", trKey: productCode)) + } + } + + + struct SubscriptionResult: Codable { + let header: Header + let body: Body + + struct Header: Codable { + let trId: String + let trKey: String + let encrypted: YesNo + + private enum CodingKeys: String, CodingKey { + case trId = "tr_id" + case trKey = "tr_key" + case encrypted = "encrypt" + } + } + + struct Body: Codable { + let resultCode: String + let messageCode: String + let message: String + let output: OutputDetail? + + private enum CodingKeys: String, CodingKey { + case resultCode = "rt_cd" + case messageCode = "msg_cd" + case message = "msg1" + case output + } + } + + struct OutputDetail: Codable { + let iv: String + let key: String + } + } } diff --git a/KissMe/Sources/Domestic/Stock/DomesticStockResult.swift b/KissMe/Sources/Domestic/Stock/DomesticStockResult.swift index 681e02d..2d917cc 100644 --- a/KissMe/Sources/Domestic/Stock/DomesticStockResult.swift +++ b/KissMe/Sources/Domestic/Stock/DomesticStockResult.swift @@ -8,7 +8,7 @@ import Foundation -public enum CustomerType: String { +public enum CustomerType: String, Codable { case corporation = "B" case personal = "P" } diff --git a/KissMe/Sources/KissProfile.swift b/KissMe/Sources/KissProfile.swift index 1a50392..cab01f8 100644 --- a/KissMe/Sources/KissProfile.swift +++ b/KissMe/Sources/KissProfile.swift @@ -37,13 +37,35 @@ public class KissProfile { return profile.recent?.isMock } + public var approvalKey: String? { + profileLock.lock() + defer { + profileLock.unlock() + } + guard let expired = profile.recent?.approvalKeyExpired, expired > Date() else { + return nil + } + return profile.recent?.approvalKey + } + public init() { loadProfile() } func setAccessToken(_ accessToken: String, expired: Date, isMock: Bool) { profileLock.lock() - profile.recent = Recent(isMock: isMock, accessToken: accessToken, accessTokenExpired: expired) + let approvalKey = profile.recent?.approvalKey + let approvalKeyExpired = profile.recent?.approvalKeyExpired + profile.recent = Recent(isMock: isMock, accessToken: accessToken, accessTokenExpired: expired, approvalKey: approvalKey, approvalKeyExpired: approvalKeyExpired) + profileLock.unlock() + + saveProfile() + } + + func setApprovalKey(_ approvalKey: String, expired: Date) { + profileLock.lock() + let recent = profile.recent?.setted(approvalKey: approvalKey, approvalKeyExpired: expired) + profile.recent = recent profileLock.unlock() saveProfile() @@ -75,6 +97,12 @@ extension KissProfile { public let isMock: Bool public let accessToken: String public let accessTokenExpired: Date + public let approvalKey: String? + public let approvalKeyExpired: Date? + + func setted(approvalKey: String, approvalKeyExpired: Date) -> Recent { + return Recent(isMock: self.isMock, accessToken: self.accessToken, accessTokenExpired: self.accessTokenExpired, approvalKey: approvalKey, approvalKeyExpired: approvalKeyExpired) + } } public struct Love: Codable { diff --git a/KissMe/Sources/Login/Login.swift b/KissMe/Sources/Login/Login.swift index 8d67d86..a49751c 100644 --- a/KissMe/Sources/Login/Login.swift +++ b/KissMe/Sources/Login/Login.swift @@ -108,6 +108,10 @@ public struct ApprovalKeyAuthRequest: AuthRequest { public var result: KResult? = nil public let credential: Credential + // TEMP: write logging temporarily + public var responseDataLoggable: Bool { return true } + public var traceable: Bool { return true } + public init(credential: Credential) { self.credential = credential } @@ -178,13 +182,19 @@ extension KissAccount { } - public func approvalKey() async throws -> String { + public func getApprovalKey() async throws -> String { + if let approvalKey = approvalKey { + return approvalKey + } + return try await withUnsafeThrowingContinuation { continuation in + let SecondsForOneDay: TimeInterval = 60 * 60 * 24 let request = ApprovalKeyAuthRequest(credential: credential) request.query { result in switch result { case .success(let result): + self.setApprovalKey(result.approvalKey, expired: Date().addingTimeInterval(SecondsForOneDay - 60)) continuation.resume(returning: result.approvalKey) case .failure(let error): continuation.resume(throwing: error) diff --git a/KissMeConsole/Sources/main.swift b/KissMeConsole/Sources/main.swift index 00e27fd..54f88cb 100644 --- a/KissMeConsole/Sources/main.swift +++ b/KissMeConsole/Sources/main.swift @@ -25,11 +25,16 @@ func test_get_websocket_key_and_request_simple() { let webSocketCredential = KissWebSocketCredential(isMock: isMock, accountNo: account.accountNo, approvalKey: approvalKey) - var socket = Domestic.ContractPriceWebSocket(credential: webSocketCredential, productCode: KissConsole.defaultProductNo) + let socket = Domestic.ContractPriceWebSocket(credential: webSocketCredential, productCode: KissConsole.defaultProductNo) do { try await socket.connect() + let result = try await socket.subscribe() + print(result) + try await Task.sleep(nanoseconds: 1_000_000_000 * 3) + let result2 = try await socket.unsubscribe() + print(result2) } catch { print(error) } @@ -56,8 +61,13 @@ func test_get_websocket_key(isMock: Bool) async -> (KissAccount, String)? { let account = KissAccount(credential: credential) do { + /// Return existing valid key + if let approvalKey = account.approvalKey { + return (account, approvalKey) + } + if try await account.login() { - let approvalKey = try await account.approvalKey() + let approvalKey = try await account.getApprovalKey() print("approvalKey : \(approvalKey)") return (account, approvalKey) }