diff --git a/CHANGELOG.md b/CHANGELOG.md index 49569114..14ba8788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/1.8.3...main) * _Contributing to this repo? Add info about your change here to be included in the next release_ +__Fixes__ +- Fixed a bug in LiveQuery that prevented reconnecting after a connection was closed. Also added a sendPing method to LiveQuery ([#172](https://github.com/parse-community/Parse-Swift/pull/172)), thanks to [Corey Baker](https://github.com/cbaker6). + ### 1.8.3 [Full Changelog](https://github.com/parse-community/Parse-Swift/compare/1.8.2...1.8.3) diff --git a/ParseSwift.playground/Pages/11 - LiveQuery.xcplaygroundpage/Contents.swift b/ParseSwift.playground/Pages/11 - LiveQuery.xcplaygroundpage/Contents.swift index c09cb9ab..cf5fee33 100644 --- a/ParseSwift.playground/Pages/11 - LiveQuery.xcplaygroundpage/Contents.swift +++ b/ParseSwift.playground/Pages/11 - LiveQuery.xcplaygroundpage/Contents.swift @@ -10,7 +10,12 @@ import PlaygroundSupport import Foundation import ParseSwift +#if canImport(SwiftUI) import SwiftUI +#if canImport(Combine) +import Combine +#endif +#endif PlaygroundPage.current.needsIndefiniteExecution = true initializeParse() @@ -38,8 +43,9 @@ struct GameScore: ParseObject { //: Be sure you have LiveQuery enabled on your server. //: Create a query just as you normally would. -var query = GameScore.query("score" > 9) +var query = GameScore.query("score" < 11) +#if canImport(SwiftUI) //: To use subscriptions inside of SwiftUI struct ContentView: View { @@ -55,7 +61,7 @@ struct ContentView: View { Text("Unsubscribed from query!") } else if let event = subscription.event { - //: This is how you register to receive notificaitons of events related to your LiveQuery. + //: This is how you register to receive notifications of events related to your LiveQuery. switch event.event { case .entered(let object): @@ -93,6 +99,7 @@ struct ContentView: View { } PlaygroundPage.current.setLiveView(ContentView()) +#endif //: This is how you subscribe to your created query using callbacks. let subscription = query.subscribeCallback! @@ -109,7 +116,7 @@ subscription.handleSubscribe { subscribedQuery, isNew in } } -//: This is how you register to receive notificaitons of events related to your LiveQuery. +//: This is how you register to receive notifications of events related to your LiveQuery. subscription.handleEvent { _, event in switch event { @@ -126,10 +133,19 @@ subscription.handleEvent { _, event in } } +//: Ping the LiveQuery server +ParseLiveQuery.client?.sendPing { error in + if let error = error { + print("Error pinging LiveQuery server: \(error)") + } else { + print("Successfully pinged server!") + } +} + //: Now go to your dashboard, go to the GameScore table and add, update or remove rows. //: You should receive notifications for each. -//: This is how you register to receive notificaitons about being unsubscribed. +//: This is how you register to receive notifications about being unsubscribed. subscription.handleUnsubscribe { query in print("Unsubscribed from \(query)") } @@ -141,6 +157,16 @@ do { print(error) } +//: Ping the LiveQuery server. This should produce an error +//: because LiveQuery is disconnected. +ParseLiveQuery.client?.sendPing { error in + if let error = error { + print("Error pinging LiveQuery server: \(error)") + } else { + print("Successfully pinged server!") + } +} + //: Create a new query. var query2 = GameScore.query("score" > 50) @@ -177,11 +203,86 @@ subscription2.handleEvent { _, event in } } -//: Now go to your dashboard, go to the GameScore table and add, update or remove rows. -//: You should receive notifications for each, but only with your fields information. +//: To close the current LiveQuery connection. +ParseLiveQuery.client?.close() + +//: To close all LiveQuery connections use: +//ParseLiveQuery.client?.closeAll() + +//: Ping the LiveQuery server. This should produce an error +//: because LiveQuery is disconnected. +ParseLiveQuery.client?.sendPing { error in + if let error = error { + print("Error pinging LiveQuery server: \(error)") + } else { + print("Successfully pinged server!") + } +} + +//: Subscribe to your new query. +let subscription3 = query2.subscribeCallback! + +//: As before, setup your subscription and event handlers. +subscription3.handleSubscribe { subscribedQuery, isNew in + + //: You can check this subscription is for this query. + if isNew { + print("Successfully subscribed to new query \(subscribedQuery)") + } else { + print("Successfully updated subscription to new query \(subscribedQuery)") + } +} + +subscription3.handleEvent { _, event in + switch event { + + case .entered(let object): + print("Entered: \(object)") + case .left(let object): + print("Left: \(object)") + case .created(let object): + print("Created: \(object)") + case .updated(let object): + print("Updated: \(object)") + case .deleted(let object): + print("Deleted: \(object)") + } +} + +//: Now lets subscribe to an additional query. +let subscription4 = query.subscribeCallback! + +//: This is how you receive notifications about the success +//: of your subscription. +subscription4.handleSubscribe { subscribedQuery, isNew in + + //: You can check this subscription is for this query + if isNew { + print("Successfully subscribed to new query \(subscribedQuery)") + } else { + print("Successfully updated subscription to new query \(subscribedQuery)") + } +} + +//: This is how you register to receive notifications of events related to your LiveQuery. +subscription4.handleEvent { _, event in + switch event { + + case .entered(let object): + print("Entered: \(object)") + case .left(let object): + print("Left: \(object)") + case .created(let object): + print("Created: \(object)") + case .updated(let object): + print("Updated: \(object)") + case .deleted(let object): + print("Deleted: \(object)") + } +} -//: This is how you register to receive notificaitons about being unsubscribed. -subscription2.handleUnsubscribe { query in +//: Now we will will unsubscribe from one of the subsriptions, but maintain the connection. +subscription3.handleUnsubscribe { query in print("Unsubscribed from \(query)") } @@ -192,5 +293,14 @@ do { print(error) } +//: Ping the LiveQuery server +ParseLiveQuery.client?.sendPing { error in + if let error = error { + print("Error pinging LiveQuery server: \(error)") + } else { + print("Successfully pinged server!") + } +} + PlaygroundPage.current.finishExecution() //: [Next](@next) diff --git a/ParseSwift.xcodeproj/project.pbxproj b/ParseSwift.xcodeproj/project.pbxproj index 2a2ea96a..d4755c23 100644 --- a/ParseSwift.xcodeproj/project.pbxproj +++ b/ParseSwift.xcodeproj/project.pbxproj @@ -384,6 +384,13 @@ 91678706259BC5D400BB5B4E /* ParseCloudTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 916786EF259BC59600BB5B4E /* ParseCloudTests.swift */; }; 91678710259BC5D600BB5B4E /* ParseCloudTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 916786EF259BC59600BB5B4E /* ParseCloudTests.swift */; }; 9167871A259BC5D600BB5B4E /* ParseCloudTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 916786EF259BC59600BB5B4E /* ParseCloudTests.swift */; }; + 918CED592684C74000CFDC83 /* ParseLiveQuery+combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 918CED582684C74000CFDC83 /* ParseLiveQuery+combine.swift */; }; + 918CED5A2684C74000CFDC83 /* ParseLiveQuery+combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 918CED582684C74000CFDC83 /* ParseLiveQuery+combine.swift */; }; + 918CED5B2684C74000CFDC83 /* ParseLiveQuery+combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 918CED582684C74000CFDC83 /* ParseLiveQuery+combine.swift */; }; + 918CED5C2684C74000CFDC83 /* ParseLiveQuery+combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 918CED582684C74000CFDC83 /* ParseLiveQuery+combine.swift */; }; + 918CED5E268618C600CFDC83 /* ParseLiveQueryCombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 918CED5D268618C600CFDC83 /* ParseLiveQueryCombineTests.swift */; }; + 918CED5F268618C600CFDC83 /* ParseLiveQueryCombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 918CED5D268618C600CFDC83 /* ParseLiveQueryCombineTests.swift */; }; + 918CED60268618C600CFDC83 /* ParseLiveQueryCombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 918CED5D268618C600CFDC83 /* ParseLiveQueryCombineTests.swift */; }; 9194657824F16E330070296B /* ParseACLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9194657724F16E330070296B /* ParseACLTests.swift */; }; 91B40651267A66ED00B129CD /* ParseErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B40650267A66ED00B129CD /* ParseErrorTests.swift */; }; 91B40652267A66ED00B129CD /* ParseErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B40650267A66ED00B129CD /* ParseErrorTests.swift */; }; @@ -707,6 +714,8 @@ 9158916A256A07DD0024BE9A /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 916786E1259B7DDA00BB5B4E /* ParseCloud.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseCloud.swift; sourceTree = ""; }; 916786EF259BC59600BB5B4E /* ParseCloudTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseCloudTests.swift; sourceTree = ""; }; + 918CED582684C74000CFDC83 /* ParseLiveQuery+combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParseLiveQuery+combine.swift"; sourceTree = ""; }; + 918CED5D268618C600CFDC83 /* ParseLiveQueryCombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseLiveQueryCombineTests.swift; sourceTree = ""; }; 9194657724F16E330070296B /* ParseACLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseACLTests.swift; sourceTree = ""; }; 91B40650267A66ED00B129CD /* ParseErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseErrorTests.swift; sourceTree = ""; }; 91CB9536265966DF0043E5D6 /* ParseAnanlyticsCombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseAnanlyticsCombineTests.swift; sourceTree = ""; }; @@ -888,6 +897,7 @@ 70110D5B2506ED0E0091CC1D /* ParseInstallationTests.swift */, 70386A5B25D9A4010048EC1B /* ParseLDAPCombineTests.swift */, 70386A4525D99C8B0048EC1B /* ParseLDAPTests.swift */, + 918CED5D268618C600CFDC83 /* ParseLiveQueryCombineTests.swift */, 7003963A25A288100052CB31 /* ParseLiveQueryTests.swift */, 70C7DC2024D20F190050419B /* ParseObjectBatchTests.swift */, 7044C1DE25C5C70D0011F6E7 /* ParseObjectCombine.swift */, @@ -1044,10 +1054,11 @@ isa = PBXGroup; children = ( 70510AAB259EE25E00FEA700 /* LiveQuerySocket.swift */, - 70C5655825AA147B00BDD57F /* ParseLiveQueryConstants.swift */, 7003959425A10DFC0052CB31 /* Messages.swift */, 700395A225A119430052CB31 /* Operations.swift */, 7003960825A184EF0052CB31 /* ParseLiveQuery.swift */, + 918CED582684C74000CFDC83 /* ParseLiveQuery+combine.swift */, + 70C5655825AA147B00BDD57F /* ParseLiveQueryConstants.swift */, 700395B925A1470F0052CB31 /* Subscription.swift */, 705D950725BE4C08003EF6F8 /* SubscriptionCallback.swift */, 700395DE25A147C40052CB31 /* Protocols */, @@ -1651,6 +1662,7 @@ 7044C1C825C5B2B10011F6E7 /* ParseAuthentication+combine.swift in Sources */, 707A3BF125B0A4F0000D215C /* ParseAuthentication.swift in Sources */, 70D1BE7325BB43EB00A42E7C /* BaseConfig.swift in Sources */, + 918CED592684C74000CFDC83 /* ParseLiveQuery+combine.swift in Sources */, 70386A0625D9718C0048EC1B /* Data+hexString.swift in Sources */, F97B465F24D9C7B500F4A88B /* KeychainStore.swift in Sources */, 70170A442656B02D0070C905 /* ParseAnalytics.swift in Sources */, @@ -1735,6 +1747,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 918CED5E268618C600CFDC83 /* ParseLiveQueryCombineTests.swift in Sources */, 911DB13624C4FC100027F3C7 /* ParseObjectTests.swift in Sources */, 70E09E1C262F0634002DD451 /* ParsePointerCombineTests.swift in Sources */, 89899D592603CF3E002E2043 /* ParseTwitterTests.swift in Sources */, @@ -1805,6 +1818,7 @@ 7044C1C925C5B2B10011F6E7 /* ParseAuthentication+combine.swift in Sources */, 707A3BF225B0A4F0000D215C /* ParseAuthentication.swift in Sources */, 70D1BE7425BB43EB00A42E7C /* BaseConfig.swift in Sources */, + 918CED5A2684C74000CFDC83 /* ParseLiveQuery+combine.swift in Sources */, 70386A0725D9718C0048EC1B /* Data+hexString.swift in Sources */, F97B466024D9C7B500F4A88B /* KeychainStore.swift in Sources */, 70170A452656B02D0070C905 /* ParseAnalytics.swift in Sources */, @@ -1898,6 +1912,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 918CED60268618C600CFDC83 /* ParseLiveQueryCombineTests.swift in Sources */, 709B98512556ECAA00507778 /* ParseEncoderExtraTests.swift in Sources */, 70E09E1E262F0634002DD451 /* ParsePointerCombineTests.swift in Sources */, 89899D642603CF3F002E2043 /* ParseTwitterTests.swift in Sources */, @@ -1960,6 +1975,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 918CED5F268618C600CFDC83 /* ParseLiveQueryCombineTests.swift in Sources */, 70F2E2B6254F283000B2EA5C /* ParseACLTests.swift in Sources */, 70E09E1D262F0634002DD451 /* ParsePointerCombineTests.swift in Sources */, 89899D632603CF3E002E2043 /* ParseTwitterTests.swift in Sources */, @@ -2030,6 +2046,7 @@ 7044C1CB25C5B2B10011F6E7 /* ParseAuthentication+combine.swift in Sources */, 707A3BF425B0A4F0000D215C /* ParseAuthentication.swift in Sources */, 70D1BE7625BB43EB00A42E7C /* BaseConfig.swift in Sources */, + 918CED5C2684C74000CFDC83 /* ParseLiveQuery+combine.swift in Sources */, 70386A0925D9718C0048EC1B /* Data+hexString.swift in Sources */, F97B460524D9C6F200F4A88B /* NoBody.swift in Sources */, 70170A472656B02D0070C905 /* ParseAnalytics.swift in Sources */, @@ -2122,6 +2139,7 @@ 7044C1CA25C5B2B10011F6E7 /* ParseAuthentication+combine.swift in Sources */, 707A3BF325B0A4F0000D215C /* ParseAuthentication.swift in Sources */, 70D1BE7525BB43EB00A42E7C /* BaseConfig.swift in Sources */, + 918CED5B2684C74000CFDC83 /* ParseLiveQuery+combine.swift in Sources */, 70386A0825D9718C0048EC1B /* Data+hexString.swift in Sources */, F97B460424D9C6F200F4A88B /* NoBody.swift in Sources */, 70170A462656B02D0070C905 /* ParseAnalytics.swift in Sources */, diff --git a/Sources/ParseSwift/LiveQuery/LiveQuerySocket.swift b/Sources/ParseSwift/LiveQuery/LiveQuerySocket.swift index d0914519..b2ed3ac6 100644 --- a/Sources/ParseSwift/LiveQuery/LiveQuerySocket.swift +++ b/Sources/ParseSwift/LiveQuery/LiveQuerySocket.swift @@ -53,6 +53,9 @@ extension LiveQuerySocket { .encode(StandardMessage(operation: .connect, additionalProperties: true)) guard let encodedAsString = String(data: encoded, encoding: .utf8) else { + let error = ParseError(code: .unknownError, + message: "Couldn't encode connect message: \(encoded)") + completion(error) return } task.send(.string(encodedAsString)) { error in @@ -104,6 +107,15 @@ extension LiveQuerySocket { } } +// MARK: Ping +@available(macOS 10.15, iOS 13.0, macCatalyst 13.0, watchOS 6.0, tvOS 13.0, *) +extension LiveQuerySocket { + + func sendPing(_ task: URLSessionWebSocketTask, pongReceiveHandler: @escaping (Error?) -> Void) { + task.sendPing(pongReceiveHandler: pongReceiveHandler) + } +} + // MARK: URLSession @available(macOS 10.15, iOS 13.0, macCatalyst 13.0, watchOS 6.0, tvOS 13.0, *) extension URLSession { diff --git a/Sources/ParseSwift/LiveQuery/ParseLiveQuery+combine.swift b/Sources/ParseSwift/LiveQuery/ParseLiveQuery+combine.swift new file mode 100644 index 00000000..b889f392 --- /dev/null +++ b/Sources/ParseSwift/LiveQuery/ParseLiveQuery+combine.swift @@ -0,0 +1,51 @@ +// +// ParseLiveQuery+combine.swift +// ParseSwift +// +// Created by Corey Baker on 6/24/21. +// Copyright © 2021 Parse Community. All rights reserved. +// + +#if canImport(Combine) +import Foundation +import Combine + +@available(macOS 10.15, iOS 13.0, macCatalyst 13.0, watchOS 6.0, tvOS 13.0, *) +extension ParseLiveQuery { + // MARK: Functions - Combine + + /** + Manually establish a connection to the `ParseLiveQuery` Server. Publishes when established. + - parameter isUserWantsToConnect: Specifies if the user is calling this function. Defaults to `true`. + - returns: A publisher that eventually produces a single value and then finishes or fails. + */ + public func openPublisher(isUserWantsToConnect: Bool = true) -> Future { + Future { promise in + self.open(isUserWantsToConnect: isUserWantsToConnect) { error in + guard let error = error else { + promise(.success(())) + return + } + promise(.failure(error)) + } + } + } + + /** + Sends a ping frame from the client side. Publishes when a pong is received from the + server endpoint. + - returns: A publisher that eventually produces a single value and then finishes or fails. + */ + public func sendPingPublisher() -> Future { + Future { promise in + self.sendPing { error in + guard let error = error else { + promise(.success(())) + return + } + promise(.failure(error)) + } + } + } +} +#endif diff --git a/Sources/ParseSwift/LiveQuery/ParseLiveQuery.swift b/Sources/ParseSwift/LiveQuery/ParseLiveQuery.swift index 2edc7fbf..28877a5d 100644 --- a/Sources/ParseSwift/LiveQuery/ParseLiveQuery.swift +++ b/Sources/ParseSwift/LiveQuery/ParseLiveQuery.swift @@ -53,7 +53,13 @@ public final class ParseLiveQuery: NSObject { let notificationQueue: DispatchQueue //Task - var task: URLSessionWebSocketTask! + var task: URLSessionWebSocketTask! { + willSet { + if newValue == nil && isSocketEstablished == true { + isSocketEstablished = false + } + } + } var url: URL! var clientId: String! var attempts: Int = 1 { @@ -205,7 +211,6 @@ public final class ParseLiveQuery: NSObject { close(useDedicatedQueue: false) authenticationDelegate = nil receiveDelegate = nil - URLSession.liveQuery.delegates.removeValue(forKey: task) } } @@ -213,7 +218,8 @@ public final class ParseLiveQuery: NSObject { @available(macOS 10.15, iOS 13.0, macCatalyst 13.0, watchOS 6.0, tvOS 13.0, *) extension ParseLiveQuery { - static var client = try? ParseLiveQuery() + /// Current LiveQuery client. + public private(set) static var client = try? ParseLiveQuery() var reconnectInterval: Int { let min = NSDecimalNumber(decimal: Swift.min(30, pow(2, attempts) - 1)) @@ -232,11 +238,16 @@ extension ParseLiveQuery { } func removePendingSubscription(_ requestId: Int) { - let requestIdToRemove = RequestId(value: requestId) self.pendingSubscriptions.removeAll(where: { $0.0.value == requestId }) - //Remove in subscriptions just in case the server - //responded before this was called - self.subscriptions.removeValue(forKey: requestIdToRemove) + closeWebsocketIfNoSubscriptions() + } + + func closeWebsocketIfNoSubscriptions() { + self.notificationQueue.async { + if self.subscriptions.isEmpty && self.pendingSubscriptions.isEmpty { + self.close() + } + } } /// Set a specific ParseLiveQuery client to be the default for all `ParseLiveQuery` connections. @@ -282,6 +293,7 @@ extension ParseLiveQuery { public func removePendingSubscription(_ query: Query) throws { let queryData = try ParseCoding.jsonEncoder().encode(query) pendingSubscriptions.removeAll(where: { (_, value) -> Bool in + self.closeWebsocketIfNoSubscriptions() if queryData == value.queryData { return true } else { @@ -406,8 +418,8 @@ extension ParseLiveQuery: LiveQuerySocketDelegate { } else { isNew = true } - self.removePendingSubscription(subscribed.0.value) self.subscriptions[subscribed.0] = subscribed.1 + self.removePendingSubscription(subscribed.0.value) self.notificationQueue.async { subscribed.1.subscribeHandlerClosure?(isNew) } @@ -417,6 +429,7 @@ extension ParseLiveQuery: LiveQuerySocketDelegate { guard let subscription = self.subscriptions[requestId] else { return } + self.subscriptions.removeValue(forKey: requestId) self.removePendingSubscription(preliminaryMessage.requestId) self.notificationQueue.async { subscription.unsubscribeHandlerClosure?() @@ -502,10 +515,15 @@ extension ParseLiveQuery { return } if isSocketEstablished { - try? URLSession.liveQuery.connect(task: self.task) { error in - if error == nil { - self.isConnecting = true + do { + try URLSession.liveQuery.connect(task: self.task) { error in + if error == nil { + self.isConnecting = true + } + completion(error) } + } catch { + completion(error) } } else { self.synchronizationQueue @@ -513,6 +531,9 @@ extension ParseLiveQuery { .seconds(reconnectInterval)) { self.createTask() self.attempts += 1 + let error = ParseError(code: .unknownError, + message: "Attempted to open socket \(self.attempts) time(s)") + completion(error) } } } @@ -525,7 +546,35 @@ extension ParseLiveQuery { self.task.cancel() self.isDisconnectedByUser = true } - URLSession.liveQuery.delegates.removeValue(forKey: self.task) + if task != nil { + URLSession.liveQuery.delegates.removeValue(forKey: self.task) + } + self.task = nil + } + } + + /// Manually disconnect all sessions and subscriptions from the `ParseLiveQuery` Server. + public func closeAll() { + synchronizationQueue.sync { + URLSession.liveQuery.closeAll() + } + } + + /** + Sends a ping frame from the client side, with a closure to receive the pong from the server endpoint. + - parameter pongReceiveHandler: A closure called by the task when it receives the pong + from the server. The closure receives an `Error` that indicates a lost connection or other problem, + or nil if no error occurred. + */ + public func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) { + synchronizationQueue.sync { + if isSocketEstablished { + URLSession.liveQuery.sendPing(task, pongReceiveHandler: pongReceiveHandler) + } else { + let error = ParseError(code: .unknownError, + message: "Need to open the websocket before it can be pinged.") + pongReceiveHandler(error) + } } } @@ -536,12 +585,16 @@ extension ParseLiveQuery { self.task.cancel() } URLSession.liveQuery.delegates.removeValue(forKey: self.task) + self.task = nil } } else { if self.isConnected { self.task.cancel() } - URLSession.liveQuery.delegates.removeValue(forKey: self.task) + if self.task != nil { + URLSession.liveQuery.delegates.removeValue(forKey: self.task) + } + self.task = nil } } @@ -550,6 +603,8 @@ extension ParseLiveQuery { self.pendingSubscriptions.append((requestId, record)) if self.isConnected { URLSession.liveQuery.send(record.messageData, task: self.task, completion: completion) + } else { + self.open(completion: completion) } } } @@ -663,10 +718,6 @@ extension ParseLiveQuery { let updatedRecord = value updatedRecord.messageData = encoded self.send(record: updatedRecord, requestId: key) { _ in } - } else { - let error = ParseError(code: .unknownError, - message: "ParseLiveQuery Error: Not subscribed to this query") - throw error } } } diff --git a/Sources/ParseSwift/LiveQuery/Protocols/ParseLiveQueryDelegate.swift b/Sources/ParseSwift/LiveQuery/Protocols/ParseLiveQueryDelegate.swift index 9a0aac29..e2de9751 100644 --- a/Sources/ParseSwift/LiveQuery/Protocols/ParseLiveQueryDelegate.swift +++ b/Sources/ParseSwift/LiveQuery/Protocols/ParseLiveQueryDelegate.swift @@ -13,7 +13,7 @@ import FoundationNetworking // swiftlint:disable line_length -///Receive/respond to notifications from the ParseLiveQuery Server. +/// Receive/respond to notifications from the ParseLiveQuery Server. @available(macOS 10.15, iOS 13.0, macCatalyst 13.0, watchOS 6.0, tvOS 13.0, *) public protocol ParseLiveQueryDelegate: AnyObject { diff --git a/Tests/ParseSwiftTests/ParseLiveQueryCombineTests.swift b/Tests/ParseSwiftTests/ParseLiveQueryCombineTests.swift new file mode 100644 index 00000000..538c9fbf --- /dev/null +++ b/Tests/ParseSwiftTests/ParseLiveQueryCombineTests.swift @@ -0,0 +1,139 @@ +// +// ParseLiveQueryCombineTests.swift +// ParseSwift +// +// Created by Corey Baker on 6/25/21. +// Copyright © 2021 Parse Community. All rights reserved. +// + +#if !os(Linux) && !os(Android) +import Foundation +import XCTest +@testable import ParseSwift +#if canImport(Combine) +import Combine +#endif + +@available(macOS 10.15, iOS 13.0, macCatalyst 13.0, watchOS 6.0, tvOS 13.0, *) +class ParseLiveQueryCombineTests: XCTestCase { + + override func setUpWithError() throws { + try super.setUpWithError() + guard let url = URL(string: "http://localhost:1337/1") else { + XCTFail("Should create valid URL") + return + } + ParseSwift.initialize(applicationId: "applicationId", + clientKey: "clientKey", + masterKey: "masterKey", + serverURL: url, + testing: true) + ParseLiveQuery.setDefault(try ParseLiveQuery(isDefault: true)) + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + MockURLProtocol.removeAll() + #if !os(Linux) && !os(Android) + try KeychainStore.shared.deleteAll() + #endif + try ParseStorage.shared.deleteAll() + URLSession.liveQuery.closeAll() + } + + func testOpen() throws { + guard let client = ParseLiveQuery.getDefault() else { + XCTFail("Should be able to get client") + return + } + client.close() + + var subscriptions = Set() + let expectation1 = XCTestExpectation(description: "Send Ping") + let publisher = client.openPublisher(isUserWantsToConnect: true) + .sink(receiveCompletion: { result in + + switch result { + + case .finished: + XCTFail("Should have produced failure") + case .failure(let error): + XCTAssertNotNil(error) //Should always fail since WS isn't intercepted. + } + expectation1.fulfill() + + }, receiveValue: { _ in + XCTFail("Should have produced error") + }) + publisher.store(in: &subscriptions) + wait(for: [expectation1], timeout: 20.0) + } + + func testPingSocketNotEstablished() throws { + guard let client = ParseLiveQuery.getDefault() else { + XCTFail("Should be able to get client") + return + } + client.close() + var subscriptions = Set() + let expectation1 = XCTestExpectation(description: "Send Ping") + let publisher = client.sendPingPublisher() + .sink(receiveCompletion: { result in + + switch result { + + case .finished: + XCTFail("Should have produced failure") + case .failure(let error): + XCTAssertEqual(client.isSocketEstablished, false) + XCTAssertNil(client.task) + guard let parseError = error as? ParseError else { + XCTFail("Should have casted to ParseError.") + expectation1.fulfill() + return + } + XCTAssertEqual(parseError.code, ParseError.Code.unknownError) + XCTAssertTrue(parseError.message.contains("pinged")) + } + expectation1.fulfill() + + }, receiveValue: { _ in + XCTFail("Should have produced error") + }) + publisher.store(in: &subscriptions) + wait(for: [expectation1], timeout: 20.0) + } + + func testPing() throws { + guard let client = ParseLiveQuery.getDefault() else { + XCTFail("Should be able to get client") + return + } + client.isSocketEstablished = true // Socket needs to be true + client.isConnecting = true + client.isConnected = true + client.clientId = "yolo" + + var subscriptions = Set() + let expectation1 = XCTestExpectation(description: "Send Ping") + let publisher = client.sendPingPublisher() + .sink(receiveCompletion: { result in + + switch result { + + case .finished: + XCTFail("Should have produced failure") + case .failure(let error): + XCTAssertEqual(client.isSocketEstablished, true) + XCTAssertNotNil(error) // Should have error because testcases don't intercept websocket + } + expectation1.fulfill() + + }, receiveValue: { _ in + XCTFail("Should have produced error") + }) + publisher.store(in: &subscriptions) + wait(for: [expectation1], timeout: 20.0) + } +} +#endif diff --git a/Tests/ParseSwiftTests/ParseLiveQueryTests.swift b/Tests/ParseSwiftTests/ParseLiveQueryTests.swift index c91f7770..1f716639 100644 --- a/Tests/ParseSwiftTests/ParseLiveQueryTests.swift +++ b/Tests/ParseSwiftTests/ParseLiveQueryTests.swift @@ -50,7 +50,7 @@ class ParseLiveQueryTests: XCTestCase { masterKey: "masterKey", serverURL: url, testing: true) - ParseLiveQuery.client = try? ParseLiveQuery(isDefault: true) + ParseLiveQuery.setDefault(try ParseLiveQuery(isDefault: true)) } override func tearDownWithError() throws { @@ -314,16 +314,30 @@ class ParseLiveQueryTests: XCTestCase { XCTFail("Should be able to get client") return } - client.isSocketEstablished = true // Socket neets to be true + client.isSocketEstablished = true // Socket needs to be true client.isConnecting = true - client.attempts = 50 client.isConnected = true + client.attempts = 5 client.clientId = "yolo" + client.isDisconnectedByUser = false XCTAssertEqual(client.isSocketEstablished, true) XCTAssertEqual(client.isConnecting, false) XCTAssertEqual(client.clientId, "yolo") - XCTAssertEqual(client.attempts, 1) + XCTAssertEqual(client.attempts, 5) + + // Test too many attempts and close + client.isSocketEstablished = true // Socket needs to be true + client.isConnecting = true + client.isConnected = true + client.attempts = ParseLiveQueryConstants.maxConnectionAttempts + 1 + client.clientId = "yolo" + client.isDisconnectedByUser = false + + XCTAssertEqual(client.isSocketEstablished, false) + XCTAssertEqual(client.isConnecting, false) + XCTAssertEqual(client.clientId, "yolo") + XCTAssertEqual(client.attempts, ParseLiveQueryConstants.maxConnectionAttempts + 1) } func testDisconnectedState() throws { @@ -331,7 +345,7 @@ class ParseLiveQueryTests: XCTestCase { XCTFail("Should be able to get client") return } - client.isSocketEstablished = true // Socket neets to be true + client.isSocketEstablished = true // Socket needs to be true client.isConnecting = true client.isConnected = true client.clientId = "yolo" @@ -352,7 +366,7 @@ class ParseLiveQueryTests: XCTestCase { XCTFail("Should be able to get client") return } - client.isSocketEstablished = true // Socket neets to be true + client.isSocketEstablished = true // Socket needs to be true client.isConnecting = true client.isConnected = true client.clientId = "yolo" @@ -384,13 +398,77 @@ class ParseLiveQueryTests: XCTestCase { XCTAssertEqual(client.clientId, "yolo") client.close() - XCTAssertEqual(client.isSocketEstablished, true) + XCTAssertEqual(client.isSocketEstablished, false) XCTAssertEqual(client.isConnected, false) XCTAssertEqual(client.isConnecting, false) XCTAssertNil(client.clientId) XCTAssertEqual(client.isDisconnectedByUser, true) } + func testOpenSocket() throws { + guard let client = ParseLiveQuery.getDefault() else { + XCTFail("Should be able to get client") + return + } + client.close() + client.open(isUserWantsToConnect: true) { error in + XCTAssertNotNil(error) //Should always fail since WS isn't intercepted. + } + } + + func testCloseAll() throws { + guard let client = ParseLiveQuery.getDefault() else { + XCTFail("Should be able to get client") + return + } + client.closeAll() + client.synchronizationQueue.asyncAfter(deadline: .now() + 2) { + XCTAssertFalse(client.isSocketEstablished) + XCTAssertNil(client.task) + } + } + + func testPingSocketNotEstablished() throws { + guard let client = ParseLiveQuery.getDefault() else { + XCTFail("Should be able to get client") + return + } + client.close() + let expectation1 = XCTestExpectation(description: "Send Ping") + client.sendPing { error in + XCTAssertEqual(client.isSocketEstablished, false) + XCTAssertNil(client.task) + guard let parseError = error as? ParseError else { + XCTFail("Should have casted to ParseError.") + expectation1.fulfill() + return + } + XCTAssertEqual(parseError.code, ParseError.Code.unknownError) + XCTAssertTrue(parseError.message.contains("pinged")) + expectation1.fulfill() + } + wait(for: [expectation1], timeout: 20.0) + } + + func testPing() throws { + guard let client = ParseLiveQuery.getDefault() else { + XCTFail("Should be able to get client") + return + } + client.isSocketEstablished = true // Socket needs to be true + client.isConnecting = true + client.isConnected = true + client.clientId = "yolo" + + let expectation1 = XCTestExpectation(description: "Send Ping") + client.sendPing { error in + XCTAssertEqual(client.isSocketEstablished, true) + XCTAssertNotNil(error) // Should have error because testcases don't intercept websocket + expectation1.fulfill() + } + wait(for: [expectation1], timeout: 20.0) + } + func testReconnectInterval() throws { guard let client = ParseLiveQuery.getDefault() else { XCTFail("Should be able to get client") @@ -545,6 +623,9 @@ class ParseLiveQueryTests: XCTestCase { //Unsubscribe subscription.handleUnsubscribe { query in XCTAssertEqual(query, subscribedQuery) + XCTAssertTrue(client.pendingSubscriptions.isEmpty) + XCTAssertTrue(client.subscriptions.isEmpty) + XCTAssertFalse(client.isSocketEstablished) expectation2.fulfill() } XCTAssertNotNil(try? query.unsubscribe()) @@ -584,14 +665,94 @@ class ParseLiveQueryTests: XCTestCase { wait(for: [expectation1, expectation2], timeout: 20.0) } - func testServerRedirectResponse() throws { + func testSubscribeCloseSubscribe() throws { + let query = GameScore.query("score" > 9) + let handler = SubscriptionCallback(query: query) + var subscription = try Query.subscribe(handler) + guard let client = ParseLiveQuery.getDefault() else { XCTFail("Should be able to get client") return } + XCTAssertEqual(subscription.query, query) + + let expectation1 = XCTestExpectation(description: "Subscribe Handler") + let expectation2 = XCTestExpectation(description: "Resubscribe Handler") + var count = 0 + var originalTask: URLSessionWebSocketTask? + subscription.handleSubscribe { subscribedQuery, isNew in + XCTAssertEqual(query, subscribedQuery) + if count == 0 { + XCTAssertTrue(isNew) + XCTAssertEqual(client.pendingSubscriptions.count, 0) + XCTAssertEqual(client.subscriptions.count, 1) + XCTAssertNotNil(ParseLiveQuery.client?.task) + originalTask = ParseLiveQuery.client?.task + expectation1.fulfill() + } else if count == 2 { + XCTAssertNotNil(ParseLiveQuery.client?.task) + XCTAssertFalse(originalTask == ParseLiveQuery.client?.task) + expectation2.fulfill() + return + } + + ParseLiveQuery.client?.close() + ParseLiveQuery.client?.synchronizationQueue.sync { + XCTAssertNil(ParseLiveQuery.client?.task) + if let socketEstablished = ParseLiveQuery.client?.isSocketEstablished { + XCTAssertFalse(socketEstablished) + } else { + XCTFail("Should have socket that isn't established") + } + + //Resubscribe + do { + count += 1 + subscription = try Query.subscribe(handler) + } catch { + XCTFail("\(error)") + } + + try? self.pretendToBeConnected() + + let response2 = PreliminaryMessageResponse(op: .subscribed, + requestId: 2, + clientId: "yolo", + installationId: "naw") + guard let encoded2 = try? ParseCoding.jsonEncoder().encode(response2) else { + expectation2.fulfill() + return + } + client.received(encoded2) + } + } + + XCTAssertFalse(try client.isSubscribed(query)) + XCTAssertTrue(try client.isPendingSubscription(query)) + XCTAssertEqual(client.subscriptions.count, 0) + XCTAssertEqual(client.pendingSubscriptions.count, 1) try pretendToBeConnected() + let response = PreliminaryMessageResponse(op: .subscribed, + requestId: 1, + clientId: "yolo", + installationId: "naw") + let encoded = try ParseCoding.jsonEncoder().encode(response) + client.received(encoded) + XCTAssertTrue(try client.isSubscribed(query)) + XCTAssertFalse(try client.isPendingSubscription(query)) + XCTAssertEqual(client.subscriptions.count, 1) + XCTAssertEqual(client.pendingSubscriptions.count, 0) - guard let url = URL(string: "http://parse.com") else { + wait(for: [expectation1, expectation2], timeout: 20.0) + } + + func testServerRedirectResponse() throws { + guard let client = ParseLiveQuery.getDefault() else { + XCTFail("Should be able to get client") + return + } + + guard let url = URL(string: "wss://parse.com") else { XCTFail("should create url") return } @@ -1255,9 +1416,9 @@ class ParseLiveQueryTests: XCTestCase { try pretendToBeConnected() let response = PreliminaryMessageResponse(op: .subscribed, - requestId: 1, - clientId: "yolo", - installationId: "naw") + requestId: 1, + clientId: "yolo", + installationId: "naw") let encoded = try ParseCoding.jsonEncoder().encode(response) client.received(encoded)