From ca7ed4ab85a40f8b61a5126a423442cf6a9fcbdc Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 13 May 2025 11:41:42 -0300 Subject: [PATCH] Custom TargetingRules decoder --- Split.xcodeproj/project.pbxproj | 18 ++ .../TargetingRulesChangeDecoder.swift | 61 ++++ .../RestClient/RestClient+SplitChanges.swift | 1 + SplitTests/RestClientSplitChangesTest.swift | 260 ++++++++++++++++++ .../TargetingRulesChangeDecoderTest.swift | 110 ++++++++ 5 files changed, 450 insertions(+) create mode 100644 Split/Models/SplitModel/TargetingRulesChangeDecoder.swift create mode 100644 SplitTests/RestClientSplitChangesTest.swift create mode 100644 SplitTests/TargetingRulesChangeDecoderTest.swift diff --git a/Split.xcodeproj/project.pbxproj b/Split.xcodeproj/project.pbxproj index f371a5a1..bffd4c5d 100644 --- a/Split.xcodeproj/project.pbxproj +++ b/Split.xcodeproj/project.pbxproj @@ -1093,6 +1093,11 @@ C539CAD72D8B5AD00050C732 /* TargetingRulesChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CAD32D8B5AD00050C732 /* TargetingRulesChange.swift */; }; C539CAD92D8B5AF00050C732 /* ProcessedRuleBasedSegmentChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CAD82D8B5AF00050C732 /* ProcessedRuleBasedSegmentChange.swift */; }; C539CADA2D8B5AF00050C732 /* ProcessedRuleBasedSegmentChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CAD82D8B5AF00050C732 /* ProcessedRuleBasedSegmentChange.swift */; }; + C53EDFA92DD295E5000DCDBC /* TargetingRulesChangeDecoderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFA82DD295E5000DCDBC /* TargetingRulesChangeDecoderTest.swift */; }; + C53EDFAB2DD29640000DCDBC /* TargetingRulesChangeDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFAA2DD29640000DCDBC /* TargetingRulesChangeDecoder.swift */; }; + C53EDFAC2DD29640000DCDBC /* TargetingRulesChangeDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFAA2DD29640000DCDBC /* TargetingRulesChangeDecoder.swift */; }; + C53EDFAE2DD299CB000DCDBC /* RestClientSplitChangesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFAD2DD299CB000DCDBC /* RestClientSplitChangesTest.swift */; }; + C53EDFB02DD38572000DCDBC /* RestClientCustomDecoderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFAF2DD38572000DCDBC /* RestClientCustomDecoderTest.swift */; }; C53F3C472DCB956900655753 /* SplitsSyncHelperTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53F3C462DCB956900655753 /* SplitsSyncHelperTest.swift */; }; C53F3C4F2DCD112400655753 /* RuleBasedSegmentChangeProcessorStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53F3C4E2DCD110700655753 /* RuleBasedSegmentChangeProcessorStub.swift */; }; C58F33732BDAC4AC00D66549 /* split_unsupported_matcher.json in Resources */ = {isa = PBXBuildFile; fileRef = C58F33722BDAC4AC00D66549 /* split_unsupported_matcher.json */; }; @@ -1956,6 +1961,10 @@ C539CAD22D8B5AD00050C732 /* RuleBasedSegmentChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBasedSegmentChange.swift; sourceTree = ""; }; C539CAD32D8B5AD00050C732 /* TargetingRulesChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetingRulesChange.swift; sourceTree = ""; }; C539CAD82D8B5AF00050C732 /* ProcessedRuleBasedSegmentChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessedRuleBasedSegmentChange.swift; sourceTree = ""; }; + C53EDFA82DD295E5000DCDBC /* TargetingRulesChangeDecoderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetingRulesChangeDecoderTest.swift; sourceTree = ""; }; + C53EDFAA2DD29640000DCDBC /* TargetingRulesChangeDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetingRulesChangeDecoder.swift; sourceTree = ""; }; + C53EDFAD2DD299CB000DCDBC /* RestClientSplitChangesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestClientSplitChangesTest.swift; sourceTree = ""; }; + C53EDFAF2DD38572000DCDBC /* RestClientCustomDecoderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestClientCustomDecoderTest.swift; sourceTree = ""; }; C53F3C462DCB956900655753 /* SplitsSyncHelperTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitsSyncHelperTest.swift; sourceTree = ""; }; C53F3C4E2DCD110700655753 /* RuleBasedSegmentChangeProcessorStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBasedSegmentChangeProcessorStub.swift; sourceTree = ""; }; C58F33722BDAC4AC00D66549 /* split_unsupported_matcher.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = split_unsupported_matcher.json; sourceTree = ""; }; @@ -2347,6 +2356,7 @@ 3B6DEEB420EA6AE20067435E /* SplitModel */ = { isa = PBXGroup; children = ( + C53EDFAA2DD29640000DCDBC /* TargetingRulesChangeDecoder.swift */, C539CAD22D8B5AD00050C732 /* RuleBasedSegmentChange.swift */, C539CAD32D8B5AD00050C732 /* TargetingRulesChange.swift */, 3B6DEEB520EA6AE20067435E /* Split.swift */, @@ -2833,6 +2843,9 @@ 592C6AA6211B6C99002D120C /* SplitTests */ = { isa = PBXGroup; children = ( + C53EDFAF2DD38572000DCDBC /* RestClientCustomDecoderTest.swift */, + C53EDFAD2DD299CB000DCDBC /* RestClientSplitChangesTest.swift */, + C53EDFA82DD295E5000DCDBC /* TargetingRulesChangeDecoderTest.swift */, C5977C0E2BF2832A003E293A /* Matcher */, 95ABF4B1292FDE60006ED016 /* Collections */, 95ABF4B0292FDDC0006ED016 /* Init */, @@ -4187,6 +4200,7 @@ 3B6DEF6320EA6AE50067435E /* ContainsStringMatcher.swift in Sources */, 955A5A51258D04DF00CAEE9F /* PeriodicRecorderWorker.swift in Sources */, 95F3F06725ACDE0400084AF8 /* EmptyMySegmentsStorage.swift in Sources */, + C53EDFAB2DD29640000DCDBC /* TargetingRulesChangeDecoder.swift in Sources */, 59B2042924F42DE80092F2E9 /* SseNotificationProcessor.swift in Sources */, 3B6DEF6D20EA6AE50067435E /* MatchesStringMatcher.swift in Sources */, 3B6DEF3420EA6AE50067435E /* Splitter.swift in Sources */, @@ -4533,6 +4547,7 @@ 95F3F006258D49B600084AF8 /* PersistentEventsStorageStub.swift in Sources */, C53F3C4F2DCD112400655753 /* RuleBasedSegmentChangeProcessorStub.swift in Sources */, 5959C477227B89820064F968 /* FactoryMonitorTest.swift in Sources */, + C53EDFA92DD295E5000DCDBC /* TargetingRulesChangeDecoderTest.swift in Sources */, 592C6ABE211B718E002D120C /* ArrayBlockingQueueTest.swift in Sources */, 5919017D24A28E1D005BD12A /* HttpRequestListTest.swift in Sources */, 593225F824A384B300496D8B /* EndpointFactoryTest.swift in Sources */, @@ -4572,10 +4587,12 @@ 59F4AAC12513C2EC00A1C69A /* PeriodicTimerStub.swift in Sources */, 9580C98226FB8A840021D729 /* StreamingControlResetTest.swift in Sources */, 95737E162ADEC794007FD15C /* StreamingTestingHelper.swift in Sources */, + C53EDFB02DD38572000DCDBC /* RestClientCustomDecoderTest.swift in Sources */, 594F5F58253A340F00A945B4 /* StreamingOccupancyTest.swift in Sources */, 59B2047624F9B8230092F2E9 /* PushManagerEventBroadcasterTest.swift in Sources */, 95F3F0302590E4BE00084AF8 /* HttpImpressionsRecorderStub.swift in Sources */, C5977C2C2BF2C068003E293A /* LessThanOrEqualToSemverMatcherTest.swift in Sources */, + C53EDFAE2DD299CB000DCDBC /* RestClientSplitChangesTest.swift in Sources */, 954F9C5C25780FDD00140B81 /* HttpMySegmentsFetcherTest.swift in Sources */, 59F4AA9924FE8DA200A1C69A /* NotificationProcessorStub.swift in Sources */, 595AD21324E1849500A7B750 /* JwtParserStub.swift in Sources */, @@ -4974,6 +4991,7 @@ 95B02D8928D0BDC30030EC8B /* HttpTask.swift in Sources */, 95B02D8A28D0BDC30030EC8B /* HttpDataRequest.swift in Sources */, 95B02D8B28D0BDC30030EC8B /* HttpStreamResponse.swift in Sources */, + C53EDFAC2DD29640000DCDBC /* TargetingRulesChangeDecoder.swift in Sources */, 95B02D8C28D0BDC30030EC8B /* HttpStreamRequest.swift in Sources */, 95B02D8D28D0BDC30030EC8B /* HttpError.swift in Sources */, 95B02D8E28D0BDC30030EC8B /* HttpRequestList.swift in Sources */, diff --git a/Split/Models/SplitModel/TargetingRulesChangeDecoder.swift b/Split/Models/SplitModel/TargetingRulesChangeDecoder.swift new file mode 100644 index 00000000..cb5b36ee --- /dev/null +++ b/Split/Models/SplitModel/TargetingRulesChangeDecoder.swift @@ -0,0 +1,61 @@ +// +// TargetingRulesChangeDecoder.swift +// Split +// +// Created on 12/05/2025. +// Copyright © 2025 Split. All rights reserved. +// + +import Foundation + +class TargetingRulesChangeDecoder { + + /// Decodes a JSON response into a TargetingRulesChange object. + /// This decoder can handle both the new TargetingRulesChange format and the legacy SplitChange format. + /// If the legacy format is detected, it will create a TargetingRulesChange with the SplitChange data + /// and an empty RuleBasedSegmentChange. + /// + /// - Parameter data: The JSON data to decode + /// - Returns: A TargetingRulesChange object + /// - Throws: Decoding errors if the JSON cannot be parsed + static func decode(from data: Data) throws -> TargetingRulesChange { + // First try to decode as TargetingRulesChange + do { + let decoder = JSONDecoder() + return try decoder.decode(TargetingRulesChange.self, from: data) + } catch { + // If that fails, try to handle the legacy format with full keys (splits, since, till) + do { + let splitChangeJson = try JSONSerialization.jsonObject(with: data, options: []) + + if let jsonDict = splitChangeJson as? [String: Any], + let splitsArray = jsonDict["splits"], + let since = jsonDict["since"] as? Int64, + let till = jsonDict["till"] as? Int64 { + + // Convert to the format with short keys that SplitChange expects + var newJsonDict = [String: Any]() + newJsonDict["d"] = splitsArray + newJsonDict["s"] = since + newJsonDict["t"] = till + + let newJsonData = try JSONSerialization.data(withJSONObject: newJsonDict, options: []) + let decoder = JSONDecoder() + let splitChange = try decoder.decode(SplitChange.self, from: newJsonData) + + // Create a TargetingRulesChange with the SplitChange data and an empty RuleBasedSegmentChange + return TargetingRulesChange( + featureFlags: splitChange, + ruleBasedSegments: RuleBasedSegmentChange.empty() + ) + } + + // If we get here, no format matched + throw error + } catch { + // If all decodings fail, throw the original error + throw error + } + } + } +} diff --git a/Split/Network/RestClient/RestClient+SplitChanges.swift b/Split/Network/RestClient/RestClient+SplitChanges.swift index c55ab8cd..3abd5d19 100644 --- a/Split/Network/RestClient/RestClient+SplitChanges.swift +++ b/Split/Network/RestClient/RestClient+SplitChanges.swift @@ -26,6 +26,7 @@ extension DefaultRestClient: RestClientSplitChanges { endpoint: endpointFactory.splitChangesEndpoint, parameters: buildParameters(since: since, rbSince: rbSince, till: till), headers: headers, + customDecoder: TargetingRulesChangeDecoder.decode, completion: completion) } diff --git a/SplitTests/RestClientSplitChangesTest.swift b/SplitTests/RestClientSplitChangesTest.swift new file mode 100644 index 00000000..73c435c5 --- /dev/null +++ b/SplitTests/RestClientSplitChangesTest.swift @@ -0,0 +1,260 @@ +// +// RestClientSplitChangesTest.swift +// SplitTests +// +// Created on 12/05/2025. +// Copyright © 2025 Split. All rights reserved. +// + +import XCTest +@testable import Split + +class RestClientSplitChangesTest: XCTestCase { + + var httpClient: HttpClient! + var httpSession: HttpSessionMock! + var requestManager: HttpRequestManagerMock! + var factory: EndpointFactory! + var serviceEndpoints: ServiceEndpoints! + var restClient: DefaultRestClient! + + override func setUp() { + httpSession = HttpSessionMock() + requestManager = HttpRequestManagerMock() + httpClient = DefaultHttpClient(session: httpSession, requestManager: requestManager) + serviceEndpoints = ServiceEndpoints.builder().build() + factory = EndpointFactory(serviceEndpoints: serviceEndpoints, apiKey: "dummy-api-key", splitsQueryString: "") + restClient = DefaultRestClient(httpClient: httpClient, endpointFactory: factory) + } + + func testGetSplitChangesWithTargetingRulesFormat() { + let json = """ + {"ff": {"d":[{"name": "test_split", "trafficTypeName": "user", "status": "active"}], "s": 1000, "t": 1001}, + "rbs": {"d":[{"name": "test_segment", "trafficTypeName": "user", "changeNumber": 1000, "status": "active"}], "s": 500, "t": 501}} + """ + + let dummyData = Data(json.utf8) + let expectation = XCTestExpectation(description: "API call completes") + var result: TargetingRulesChange? + var error: Error? + + restClient.getSplitChanges(since: 1000, rbSince: 500, till: nil, headers: nil) { dataResult in + do { + result = try dataResult.unwrap() + expectation.fulfill() + } catch let err { + error = err + expectation.fulfill() + } + } + + requestManager.append(data: dummyData, to: 1) + _ = requestManager.set(responseCode: 200, to: 1) + + wait(for: [expectation], timeout: 1) + + XCTAssertEqual(1, httpSession.dataTaskCallCount) + XCTAssertEqual(1, requestManager.addRequestCallCount) + XCTAssertNil(error) + XCTAssertNotNil(result) + XCTAssertEqual(result?.featureFlags.since, 1000) + XCTAssertEqual(result?.featureFlags.till, 1001) + XCTAssertEqual(result?.featureFlags.splits.count, 1) + XCTAssertEqual(result?.featureFlags.splits[0].name, "test_split") + + XCTAssertEqual(result?.ruleBasedSegments.since, 500) + XCTAssertEqual(result?.ruleBasedSegments.till, 501) + XCTAssertEqual(result?.ruleBasedSegments.segments.count, 1) + XCTAssertEqual(result?.ruleBasedSegments.segments[0].name, "test_segment") + XCTAssertTrue(requestManager.request.url.absoluteString.contains("rbSince=500")) + } + + func testGetSplitChangesWithLegacyFullKeyFormat() { + let json = """ + { + "splits": [ + { + "trafficTypeName": "account", + "name": "test_split", + "trafficAllocation": 59, + "trafficAllocationSeed": -2108186082, + "seed": -1947050785, + "status": "ACTIVE", + "killed": false, + "defaultTreatment": "off", + "changeNumber": 1506703262916, + "algo": 2, + "conditions": [ + { + "conditionType": "WHITELIST", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": null, + "matcherType": "WHITELIST", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": { + "whitelist": [ + "nico_test", + "othertest" + ] + }, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 100 + } + ], + "label": "whitelisted" + }, + { + "conditionType": "WHITELIST", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": null, + "matcherType": "WHITELIST", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": { + "whitelist": [ + "bla" + ] + }, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "off", + "size": 100 + } + ], + "label": "whitelisted" + }, + { + "conditionType": "ROLLOUT", + "matcherGroup": { + "combiner": "AND", + "matchers": [ + { + "keySelector": { + "trafficType": "account", + "attribute": null + }, + "matcherType": "ALL_KEYS", + "negate": false, + "userDefinedSegmentMatcherData": null, + "whitelistMatcherData": null, + "unaryNumericMatcherData": null, + "betweenMatcherData": null, + "booleanMatcherData": null, + "dependencyMatcherData": null, + "stringMatcherData": null + } + ] + }, + "partitions": [ + { + "treatment": "on", + "size": 0 + }, + { + "treatment": "off", + "size": 100 + }, + { + "treatment": "visa", + "size": 0 + } + ], + "label": "in segment all" + } + ] + } + ], + "since": 1000, + "till": 1001 + } + """ + + let dummyData = Data(json.utf8) + let expectation = XCTestExpectation(description: "API call completes") + var result: TargetingRulesChange? + var error: Error? + + restClient.getSplitChanges(since: 1000, rbSince: 500, till: nil, headers: nil) { dataResult in + do { + result = try dataResult.unwrap() + expectation.fulfill() + } catch let err { + error = err + expectation.fulfill() + } + } + + // Simulate response + requestManager.append(data: dummyData, to: 1) + _ = requestManager.set(responseCode: 200, to: 1) + + wait(for: [expectation], timeout: 1) + + XCTAssertEqual(1, httpSession.dataTaskCallCount) + XCTAssertEqual(1, requestManager.addRequestCallCount) + XCTAssertNil(error) + XCTAssertNotNil(result) + XCTAssertEqual(result?.featureFlags.since, 1000) + XCTAssertEqual(result?.featureFlags.till, 1001) + XCTAssertEqual(result?.featureFlags.splits.count, 1) + XCTAssertEqual(result?.featureFlags.splits[0].name, "test_split") + + XCTAssertEqual(result?.ruleBasedSegments.since, -1) + XCTAssertEqual(result?.ruleBasedSegments.till, -1) + XCTAssertEqual(result?.ruleBasedSegments.segments.count, 0) + + XCTAssertTrue(requestManager.request.url.absoluteString.contains("rbSince=500")) + } + + func testGetSplitChangesWithError() { + let expectation = XCTestExpectation(description: "API call completes with error") + var result: TargetingRulesChange? + var error: Error? + + restClient.getSplitChanges(since: 1000, rbSince: 500, till: nil, headers: nil) { dataResult in + do { + result = try dataResult.unwrap() + expectation.fulfill() + } catch let err { + error = err + expectation.fulfill() + } + } + + let mockError = HttpError.unknown(code: 500, message: "Test error") + requestManager.complete(taskIdentifier: 1, error: mockError) + + wait(for: [expectation], timeout: 1) + + XCTAssertEqual(1, httpSession.dataTaskCallCount) + XCTAssertEqual(1, requestManager.addRequestCallCount) + XCTAssertNil(result) + XCTAssertNotNil(error) + XCTAssertEqual(500, (error as? HttpError)?.code) + } +} diff --git a/SplitTests/TargetingRulesChangeDecoderTest.swift b/SplitTests/TargetingRulesChangeDecoderTest.swift new file mode 100644 index 00000000..15c0acc9 --- /dev/null +++ b/SplitTests/TargetingRulesChangeDecoderTest.swift @@ -0,0 +1,110 @@ +// +// TargetingRulesChangeDecoderTest.swift +// SplitTests +// +// Created on 12/05/2025. +// Copyright © 2025 Split. All rights reserved. +// + +import XCTest +@testable import Split + +class TargetingRulesChangeDecoderTest: XCTestCase { + + func testDecodeTargetingRulesChange() { + // Given + let json = """ + { + "ff": { + "s": 1000, + "t": 1001, + "d": [ + { + "name": "test_split", + "trafficTypeName": "user", + "status": "active" + } + ] + }, + "rbs": { + "s": 500, + "t": 501, + "d": [ + { + "name": "test_segment", + "trafficTypeName": "user", + "status": "active" + } + ] + } + } + """ + + // When + let data = json.data(using: .utf8)! + let result = try? TargetingRulesChangeDecoder.decode(from: data) + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result?.featureFlags.since, 1000) + XCTAssertEqual(result?.featureFlags.till, 1001) + XCTAssertEqual(result?.featureFlags.splits.count, 1) + XCTAssertEqual(result?.featureFlags.splits[0].name, "test_split") + + XCTAssertEqual(result?.ruleBasedSegments.since, 500) + XCTAssertEqual(result?.ruleBasedSegments.till, 501) + XCTAssertEqual(result?.ruleBasedSegments.segments.count, 1) + XCTAssertEqual(result?.ruleBasedSegments.segments[0].name, "test_segment") + } + + func testDecodeLegacySplitChangeWithFullKeys() { + // Given + let json = """ + { + "since": 1000, + "till": 1001, + "splits": [ + { + "name": "test_split", + "trafficTypeName": "user", + "status": "active" + } + ] + } + """ + + // When + let data = json.data(using: .utf8)! + let result = try? TargetingRulesChangeDecoder.decode(from: data) + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result?.featureFlags.since, 1000) + XCTAssertEqual(result?.featureFlags.till, 1001) + XCTAssertEqual(result?.featureFlags.splits.count, 1) + XCTAssertEqual(result?.featureFlags.splits[0].name, "test_split") + + // Verify that an empty RuleBasedSegmentChange was created + XCTAssertEqual(result?.ruleBasedSegments.since, -1) + XCTAssertEqual(result?.ruleBasedSegments.till, -1) + XCTAssertEqual(result?.ruleBasedSegments.segments.count, 0) + } + + + + func testDecodeInvalidJson() { + // Given + let json = """ + { + "invalid": "json" + } + """ + + // When + let data = json.data(using: .utf8)! + let result = try? TargetingRulesChangeDecoder.decode(from: data) + + // Then + XCTAssertNil(result) + } +}