diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0751f318a..651f6e84e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: branches: [ main ] env: - CI_XCODE_OLDEST: '/Applications/Xcode_13.3.1.app/Contents/Developer' + CI_XCODE_OLDEST: '/Applications/Xcode_14.2.app/Contents/Developer' CI_XCODE_14: '/Applications/Xcode_14.3.1.app/Contents/Developer' CI_XCODE_LATEST: '/Applications/Xcode_15.4.app/Contents/Developer' @@ -101,7 +101,7 @@ jobs: env: DEVELOPER_DIR: ${{ env.CI_XCODE_LATEST }} - xcode-test-5_5: + xcode-test-5_7: timeout-minutes: 25 needs: linux runs-on: macos-12 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd2cace7..a2b08df2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,15 @@ # Parse-Swift Changelog ### main -[Full Changelog](https://github.com/netreconlab/Parse-Swift/compare/5.10.3...main), [Documentation](https://swiftpackageindex.com/netreconlab/Parse-Swift/main/documentation/parseswift) +[Full Changelog](https://github.com/netreconlab/Parse-Swift/compare/5.11.0...main), [Documentation](https://swiftpackageindex.com/netreconlab/Parse-Swift/main/documentation/parseswift) * _Contributing to this repo? Add info about your change here to be included in the next release_ +### 5.11.0 +[Full Changelog](https://github.com/netreconlab/Parse-Swift/compare/5.10.3...5.11.0), [Documentation](https://swiftpackageindex.com/netreconlab/Parse-Swift/5.11.0/documentation/parseswift) + +__New features__ +* Allow hook triggers on ParseConfig and improve SDK ability to throw errors when the developer uses unsupported trigger combinations. Also changes lowest requirements to be Swift 5.7 and Xcode 14.0 which aligns with other Swift Packages ([#179](https://github.com/netreconlab/Parse-Swift/pull/179)), thanks to [Corey Baker](https://github.com/cbaker6). + ### 5.10.3 [Full Changelog](https://github.com/netreconlab/Parse-Swift/compare/5.10.2...5.10.3), [Documentation](https://swiftpackageindex.com/netreconlab/Parse-Swift/5.10.3/documentation/parseswift) diff --git a/Package.swift b/Package.swift index 38b290cb8..7306c9c86 100644 --- a/Package.swift +++ b/Package.swift @@ -1,26 +1,31 @@ -// swift-tools-version:5.5.2 +// swift-tools-version:5.7 import PackageDescription let package = Package( name: "ParseSwift", - platforms: [.iOS(.v13), - .macCatalyst(.v13), - .macOS(.v10_15), - .tvOS(.v13), - .watchOS(.v6)], + platforms: [ + .iOS(.v13), + .macCatalyst(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6) + ], products: [ .library( name: "ParseSwift", - targets: ["ParseSwift"]) + targets: ["ParseSwift"] + ) ], targets: [ .target( name: "ParseSwift", - dependencies: []), + dependencies: [] + ), .testTarget( name: "ParseSwiftTests", dependencies: ["ParseSwift"], - exclude: ["Info.plist"]) + exclude: ["Info.plist"] + ) ] ) diff --git a/Sources/ParseSwift/ParseConstants.swift b/Sources/ParseSwift/ParseConstants.swift index f0d915e82..0b3930438 100644 --- a/Sources/ParseSwift/ParseConstants.swift +++ b/Sources/ParseSwift/ParseConstants.swift @@ -10,7 +10,7 @@ import Foundation enum ParseConstants { static let sdk = "swift" - static let version = "5.10.3" + static let version = "5.11.0" static let fileManagementDirectory = "parse/" static let fileManagementPrivateDocumentsDirectory = "Private Documents/" static let fileManagementLibraryDirectory = "Library/" @@ -51,9 +51,9 @@ public enum ParseHookTriggerType: String, Codable, Sendable { case afterLogin /// Occurs after logout of a `ParseUser`. case afterLogout - /// Occurs before saving a `ParseObject` or `ParseFile`. + /// Occurs before saving a `ParseObject`, `ParseFile`, or `ParseConfig`. case beforeSave - /// Occurs after saving a `ParseObject` or `ParseFile`. + /// Occurs after saving a `ParseObject`, `ParseFile`, or `ParseConfig`. case afterSave /// Occurs before deleting a `ParseObject` or `ParseFile`. case beforeDelete @@ -70,3 +70,38 @@ public enum ParseHookTriggerType: String, Codable, Sendable { /// Occurs after a `ParseLiveQuery` event. case afterEvent } + +/** + The objects that Parse Hooks can be triggered on. + */ +public enum ParseHookTriggerObject: Sendable { + /// The type of `ParseObject` to trigger on. + case objectType(any ParseObject.Type) + /// An instance of a `ParseObject` to trigger on. + case object(any ParseObject) + /// Trigger on `ParseFile`'s. + case file + /// Trigger on `ParseConfig` updates. + /// - warning: Requires Parse Server 7.3.0-alpha.6+. + case config + /// Trigger on `ParseLiveQuery` connections. + case liveQueryConnect + + /// The class name of the `ParseObject` to trigger on. + var className: String { + switch self { + + case .objectType(let object): + return object.className + case .object(let object): + return object.className + case .file: + return "@File" + case .config: + return "@Config" + case .liveQueryConnect: + return "@Connect" + + } + } +} diff --git a/Sources/ParseSwift/Protocols/ParseHookTriggerable.swift b/Sources/ParseSwift/Protocols/ParseHookTriggerable.swift index ae647f938..b2c956999 100644 --- a/Sources/ParseSwift/Protocols/ParseHookTriggerable.swift +++ b/Sources/ParseSwift/Protocols/ParseHookTriggerable.swift @@ -31,7 +31,11 @@ public extension ParseHookTriggerable { - parameter trigger: The `ParseHookTriggerType` type. - parameter url: The endpoint of the hook. */ - init(className: String, trigger: ParseHookTriggerType, url: URL) { + init( + className: String, + trigger: ParseHookTriggerType, + url: URL + ) { self.init() self.className = className self.triggerName = trigger @@ -45,7 +49,11 @@ public extension ParseHookTriggerable { - parameter url: The endpoint of the hook. */ @available(*, deprecated, message: "Change \"triggerName\" to \"trigger\"") - init(className: String, triggerName: ParseHookTriggerType, url: URL) { + init( + className: String, + triggerName: ParseHookTriggerType, + url: URL + ) { self.init(className: className, trigger: triggerName, url: url) } @@ -55,7 +63,11 @@ public extension ParseHookTriggerable { - parameter trigger: The `ParseHookTriggerType` type. - parameter url: The endpoint of the hook. */ - init(object: T.Type, trigger: ParseHookTriggerType, url: URL) where T: ParseObject { + init( + object: T.Type, + trigger: ParseHookTriggerType, + url: URL + ) where T: ParseObject { self.init(className: object.className, trigger: trigger, url: url) } @@ -65,7 +77,11 @@ public extension ParseHookTriggerable { - parameter trigger: The `ParseHookTriggerType` type. - parameter url: The endpoint of the hook. */ - init(object: T, trigger: ParseHookTriggerType, url: URL) where T: ParseObject { + init( + object: T, + trigger: ParseHookTriggerType, + url: URL + ) where T: ParseObject { self.init(className: T.className, trigger: trigger, url: url) } @@ -76,27 +92,129 @@ public extension ParseHookTriggerable { - parameter url: The endpoint of the hook. */ @available(*, deprecated, message: "Change \"triggerName\" to \"trigger\"") - init(object: T, triggerName: ParseHookTriggerType, url: URL) where T: ParseObject { + init( + object: T, + triggerName: ParseHookTriggerType, + url: URL + ) where T: ParseObject { self.init(object: object, trigger: triggerName, url: url) } + /** + Creates a new Parse hook trigger for any supported `ParseHookTriggerObject`. + - parameter object: The `ParseHookTriggerObject` the trigger should act on. + - parameter trigger: The `ParseHookTriggerType` type. + - parameter url: The endpoint of the hook. + */ + init( // swiftlint:disable:this cyclomatic_complexity function_body_length + object: ParseHookTriggerObject, + trigger: ParseHookTriggerType, + url: URL + ) throws { + + let notSupportedError = ParseError( + code: .otherCause, + message: "This object \"\(object)\" currently does not support the hook trigger \"\(trigger)\"" + ) + + switch object { + case .objectType(let parseObject): + switch trigger { + case .beforeLogin, .afterLogin, .afterLogout: + guard parseObject is (any ParseUser.Type) else { + throw notSupportedError + } + case .beforeSave, .afterSave, .beforeDelete, + .afterDelete, .beforeFind, .afterFind, + .beforeSubscribe, .afterEvent: + break // No op + default: + throw notSupportedError + } + self.init( + className: object.className, + trigger: trigger, + url: url + ) + case .object(let parseObject): + switch trigger { + case .beforeLogin, .afterLogin, .afterLogout: + guard parseObject is (any ParseUser) else { + throw notSupportedError + } + case .beforeSave, .afterSave, .beforeDelete, + .afterDelete, .beforeFind, .afterFind, + .beforeSubscribe, .afterEvent: + break // No op + default: + throw notSupportedError + } + self.init( + className: object.className, + trigger: trigger, + url: url + ) + case .file: + switch trigger { + case .beforeSave, .afterSave, .beforeDelete, .afterDelete: + break // No op + default: + throw notSupportedError + } + self.init( + className: object.className, + trigger: trigger, + url: url + ) + case .config: + switch trigger { + case .beforeSave, .afterSave: + break // No op + default: + throw notSupportedError + } + self.init( + className: object.className, + trigger: trigger, + url: url + ) + case .liveQueryConnect: + guard trigger == .beforeConnect else { + throw notSupportedError + } + self.init( + className: object.className, + trigger: trigger, + url: url + ) + } + } + /** Creates a new `ParseFile` or `ParseHookTriggerType.beforeConnect` hook trigger. - parameter trigger: The `ParseHookTriggerType` type. - parameter url: The endpoint of the hook. */ + @available(*, deprecated, message: "Add \"object\" as the first argument") init(trigger: ParseHookTriggerType, url: URL) throws { - self.init() - self.triggerName = trigger - self.url = url - switch triggerName { + switch trigger { case .beforeSave, .afterSave, .beforeDelete, .afterDelete: - self.className = "@File" + self.init( + className: ParseHookTriggerObject.file.className, + trigger: trigger, + url: url + ) case .beforeConnect: - self.className = "@Connect" + self.init( + className: ParseHookTriggerObject.liveQueryConnect.className, + trigger: trigger, + url: url + ) default: - throw ParseError(code: .otherCause, - message: "This initializer should only be used for \"ParseFile\" and \"beforeConnect\"") + throw ParseError( + code: .otherCause, + message: "This initializer should only be used for \"ParseFile\" and \"beforeConnect\"" + ) } } diff --git a/Tests/ParseSwiftTests/ParseHookTriggerTests.swift b/Tests/ParseSwiftTests/ParseHookTriggerTests.swift index e32800872..5a0ed5ee2 100644 --- a/Tests/ParseSwiftTests/ParseHookTriggerTests.swift +++ b/Tests/ParseSwiftTests/ParseHookTriggerTests.swift @@ -46,6 +46,26 @@ class ParseHookTriggerTests: XCTestCase { } } + struct User: ParseUser { + + //: These are required by ParseObject + var objectId: String? + var createdAt: Date? + var updatedAt: Date? + var ACL: ParseACL? + var originalData: Data? + + // These are required by ParseUser + var username: String? + var email: String? + var emailVerified: Bool? + var password: String? + var authData: [String: [String: String]?]? + + // Your custom keys + var customKey: String? + } + override func setUp() async throws { try await super.setUp() guard let url = URL(string: "http://localhost:1337/parse") else { @@ -68,12 +88,129 @@ class ParseHookTriggerTests: XCTestCase { try await ParseStorage.shared.deleteAll() } + // swiftlint:disable:next function_body_length func testCoding() throws { guard let url = URL(string: "https://api.example.com/foo") else { XCTFail("Should have unwrapped") return } + let parseObjectType = GameScore.self + let object = ParseHookTriggerObject.objectType(parseObjectType) + let hookTrigger = try ParseHookTrigger( + object: object, + trigger: .afterSave, + url: url + ) + // swiftlint:disable:next line_length + let expected = "{\"className\":\"GameScore\",\"triggerName\":\"afterSave\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger.description, expected) + + let parseObject = GameScore() + let object2 = ParseHookTriggerObject.object(parseObject) + let hookTrigger2 = try ParseHookTrigger( + object: object2, + trigger: .afterSave, + url: url + ) + // swiftlint:disable:next line_length + let expected2 = "{\"className\":\"GameScore\",\"triggerName\":\"afterSave\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger2.description, expected2) + + let hookTrigger3 = try ParseHookTrigger( + object: .file, + trigger: .afterSave, + url: url + ) + // swiftlint:disable:next line_length + let expected3 = "{\"className\":\"@File\",\"triggerName\":\"afterSave\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger3.description, expected3) + + let hookTrigger4 = try ParseHookTrigger( + object: .liveQueryConnect, + trigger: .beforeConnect, + url: url + ) + // swiftlint:disable:next line_length + let expected4 = "{\"className\":\"@Connect\",\"triggerName\":\"beforeConnect\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger4.description, expected4) + + let hookTrigger5 = try ParseHookTrigger( + object: .config, + trigger: .afterSave, + url: url + ) + // swiftlint:disable:next line_length + let expected5 = "{\"className\":\"@Config\",\"triggerName\":\"afterSave\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger5.description, expected5) + + let parseUserType = User.self + let parseUser = User() + + let object3 = ParseHookTriggerObject.objectType(parseUserType) + let object4 = ParseHookTriggerObject.object(parseUser) + + let hookTrigger6 = try ParseHookTrigger( + object: object3, + trigger: .beforeLogin, + url: url + ) + // swiftlint:disable:next line_length + let expected6 = "{\"className\":\"_User\",\"triggerName\":\"beforeLogin\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger6.description, expected6) + + let hookTrigger7 = try ParseHookTrigger( + object: object3, + trigger: .afterLogin, + url: url + ) + // swiftlint:disable:next line_length + let expected7 = "{\"className\":\"_User\",\"triggerName\":\"afterLogin\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger7.description, expected7) + + let hookTrigger8 = try ParseHookTrigger( + object: object3, + trigger: .afterLogout, + url: url + ) + // swiftlint:disable:next line_length + let expected8 = "{\"className\":\"_User\",\"triggerName\":\"afterLogout\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger8.description, expected8) + + let hookTrigger9 = try ParseHookTrigger( + object: object4, + trigger: .beforeLogin, + url: url + ) + // swiftlint:disable:next line_length + let expected9 = "{\"className\":\"_User\",\"triggerName\":\"beforeLogin\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger9.description, expected9) + + let hookTrigger10 = try ParseHookTrigger( + object: object4, + trigger: .afterLogin, + url: url + ) + // swiftlint:disable:next line_length + let expected10 = "{\"className\":\"_User\",\"triggerName\":\"afterLogin\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger10.description, expected10) + + let hookTrigger11 = try ParseHookTrigger( + object: object4, + trigger: .afterLogout, + url: url + ) + // swiftlint:disable:next line_length + let expected11 = "{\"className\":\"_User\",\"triggerName\":\"afterLogout\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" + XCTAssertEqual(hookTrigger11.description, expected11) + } + + func testCodingDeprecated() throws { + guard let url = URL(string: "https://api.example.com/foo") else { + XCTFail("Should have unwrapped") + return + } + let hookTrigger = ParseHookTrigger(className: "foo", triggerName: .afterSave, url: url) @@ -81,10 +218,6 @@ class ParseHookTriggerTests: XCTestCase { let expected = "{\"className\":\"foo\",\"triggerName\":\"afterSave\",\"url\":\"https:\\/\\/api.example.com\\/foo\"}" XCTAssertEqual(hookTrigger.description, expected) let object = GameScore() - guard let url = URL(string: "https://api.example.com/foo") else { - XCTFail("Should have unwrapped") - return - } let hookTrigger2 = ParseHookTrigger(object: object, triggerName: .afterSave, url: url) @@ -118,6 +251,71 @@ class ParseHookTriggerTests: XCTestCase { url: url)) } + // swiftlint:disable:next function_body_length + func testParseHookTriggerObjectUnsupported() throws { + guard let url = URL(string: "https://api.example.com/foo") else { + XCTFail("Should have unwrapped") + return + } + + XCTAssertThrowsError( + try ParseHookTrigger( + object: ParseHookTriggerObject.objectType(GameScore.self), + trigger: .beforeConnect, + url: url + ) + ) + XCTAssertThrowsError( + try ParseHookTrigger( + object: ParseHookTriggerObject.object(GameScore()), + trigger: .beforeConnect, + url: url + ) + ) + XCTAssertThrowsError( + try ParseHookTrigger( + object: ParseHookTriggerObject.object(GameScore()), + trigger: .beforeLogin, + url: url + ) + ) + XCTAssertThrowsError( + try ParseHookTrigger( + object: ParseHookTriggerObject.object(GameScore()), + trigger: .afterLogin, + url: url + ) + ) + XCTAssertThrowsError( + try ParseHookTrigger( + object: ParseHookTriggerObject.object(GameScore()), + trigger: .afterLogout, + url: url + ) + ) + XCTAssertThrowsError( + try ParseHookTrigger( + object: ParseHookTriggerObject.file, + trigger: .beforeConnect, + url: url + ) + ) + XCTAssertThrowsError( + try ParseHookTrigger( + object: ParseHookTriggerObject.config, + trigger: .beforeConnect, + url: url + ) + ) + XCTAssertThrowsError( + try ParseHookTrigger( + object: ParseHookTriggerObject.liveQueryConnect, + trigger: .beforeFind, + url: url + ) + ) + } + @MainActor func testCreate() async throws { guard let url = URL(string: "https://api.example.com/foo") else {