diff --git a/Package.swift b/Package.swift index da614fe..21f44bc 100644 --- a/Package.swift +++ b/Package.swift @@ -6,10 +6,10 @@ import PackageDescription let package = Package( name: "CloudKitFeatureToggles", platforms: [ - .iOS(SupportedPlatform.IOSVersion.v10), - .macOS(SupportedPlatform.MacOSVersion.v10_12), - .tvOS(SupportedPlatform.TVOSVersion.v9), - .watchOS(SupportedPlatform.WatchOSVersion.v3) + .iOS(.v13), + .macOS(.v10_12), + .tvOS(.v9), + .watchOS(.v3) ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. diff --git a/README.md b/README.md index ae4581f..674c01e 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ And don't forget to add the dependency to your target(s). | Field | Type | | --- | --- | -| `featureName` | `String` | -| `isActive` | `Int64` | +| `name` | `String` | +| `value` | `Any` | For each feature toggle you want to support in your application later add a new record in your CloudKit *public database*. diff --git a/Sources/CloudKitFeatureToggles/FeatureToggleApplicationService.swift b/Sources/CloudKitFeatureToggles/FeatureToggleApplicationService.swift index acc66c7..fa48d3d 100644 --- a/Sources/CloudKitFeatureToggles/FeatureToggleApplicationService.swift +++ b/Sources/CloudKitFeatureToggles/FeatureToggleApplicationService.swift @@ -61,12 +61,17 @@ extension FeatureToggleApplicationService: UIApplicationDelegate { return true } - public func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo), let subscriptionID = notification.subscriptionID else { - return + public func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult { + guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo), + let subscriptionID = notification.subscriptionID else { + return .noData + } + + return await withCheckedContinuation { continuation in + handleRemoteNotification(subscriptionID: subscriptionID) { result in + continuation.resume(returning: result) + } } - - handleRemoteNotification(subscriptionID: subscriptionID, completionHandler: completionHandler) } } #endif diff --git a/Sources/CloudKitFeatureToggles/FeatureToggleMapper.swift b/Sources/CloudKitFeatureToggles/FeatureToggleMapper.swift index 404537e..626fd31 100644 --- a/Sources/CloudKitFeatureToggles/FeatureToggleMapper.swift +++ b/Sources/CloudKitFeatureToggles/FeatureToggleMapper.swift @@ -8,19 +8,24 @@ import Foundation import CloudKit +public enum FeatureToggleValue: Equatable { + case integer(Int) + case string(String) +} + public protocol FeatureToggleRepresentable { var identifier: String { get } - var isActive: Bool { get } + var value: FeatureToggleValue { get } } public protocol FeatureToggleIdentifiable { var identifier: String { get } - var fallbackValue: Bool { get } + var fallbackValue: FeatureToggleValue { get } } public struct FeatureToggle: FeatureToggleRepresentable, Equatable { public let identifier: String - public let isActive: Bool + public let value: FeatureToggleValue } protocol FeatureToggleMappable { @@ -29,18 +34,55 @@ protocol FeatureToggleMappable { class FeatureToggleMapper: FeatureToggleMappable { private let featureToggleNameFieldID: String - private let featureToggleIsActiveFieldID: String + private let featureToggleIntValueFieldID: String + private let featureToggleStringValueFieldID: String - init(featureToggleNameFieldID: String, featureToggleIsActiveFieldID: String) { + init(featureToggleNameFieldID: String, featureToggleIntValueFieldID: String, featureToggleStringValueFieldID: String) { self.featureToggleNameFieldID = featureToggleNameFieldID - self.featureToggleIsActiveFieldID = featureToggleIsActiveFieldID + self.featureToggleIntValueFieldID = featureToggleIntValueFieldID + self.featureToggleStringValueFieldID = featureToggleStringValueFieldID } func map(record: CKRecord) -> FeatureToggle? { - guard let isActive = record[featureToggleIsActiveFieldID] as? Int64, let featureName = record[featureToggleNameFieldID] as? String else { + guard let featureName = record[featureToggleNameFieldID] as? String else { return nil } - return FeatureToggle(identifier: featureName, isActive: NSNumber(value: isActive).boolValue) + return if let value = record[featureToggleIntValueFieldID] as? Int { + FeatureToggle(identifier: featureName, value: .integer(value)) + } else if let value = record[featureToggleStringValueFieldID] as? String { + FeatureToggle(identifier: featureName, value: .string(value)) + } else { + nil + } + } +} + +public extension FeatureToggleRepresentable { + var intValue: Int { + switch value { + case .integer(let int): + int + default: + fatalError("Int value used on non-int feature type") + } + } + + var stringValue: String { + switch value { + case .string(let string): + string + default: + fatalError("String value used on non-string feature type") + } + } + + var boolValue: Bool { + switch value { + case .integer(let i): + i != 0 + case .string(let s): + (s as NSString).boolValue + } } } diff --git a/Sources/CloudKitFeatureToggles/FeatureToggleRepository.swift b/Sources/CloudKitFeatureToggles/FeatureToggleRepository.swift index ffb4163..563d46e 100644 --- a/Sources/CloudKitFeatureToggles/FeatureToggleRepository.swift +++ b/Sources/CloudKitFeatureToggles/FeatureToggleRepository.swift @@ -26,12 +26,28 @@ public class FeatureToggleUserDefaultsRepository { extension FeatureToggleUserDefaultsRepository: FeatureToggleRepository { public func retrieve(identifiable: FeatureToggleIdentifiable) -> FeatureToggleRepresentable { - let isActive = defaults.value(forKey: identifiable.identifier) as? Bool + let storedValue = defaults.value(forKey: identifiable.identifier) + let value: FeatureToggleValue - return FeatureToggle(identifier: identifiable.identifier, isActive: isActive ?? identifiable.fallbackValue) + switch storedValue { + + case let int as Int: + value = .integer(int) + case let string as String: + value = .string(string) + default: + value = identifiable.fallbackValue + } + + return FeatureToggle(identifier: identifiable.identifier, value: value) } public func save(featureToggle: FeatureToggleRepresentable) { - defaults.set(featureToggle.isActive, forKey: featureToggle.identifier) + switch featureToggle.value { + case .integer(let intValue): + defaults.set(intValue, forKey: featureToggle.identifier) + case .string(let stringValue): + defaults.set(stringValue, forKey: featureToggle.identifier) + } } } diff --git a/Sources/CloudKitFeatureToggles/FeatureToggleSubscriptor.swift b/Sources/CloudKitFeatureToggles/FeatureToggleSubscriptor.swift index c82fbc2..58b68c7 100644 --- a/Sources/CloudKitFeatureToggles/FeatureToggleSubscriptor.swift +++ b/Sources/CloudKitFeatureToggles/FeatureToggleSubscriptor.swift @@ -22,9 +22,9 @@ class FeatureToggleSubscriptor: CloudKitSubscriptionProtocol { let subscriptionID = "cloudkit-recordType-FeatureToggle" let database: CloudKitDatabaseConformable - init(toggleRepository: FeatureToggleRepository = FeatureToggleUserDefaultsRepository(), toggleMapper: FeatureToggleMappable? = nil, featureToggleRecordID: String = "FeatureStatus", featureToggleNameFieldID: String = "featureName", featureToggleIsActiveFieldID: String = "isActive", defaults: UserDefaults = UserDefaults(suiteName: FeatureToggleSubscriptor.defaultsSuiteName) ?? .standard, notificationCenter: NotificationCenter = .default, cloudKitDatabaseConformable: CloudKitDatabaseConformable = CKContainer.default().publicCloudDatabase) { + init(toggleRepository: FeatureToggleRepository = FeatureToggleUserDefaultsRepository(), toggleMapper: FeatureToggleMappable? = nil, featureToggleRecordID: String = "FeatureStatus", featureToggleNameFieldID: String = "name", featureToggleIntValueFieldID: String = "intValue", featureToggleStringValueFieldID: String = "stringValue", defaults: UserDefaults = UserDefaults(suiteName: FeatureToggleSubscriptor.defaultsSuiteName) ?? .standard, notificationCenter: NotificationCenter = .default, cloudKitDatabaseConformable: CloudKitDatabaseConformable = CKContainer.default().publicCloudDatabase) { self.toggleRepository = toggleRepository - self.toggleMapper = toggleMapper ?? FeatureToggleMapper(featureToggleNameFieldID: featureToggleNameFieldID, featureToggleIsActiveFieldID: featureToggleIsActiveFieldID) + self.toggleMapper = toggleMapper ?? FeatureToggleMapper(featureToggleNameFieldID: featureToggleNameFieldID, featureToggleIntValueFieldID: featureToggleIntValueFieldID, featureToggleStringValueFieldID: featureToggleStringValueFieldID) self.featureToggleRecordID = featureToggleRecordID self.defaults = defaults self.notificationCenter = notificationCenter diff --git a/Tests/CloudKitFeatureTogglesTests/FeatureToggleMapperTests.swift b/Tests/CloudKitFeatureTogglesTests/FeatureToggleMapperTests.swift index a174253..9e2bee6 100644 --- a/Tests/CloudKitFeatureTogglesTests/FeatureToggleMapperTests.swift +++ b/Tests/CloudKitFeatureTogglesTests/FeatureToggleMapperTests.swift @@ -14,7 +14,7 @@ class FeatureToggleMapperTests: XCTestCase { var subject: FeatureToggleMappable! override func setUp() { - subject = FeatureToggleMapper(featureToggleNameFieldID: "featureName", featureToggleIsActiveFieldID: "isActive") + subject = FeatureToggleMapper(featureToggleNameFieldID: "name", featureToggleIntValueFieldID: "intValue", featureToggleStringValueFieldID: "stringValue") } func testMapInvalidInput() { @@ -32,53 +32,47 @@ class FeatureToggleMapperTests: XCTestCase { let wrongIsActiveField = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier3")) wrongIsActiveField["bla"] = true - wrongIsActiveField["featureName"] = 1283765 + wrongIsActiveField["name"] = 1283765 XCTAssertNil(subject.map(record: wrongIsActiveField)) let wrongFeatureNameField = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier4")) - wrongFeatureNameField["isActive"] = true + wrongFeatureNameField["value"] = true wrongFeatureNameField["muh"] = 1283765 XCTAssertNil(subject.map(record: wrongFeatureNameField)) - let wrongIsActiveType = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier5")) - wrongIsActiveType["isActive"] = "true" - wrongIsActiveType["featureName"] = "1283765" - - XCTAssertNil(subject.map(record: wrongIsActiveType)) - let wrongFeatureNameType = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier6")) - wrongFeatureNameType["isActive"] = true - wrongFeatureNameType["featureName"] = 1283765 + wrongFeatureNameType["value"] = true + wrongFeatureNameType["name"] = 1283765 XCTAssertNil(subject.map(record: wrongFeatureNameType)) } func testMap() { let expectedIdentifier = "1283765" - let expectedIsActive = true + let expectedValue: FeatureToggleValue = .integer(1) let record = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier")) - record["isActive"] = expectedIsActive - record["featureName"] = expectedIdentifier + record["intValue"] = true + record["name"] = expectedIdentifier let result = subject.map(record: record) XCTAssertNotNil(result) - XCTAssertEqual(result, FeatureToggle(identifier: expectedIdentifier, isActive: expectedIsActive)) + XCTAssertEqual(result, FeatureToggle(identifier: expectedIdentifier, value: expectedValue)) } func testMap2() { let expectedIdentifier = "akjshgdjaskd(/(/&%$ยง" - let expectedIsActive = false + let expectedValue: FeatureToggleValue = .integer(0) let record = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier")) - record["isActive"] = expectedIsActive - record["featureName"] = expectedIdentifier + record["intValue"] = false + record["name"] = expectedIdentifier let result = subject.map(record: record) XCTAssertNotNil(result) - XCTAssertEqual(result, FeatureToggle(identifier: expectedIdentifier, isActive: expectedIsActive)) + XCTAssertEqual(result, FeatureToggle(identifier: expectedIdentifier, value: expectedValue)) } static var allTests = [ diff --git a/Tests/CloudKitFeatureTogglesTests/FeatureToggleRepositoryTests.swift b/Tests/CloudKitFeatureTogglesTests/FeatureToggleRepositoryTests.swift index ae61e77..29715fa 100644 --- a/Tests/CloudKitFeatureTogglesTests/FeatureToggleRepositoryTests.swift +++ b/Tests/CloudKitFeatureTogglesTests/FeatureToggleRepositoryTests.swift @@ -15,12 +15,12 @@ class FeatureToggleRepositoryTests: XCTestCase { return self.rawValue } - var fallbackValue: Bool { + var fallbackValue: FeatureToggleValue { switch self { case .feature1: - return false + return .integer(0) case .feature2: - return true + return .integer(1) } } @@ -52,25 +52,25 @@ class FeatureToggleRepositoryTests: XCTestCase { } func testRetrieveBeforeSave() { - XCTAssertEqual(subject.retrieve(identifiable: TestToggle.feature1).isActive, TestToggle.feature1.fallbackValue) - XCTAssertEqual(subject.retrieve(identifiable: TestToggle.feature2).isActive, TestToggle.feature2.fallbackValue) + XCTAssertEqual(subject.retrieve(identifiable: TestToggle.feature1).value, TestToggle.feature1.fallbackValue) + XCTAssertEqual(subject.retrieve(identifiable: TestToggle.feature2).value, TestToggle.feature2.fallbackValue) - XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature1).isActive) - subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature1.rawValue, isActive: true)) - XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).isActive) + XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature1).value == .integer(1)) + subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature1.rawValue, value: .integer(1))) + XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).value == .integer(1)) } func testSaveAndRetrieve() { - XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature1).isActive) - XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature2).isActive) + XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature1).value == .integer(1)) + XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature2).value == .integer(1)) - subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature1.rawValue, isActive: true)) - XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).isActive) - XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature2).isActive) + subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature1.rawValue, value: .integer(1))) + XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).value == .integer(1)) + XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature2).value == .integer(1)) - subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature2.rawValue, isActive: false)) - XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).isActive) - XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature2).isActive) + subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature2.rawValue, value: .integer(0))) + XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).value == .integer(1)) + XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature2).value == .integer(1)) } static var allTests = [ diff --git a/Tests/CloudKitFeatureTogglesTests/FeatureToggleSubscriptorTests.swift b/Tests/CloudKitFeatureTogglesTests/FeatureToggleSubscriptorTests.swift index e00f77b..5111ad3 100644 --- a/Tests/CloudKitFeatureTogglesTests/FeatureToggleSubscriptorTests.swift +++ b/Tests/CloudKitFeatureTogglesTests/FeatureToggleSubscriptorTests.swift @@ -25,7 +25,7 @@ class FeatureToggleSubscriptorTests: XCTestCase { cloudKitDatabase = MockCloudKitDatabaseConformable() repository = MockToggleRepository() - subject = FeatureToggleSubscriptor(toggleRepository: repository, featureToggleRecordID: "TestFeatureStatus", featureToggleNameFieldID: "toggleName", featureToggleIsActiveFieldID: "isActive", defaults: defaults, cloudKitDatabaseConformable: cloudKitDatabase) + subject = FeatureToggleSubscriptor(toggleRepository: repository, featureToggleRecordID: "TestFeatureStatus", featureToggleNameFieldID: "name", featureToggleIntValueFieldID: "intValue", featureToggleStringValueFieldID: "stringValue", defaults: defaults, cloudKitDatabaseConformable: cloudKitDatabase) } override func tearDown() { @@ -38,8 +38,8 @@ class FeatureToggleSubscriptorTests: XCTestCase { XCTAssertNil(cloudKitDatabase.recordType) XCTAssertEqual(repository.toggles.count, 0) - cloudKitDatabase.recordFetched["isActive"] = 1 - cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + cloudKitDatabase.recordFetched["intValue"] = 1 + cloudKitDatabase.recordFetched["name"] = "Toggle1" subject.fetchAll() @@ -49,10 +49,10 @@ class FeatureToggleSubscriptorTests: XCTestCase { return } XCTAssertEqual(toggle.identifier, "Toggle1") - XCTAssertTrue(toggle.isActive) + XCTAssertEqual(toggle.value, .integer(1)) - cloudKitDatabase.recordFetched["isActive"] = 0 - cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + cloudKitDatabase.recordFetched["intValue"] = 0 + cloudKitDatabase.recordFetched["name"] = "Toggle1" subject.fetchAll() @@ -63,7 +63,7 @@ class FeatureToggleSubscriptorTests: XCTestCase { return } XCTAssertEqual(toggle2.identifier, "Toggle1") - XCTAssertFalse(toggle2.isActive) + XCTAssertEqual(toggle2.value, .integer(0)) } func testFetchAllError() { @@ -72,8 +72,8 @@ class FeatureToggleSubscriptorTests: XCTestCase { XCTAssertNil(cloudKitDatabase.recordType) XCTAssertEqual(repository.toggles.count, 0) - cloudKitDatabase.recordFetched["isActive"] = 1 - cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + cloudKitDatabase.recordFetched["intValue"] = 1 + cloudKitDatabase.recordFetched["name"] = "Toggle1" subject.fetchAll() @@ -89,8 +89,8 @@ class FeatureToggleSubscriptorTests: XCTestCase { return toggles.count == 1 } - cloudKitDatabase.recordFetched["isActive"] = 1 - cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + cloudKitDatabase.recordFetched["intValue"] = 1 + cloudKitDatabase.recordFetched["name"] = "Toggle1" subject.fetchAll() wait(for: [expectation], timeout: 0.1) @@ -149,8 +149,8 @@ class FeatureToggleSubscriptorTests: XCTestCase { XCTAssertEqual(cloudKitDatabase.addCalledCount, 0) XCTAssertEqual(repository.toggles.count, 0) - cloudKitDatabase.recordFetched["isActive"] = 1 - cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + cloudKitDatabase.recordFetched["intValue"] = 1 + cloudKitDatabase.recordFetched["name"] = "Toggle1" subject.handleNotification() @@ -162,10 +162,10 @@ class FeatureToggleSubscriptorTests: XCTestCase { return } XCTAssertEqual(toggle.identifier, "Toggle1") - XCTAssertTrue(toggle.isActive) + XCTAssertEqual(toggle.value, .integer(1)) - cloudKitDatabase.recordFetched["isActive"] = 0 - cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + cloudKitDatabase.recordFetched["intValue"] = 0 + cloudKitDatabase.recordFetched["name"] = "Toggle1" subject.handleNotification() @@ -178,7 +178,7 @@ class FeatureToggleSubscriptorTests: XCTestCase { return } XCTAssertEqual(toggle2.identifier, "Toggle1") - XCTAssertFalse(toggle2.isActive) + XCTAssertEqual(toggle2.value, .integer(0)) } func testHandleNotificationSendNotification() { @@ -189,8 +189,8 @@ class FeatureToggleSubscriptorTests: XCTestCase { return toggles.count == 1 } - cloudKitDatabase.recordFetched["isActive"] = 1 - cloudKitDatabase.recordFetched["toggleName"] = "Toggle1" + cloudKitDatabase.recordFetched["intValue"] = 1 + cloudKitDatabase.recordFetched["name"] = "Toggle1" subject.handleNotification() wait(for: [expectation], timeout: 0.1) @@ -239,13 +239,13 @@ class MockToggleRepository: FeatureToggleRepository { func retrieve(identifiable: FeatureToggleIdentifiable) -> FeatureToggleRepresentable { toggles.first { (representable) -> Bool in representable.identifier == identifiable.identifier - } ?? MockToggleRepresentable(identifier: identifiable.identifier, isActive: identifiable.fallbackValue) + } ?? MockToggleRepresentable(identifier: identifiable.identifier, value: identifiable.fallbackValue) } } struct MockToggleRepresentable: FeatureToggleRepresentable { var identifier: String - var isActive: Bool + var value: FeatureToggleValue } class MockCloudKitDatabaseConformable: CloudKitDatabaseConformable {