diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 62dee985..bc8c718c 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2092,6 +2092,24 @@ 98F28A2C2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; }; 98F28A2E2E01968000A86546 /* CmabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A2D2E01968000A86546 /* CmabTests.swift */; }; 98F28A3E2E01AC0700A86546 /* CmabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A2D2E01968000A86546 /* CmabTests.swift */; }; + 98F28A412E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A422E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A432E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A442E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A452E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A462E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A472E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A482E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A492E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4A2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4B2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4C2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4D2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4E2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A4F2E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A502E02DD6D00A86546 /* CmabClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A402E02DD6D00A86546 /* CmabClient.swift */; }; + 98F28A522E02E81500A86546 /* CMABClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A512E02E81500A86546 /* CMABClientTests.swift */; }; + 98F28A532E02E81500A86546 /* CMABClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A512E02E81500A86546 /* CMABClientTests.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2545,6 +2563,8 @@ 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Holdouts.swift; sourceTree = ""; }; 98F28A1C2E01940500A86546 /* Cmab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cmab.swift; sourceTree = ""; }; 98F28A2D2E01968000A86546 /* CmabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabTests.swift; sourceTree = ""; }; + 98F28A402E02DD6D00A86546 /* CmabClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabClient.swift; sourceTree = ""; }; + 98F28A512E02E81500A86546 /* CMABClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMABClientTests.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -2781,6 +2801,7 @@ 6E75165D22C520D400B2B157 /* Sources */ = { isa = PBXGroup; children = ( + 98F28A3F2E02DD4D00A86546 /* CMAB */, 6E75166622C520D400B2B157 /* Optimizely */, 6EC6DD3F24ABF8180017D296 /* Optimizely+Decide */, 6E75165E22C520D400B2B157 /* Customization */, @@ -3076,6 +3097,7 @@ 84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */, 8486180E286D0B8900B7F41B /* OdpManagerTests.swift */, 8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */, + 98F28A512E02E81500A86546 /* CMABClientTests.swift */, 8486180F286D0B8900B7F41B /* VuidManagerTests.swift */, 84861819286D188B00B7F41B /* OdpSegmentApiManagerTests.swift */, 8486181A286D188B00B7F41B /* OdpEventApiManagerTests.swift */, @@ -3244,6 +3266,14 @@ name = Frameworks; sourceTree = ""; }; + 98F28A3F2E02DD4D00A86546 /* CMAB */ = { + isa = PBXGroup; + children = ( + 98F28A402E02DD6D00A86546 /* CmabClient.swift */, + ); + path = CMAB; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -4315,6 +4345,7 @@ 6E14CD842423F9A100010234 /* BatchEventBuilder.swift in Sources */, 6E14CD6E2423F85E00010234 /* EventDispatcherTests_Batch.swift in Sources */, 6E14CDA92423F9C300010234 /* Utils.swift in Sources */, + 98F28A472E02DD6D00A86546 /* CmabClient.swift in Sources */, 6EF8DE1F24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E14CD882423F9A100010234 /* AttributeValue.swift in Sources */, 84E2E9492852A378001114AB /* VuidManager.swift in Sources */, @@ -4381,6 +4412,7 @@ 6E424CF726324B620081004A /* DefaultDecisionService.swift in Sources */, 6E424CF826324B620081004A /* DecisionReasons.swift in Sources */, 6E424CF926324B620081004A /* DecisionResponse.swift in Sources */, + 98F28A412E02DD6D00A86546 /* CmabClient.swift in Sources */, 84E2E9782855875E001114AB /* OdpEventManager.swift in Sources */, 6E424CFA26324B620081004A /* DataStoreMemory.swift in Sources */, 6E424CFB26324B620081004A /* DataStoreUserDefaults.swift in Sources */, @@ -4519,6 +4551,7 @@ 6E75177322C520D400B2B157 /* SDKVersion.swift in Sources */, 6E75179722C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, 6E7518DD22C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 98F28A482E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75187D22C520D400B2B157 /* TrafficAllocation.swift in Sources */, 98F28A252E01940500A86546 /* Cmab.swift in Sources */, C78CAFA524486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, @@ -4636,6 +4669,7 @@ 6E75195C22C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E7518E422C520D400B2B157 /* ConditionLeaf.swift in Sources */, 6E7518F022C520D500B2B157 /* ConditionHolder.swift in Sources */, + 98F28A442E02DD6D00A86546 /* CmabClient.swift in Sources */, 6EF8DE2424BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E75183022C520D400B2B157 /* BatchEvent.swift in Sources */, 84E2E94E2852A378001114AB /* VuidManager.swift in Sources */, @@ -4692,6 +4726,7 @@ 6E7517CC22C520D400B2B157 /* DefaultBucketer.swift in Sources */, 6E75178E22C520D400B2B157 /* OptimizelyClient+Extension.swift in Sources */, 6E75172E22C520D400B2B157 /* Constants.swift in Sources */, + 98F28A422E02DD6D00A86546 /* CmabClient.swift in Sources */, 84E7ABC327D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E9B11E022C548A200C22D81 /* OptimizelyClientTests_Group.swift in Sources */, 6E75187422C520D400B2B157 /* Variation.swift in Sources */, @@ -4819,6 +4854,7 @@ 6E75175522C520D400B2B157 /* LogMessage.swift in Sources */, C78CAF602445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 6E623F0B253F9045000617D0 /* DecisionInfo.swift in Sources */, + 98F28A462E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75193722C520D500B2B157 /* OPTDataStore.swift in Sources */, 6E75191322C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 84E7ABC627D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, @@ -4951,6 +4987,7 @@ 6E7516AF22C520D400B2B157 /* DefaultLogger.swift in Sources */, 6EF8DE2524BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */, + 98F28A532E02E81500A86546 /* CMABClientTests.swift in Sources */, 6E75194522C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E75185522C520D400B2B157 /* ProjectConfig.swift in Sources */, 84F6BAB427FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift in Sources */, @@ -5002,6 +5039,7 @@ 6E7518CD22C520D400B2B157 /* Audience.swift in Sources */, 980CC90C2D833F2800E07D24 /* ExperimentCore.swift in Sources */, 84E2E96E28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, + 98F28A4E2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E9B117322C5487100C22D81 /* BatchEventBuilderTests_Attributes.swift in Sources */, 6E9B11B622C5489600C22D81 /* OTUtils.swift in Sources */, 6E75183122C520D400B2B157 /* BatchEvent.swift in Sources */, @@ -5188,6 +5226,7 @@ 6E7516F822C520D400B2B157 /* OptimizelyError.swift in Sources */, 84B4D75E27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 848617E82863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, + 98F28A4F2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E424C09263228FD0081004A /* AtomicDictionary.swift in Sources */, 6E75189E22C520D400B2B157 /* Experiment.swift in Sources */, 6E75178822C520D400B2B157 /* ArrayEventForDispatch+Extension.swift in Sources */, @@ -5237,6 +5276,7 @@ 6E75182B22C520D400B2B157 /* BatchEvent.swift in Sources */, 6EF8DE1E24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */, + 98F28A522E02E81500A86546 /* CMABClientTests.swift in Sources */, 6E75190322C520D500B2B157 /* Attribute.swift in Sources */, 6E75192722C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516F122C520D400B2B157 /* OptimizelyError.swift in Sources */, @@ -5288,6 +5328,7 @@ 6E7517BF22C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, 6E9B115922C5486E00C22D81 /* BatchEventBuilderTests_Attributes.swift in Sources */, 6E9B11AA22C5489200C22D81 /* OTUtils.swift in Sources */, + 98F28A432E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E7518D322C520D400B2B157 /* AttributeValue.swift in Sources */, 6E0A72D426C5B9AE00FF92B5 /* OptimizelyUserContextTests_ForcedDecisions.swift in Sources */, 6EF41A332522BE1900EAADF1 /* OptimizelyUserContextTests_Decide.swift in Sources */, @@ -5414,6 +5455,7 @@ 6E5D12242638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */, 6E75183922C520D400B2B157 /* EventForDispatch.swift in Sources */, 6E9B118A22C5488100C22D81 /* ExperimentTests.swift in Sources */, + 98F28A492E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E7516E722C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75181522C520D400B2B157 /* DataStoreQueueStackImpl.swift in Sources */, 6EF8DE2124BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, @@ -5522,6 +5564,7 @@ 6E7517E622C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171822C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 98F28A452E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75174822C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 84E2E94C2852A378001114AB /* VuidManager.swift in Sources */, 6E7518FA22C520D500B2B157 /* UserAttribute.swift in Sources */, @@ -5629,6 +5672,7 @@ 6E7517EB22C520D400B2B157 /* DefaultDecisionService.swift in Sources */, 984FE51D2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171D22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 98F28A4A2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E75174D22C520D400B2B157 /* HandlerRegistryService.swift in Sources */, 84E2E9512852A378001114AB /* VuidManager.swift in Sources */, 6E7518FF22C520D500B2B157 /* UserAttribute.swift in Sources */, @@ -5744,6 +5788,7 @@ 6E75188822C520D400B2B157 /* Project.swift in Sources */, 6E7518D022C520D400B2B157 /* AttributeValue.swift in Sources */, 6E75181C22C520D400B2B157 /* BatchEventBuilder.swift in Sources */, + 98F28A4C2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6E7518DC22C520D400B2B157 /* ConditionLeaf.swift in Sources */, 98F28A1F2E01940500A86546 /* Cmab.swift in Sources */, C78CAFA424486E0A009FE876 /* OptimizelyJSON+ObjC.swift in Sources */, @@ -5861,6 +5906,7 @@ 6E75188A22C520D400B2B157 /* Project.swift in Sources */, 6E75195622C520D500B2B157 /* OPTBucketer.swift in Sources */, 6E7518DE22C520D400B2B157 /* ConditionLeaf.swift in Sources */, + 98F28A4B2E02DD6D00A86546 /* CmabClient.swift in Sources */, 6EF8DE1D24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E7518EA22C520D400B2B157 /* ConditionHolder.swift in Sources */, 84E2E9462852A378001114AB /* VuidManager.swift in Sources */, @@ -5993,6 +6039,7 @@ 75C71A4125E454460084187E /* MurmurHash3.swift in Sources */, 848617ED2863E21200B7F41B /* OdpEventApiManager.swift in Sources */, 848617FE286CF33700B7F41B /* OdpEvent.swift in Sources */, + 98F28A502E02DD6D00A86546 /* CmabClient.swift in Sources */, 75C71A4225E454460084187E /* HandlerRegistryService.swift in Sources */, 75C71A4325E454460084187E /* LogMessage.swift in Sources */, 75C71A4425E454460084187E /* AtomicProperty.swift in Sources */, @@ -6049,6 +6096,7 @@ BD6485572491474500F30986 /* Project.swift in Sources */, BD6485582491474500F30986 /* AttributeValue.swift in Sources */, BD6485592491474500F30986 /* BatchEventBuilder.swift in Sources */, + 98F28A4D2E02DD6D00A86546 /* CmabClient.swift in Sources */, BD64855A2491474500F30986 /* ConditionLeaf.swift in Sources */, 98F28A222E01940500A86546 /* Cmab.swift in Sources */, BD64855B2491474500F30986 /* OptimizelyJSON+ObjC.swift in Sources */, diff --git a/Sources/CMAB/CmabClient.swift b/Sources/CMAB/CmabClient.swift new file mode 100644 index 00000000..3444cf6e --- /dev/null +++ b/Sources/CMAB/CmabClient.swift @@ -0,0 +1,194 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// 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 CmabClientError: Error, Equatable { + case fetchFailed(String) + case invalidResponse + + var message: String { + switch self { + case .fetchFailed(let message): + return message + case .invalidResponse: + return "Invalid response from CMA-B server" + + } + } +} + +struct CmabRetryConfig { + var maxRetries: Int = 3 + var initialBackoff: TimeInterval = 0.1 // seconds + var maxBackoff: TimeInterval = 10.0 // seconds + var backoffMultiplier: Double = 2.0 +} + +protocol CmabClient { + func fetchDecision( + ruleId: String, + userId: String, + attributes: [String: Any], + cmabUUID: String, + completion: @escaping (Result) -> Void + ) +} + +class DefaultCmabClient: CmabClient { + let session: URLSession + let retryConfig: CmabRetryConfig + let maxWaitTime: TimeInterval + let cmabQueue = DispatchQueue(label: "com.optimizley.cmab") + let logger = OPTLoggerFactory.getLogger() + + init(session: URLSession = .shared, + retryConfig: CmabRetryConfig = CmabRetryConfig(), + maxWaitTime: TimeInterval = 10.0 + ) { + self.session = session + self.retryConfig = retryConfig + self.maxWaitTime = maxWaitTime + } + + func fetchDecision( + ruleId: String, + userId: String, + attributes: [String: Any], + cmabUUID: String, + completion: @escaping (Result) -> Void + ) { + let urlString = "https://prediction.cmab.optimizely.com/predict/\(ruleId)" + guard let url = URL(string: urlString) else { + completion(.failure(CmabClientError.fetchFailed("Invalid URL"))) + return + } + let attrType = "custom_attribute" + let cmabAttributes = attributes.map { (key, value) in + ["id": key, "value": value, "type": attrType] + } + + let requestBody: [String: Any] = [ + "instances": [[ + "visitorId": userId, + "experimentId": ruleId, + "attributes": cmabAttributes, + "cmabUUID": cmabUUID + ]] + ] + + doFetchWithRetry( + url: url, + requestBody: requestBody, + timeout: maxWaitTime, + completion: completion + ) + } + + private func doFetchWithRetry( + url: URL, + requestBody: [String: Any], + timeout: TimeInterval, + completion: @escaping (Result) -> Void + ) { + var attempt = 0 + var backoff = retryConfig.initialBackoff + + func attemptFetch() { + doFetch(url: url, requestBody: requestBody, timeout: timeout) { result in + switch result { + case .success(let variationId): + completion(.success(variationId)) + case .failure(let error): + self.logger.e((error as? CmabClientError)?.message ?? "") + if let cmabError = error as? CmabClientError { + if case .invalidResponse = cmabError { + // Don't retry on invalid response + completion(.failure(cmabError)) + return + } + } + if attempt < self.retryConfig.maxRetries { + attempt += 1 + self.cmabQueue.asyncAfter(deadline: .now() + backoff) { + backoff = min(backoff * pow(self.retryConfig.backoffMultiplier, Double(attempt)), self.retryConfig.maxBackoff) + attemptFetch() + } + } else { + completion(.failure(CmabClientError.fetchFailed("Exhausted all retries for CMAB request. Last error: \(error)"))) + } + } + } + } + attemptFetch() + } + + private func doFetch( + url: URL, + requestBody: [String: Any], + timeout: TimeInterval, + completion: @escaping (Result) -> Void + ) { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody, options: []) else { + completion(.failure(CmabClientError.fetchFailed("Failed to encode request body"))) + return + } + request.httpBody = httpBody + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(CmabClientError.fetchFailed(error.localizedDescription))) + return + } + guard let httpResponse = response as? HTTPURLResponse, let data = data, (200...299).contains(httpResponse.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + completion(.failure(CmabClientError.fetchFailed("HTTP error code: \(code)"))) + return + } + do { + if + let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + self.validateResponse(body: json), + let predictions = json["predictions"] as? [[String: Any]], + let variationId = predictions.first?["variation_id"] as? String + { + completion(.success(variationId)) + } else { + completion(.failure(CmabClientError.invalidResponse)) + } + } catch { + completion(.failure(CmabClientError.invalidResponse)) + } + } + task.resume() + } + + private func validateResponse(body: [String: Any]) -> Bool { + if + let predictions = body["predictions"] as? [[String: Any]], + predictions.count > 0, + predictions.first?["variation_id"] != nil + { + return true + } + return false + } +} diff --git a/Tests/OptimizelyTests-Common/CMABClientTests.swift b/Tests/OptimizelyTests-Common/CMABClientTests.swift new file mode 100644 index 00000000..6a98502e --- /dev/null +++ b/Tests/OptimizelyTests-Common/CMABClientTests.swift @@ -0,0 +1,278 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// 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 + +class DefaultCmabClientTests: XCTestCase { + var client: DefaultCmabClient! + var mockSession: MockURLSession! + var shortRetryConfig: CmabRetryConfig! + + override func setUp() { + super.setUp() + mockSession = MockURLSession() + shortRetryConfig = CmabRetryConfig(maxRetries: 2, initialBackoff: 0.01, maxBackoff: 0.05, backoffMultiplier: 1.0) + client = DefaultCmabClient(session: mockSession, retryConfig: shortRetryConfig) + } + + override func tearDown() { + client = nil + mockSession = nil + shortRetryConfig = nil + super.tearDown() + } + + // MARK: - Helpers + + func makeSuccessResponse(variationId: String) -> (Data, URLResponse, Error?) { + let json: [String: Any] = [ + "predictions": [ + ["variation_id": variationId] + ] + ] + let data = try! JSONSerialization.data(withJSONObject: json, options: []) + let response = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response, nil) + } + + func makeFailureResponse() -> (Data, URLResponse, Error?) { + let response = HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data(), response, nil) + } + + // MARK: - Test Cases + + func testFetchDecision_SuccessOnFirstTry() { + let (successData, successResponse, _) = makeSuccessResponse(variationId: "variation-123") + mockSession.responses = [(successData, successResponse, nil)] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .success(variationId) = result { + XCTAssertEqual(variationId, "variation-123") + XCTAssertEqual(self.mockSession.callCount, 1) + } else { + XCTFail("Expected success result") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testFetchDecision_SuccessOnSecondTry() { + let (successData, successResponse, _) = makeSuccessResponse(variationId: "variation-retry") + let fail = makeFailureResponse() + mockSession.responses = [fail, (successData, successResponse, nil)] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .success(variationId) = result { + XCTAssertEqual(variationId, "variation-retry") + XCTAssertEqual(self.mockSession.callCount, 2) + } else { + XCTFail("Expected success after retry") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_SuccessOnThirdTry() { + let (successData, successResponse, _) = makeSuccessResponse(variationId: "success-third") + let fail = makeFailureResponse() + mockSession.responses = [fail, fail, (successData, successResponse, nil)] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .success(variationId) = result { + XCTAssertEqual(variationId, "success-third") + XCTAssertEqual(self.mockSession.callCount, 3) + } else { + XCTFail("Expected success after two retries") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_ExhaustsAllRetries() { + let fail = makeFailureResponse() + mockSession.responses = [fail, fail, fail] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .failure(error) = result { + XCTAssertTrue("\(error)".contains("Exhausted all retries")) + XCTAssertEqual(self.mockSession.callCount, 3) + } else { + XCTFail("Expected failure after all retries") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_HttpError() { + mockSession.responses = [ + (Data(), HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 500, httpVersion: nil, headerFields: nil), nil) + ] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .failure(error) = result { + XCTAssertTrue("\(error)".contains("HTTP error code")) + } else { + XCTFail("Expected failure on HTTP error") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_InvalidJson() { + mockSession.responses = [ + (Data("not a json".utf8), HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil), nil) + ] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid" + ) { result in + if case let .failure(error) = result { + XCTAssertTrue(error is CmabClientError) + XCTAssertEqual(self.mockSession.callCount, 1) + } else { + XCTFail("Expected failure on invalid JSON") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testFetchDecision_Invalid_Response_Structure() { + let responseJSON: [String: Any] = [ "not_predictions": [] ] + let responseData = try! JSONSerialization.data(withJSONObject: responseJSON, options: []) + mockSession.responses = [ + (responseData, HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!, + statusCode: 200, httpVersion: nil, headerFields: nil), nil) + ] + + let expectation = self.expectation(description: "Completion called") + client.fetchDecision( + ruleId: "abc", userId: "user1", + attributes: ["foo": "bar"], + cmabUUID: "uuid-1234" + ) { result in + if case let .failure(error) = result { + XCTAssertEqual(error as? CmabClientError, .invalidResponse) + XCTAssertEqual(self.mockSession.callCount, 1) + } else { + XCTFail("Expected failure on invalid response structure") + } + self.verifyRequest(ruleId: "abc", userId: "user1", attributes: ["foo": "bar"], cmabUUID: "uuid-1234") + expectation.fulfill() + } + waitForExpectations(timeout: 2) + + } + + private func verifyRequest(ruleId: String, userId: String, attributes: [String: Any], cmabUUID: String) { + // Assert request body + guard let request = mockSession.lastRequest else { + XCTFail("No request was sent") + return + } + guard let body = request.httpBody else { + XCTFail("No HTTP body in request") + return + } + + let json = try! JSONSerialization.jsonObject(with: body, options: []) as! [String: Any] + let instances = json["instances"] as? [[String: Any]] + XCTAssertNotNil(instances) + let instance = instances?.first + XCTAssertEqual(instance?["visitorId"] as? String, userId) + XCTAssertEqual(instance?["experimentId"] as? String, ruleId) + XCTAssertEqual(instance?["cmabUUID"] as? String, cmabUUID) + // You can add further assertions for the attributes, e.g.: + let payloadAttributes = instance?["attributes"] as? [[String: Any]] + XCTAssertEqual(payloadAttributes?.first?["id"] as? String, attributes.keys.first) + XCTAssertEqual(payloadAttributes?.first?["value"] as? String, attributes.values.first as? String) + XCTAssertEqual(payloadAttributes?.first?["type"] as? String, "custom_attribute") + } + +} + +// MARK: - MockURLSession for ordered responses + +extension DefaultCmabClientTests { + class MockURLSessionDataTask: URLSessionDataTask { + private let closure: () -> Void + override var state: URLSessionTask.State { .completed } + init(closure: @escaping () -> Void) { self.closure = closure } + override func resume() { closure() } + } + + class MockURLSession: URLSession { + typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void + var responses: [(Data?, URLResponse?, Error?)] = [] + var callCount = 0 + var lastRequest: URLRequest? + + override func dataTask( + with request: URLRequest, + completionHandler: @escaping CompletionHandler + ) -> URLSessionDataTask { + self.lastRequest = request + let idx = callCount + callCount += 1 + let tuple = idx < responses.count ? responses[idx] : (nil, nil, nil) + return MockURLSessionDataTask { completionHandler(tuple.0, tuple.1, tuple.2) } + } + } +}