Skip to content

Commit 3bc59ca

Browse files
authored
use HTTP GET for feature flags (#677)
* use HTTP GET for feature flags * use iPhone 16 Pro for CI --------- Co-authored-by: Mark Siebert <[email protected]>
1 parent 7459dd6 commit 3bc59ca

File tree

3 files changed

+223
-7
lines changed

3 files changed

+223
-7
lines changed

.github/workflows/iOS.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
runs-on: macos-latest
1212
strategy:
1313
matrix:
14-
destination: ['name=iPhone 15 Pro,OS=latest']
14+
destination: ['name=iPhone 16 Pro,OS=latest']
1515

1616
steps:
1717
- uses: actions/checkout@v2

MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ class MockFeatureFlagManager: FeatureFlagManager {
116116
var fetchRequestCount = 0
117117
private var fetchStartTime: Date?
118118

119+
// Request validation properties
120+
var requestValidationEnabled = false
121+
var lastRequestMethod: RequestMethod?
122+
var lastRequestHeaders: [String: String]?
123+
var lastRequestBody: Data?
124+
var lastQueryItems: [URLQueryItem]?
125+
var requestValidationError: String?
126+
119127
// Override the now-internal method to prevent real network calls
120128
override func _performFetchRequest() {
121129
fetchRequestCount += 1
@@ -127,6 +135,11 @@ class MockFeatureFlagManager: FeatureFlagManager {
127135
self?.fetchStartTime = startTime
128136
}
129137

138+
// If request validation is enabled, intercept and validate the request construction
139+
if requestValidationEnabled {
140+
validateRequestConstruction()
141+
}
142+
130143
// Instead of real network call, use simulated result
131144
if let result = simulatedFetchResult {
132145
if shouldSimulateNetworkDelay {
@@ -149,6 +162,56 @@ class MockFeatureFlagManager: FeatureFlagManager {
149162
}
150163
}
151164

165+
private func validateRequestConstruction() {
166+
// Clear previous validation error
167+
requestValidationError = nil
168+
169+
// Replicate the request construction logic from the real implementation to capture parameters
170+
guard let delegate = self.delegate else {
171+
requestValidationError = "Delegate missing"
172+
return
173+
}
174+
let options = delegate.getOptions()
175+
176+
let distinctId = delegate.getDistinctId()
177+
let anonymousId = delegate.getAnonymousId()
178+
179+
var context = options.featureFlagsContext
180+
context["distinct_id"] = distinctId
181+
if let anonymousId = anonymousId {
182+
context["device_id"] = anonymousId
183+
}
184+
185+
guard
186+
let contextData = try? JSONSerialization.data(
187+
withJSONObject: context, options: []),
188+
let contextString = String(data: contextData, encoding: .utf8)
189+
else {
190+
requestValidationError = "Failed to serialize context"
191+
return
192+
}
193+
194+
guard let authData = "\(options.token):".data(using: .utf8) else {
195+
requestValidationError = "Failed to create auth data"
196+
return
197+
}
198+
let base64Auth = authData.base64EncodedString()
199+
let headers = ["Authorization": "Basic \(base64Auth)"]
200+
201+
let queryItems = [
202+
URLQueryItem(name: "context", value: contextString),
203+
URLQueryItem(name: "token", value: options.token),
204+
URLQueryItem(name: "mp_lib", value: "swift"),
205+
URLQueryItem(name: "$lib_version", value: AutomaticProperties.libVersion())
206+
]
207+
208+
// Capture the constructed request parameters for validation
209+
lastRequestMethod = .get
210+
lastRequestHeaders = headers
211+
lastRequestBody = nil // GET request should have no body
212+
lastQueryItems = queryItems
213+
}
214+
152215
private func completeSimulatedFetch(
153216
success: Bool, flags: [String: MixpanelFlagVariant]?, startTime: Date
154217
) {
@@ -1427,4 +1490,148 @@ class FeatureFlagManagerTests: XCTestCase {
14271490
}
14281491
}
14291492

1493+
func testGETRequestFormat() {
1494+
// Use a fresh MockFeatureFlagManager with request validation enabled
1495+
let mockManager = MockFeatureFlagManager(serverURL: "https://api.mixpanel.com", delegate: mockDelegate)
1496+
mockManager.requestValidationEnabled = true
1497+
mockManager.simulatedFetchResult = (success: true, flags: sampleFlags)
1498+
1499+
// Trigger a request
1500+
mockManager.loadFlags()
1501+
1502+
// Wait for request to be processed
1503+
let expectation = XCTestExpectation(description: "Request validation completes")
1504+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { expectation.fulfill() }
1505+
wait(for: [expectation], timeout: 1.0)
1506+
1507+
// Verify no validation errors
1508+
XCTAssertNil(mockManager.requestValidationError, "Request validation should not have errors: \(mockManager.requestValidationError ?? "")")
1509+
1510+
// Verify GET method
1511+
XCTAssertEqual(mockManager.lastRequestMethod, .get, "Should use GET method")
1512+
1513+
// Verify headers
1514+
XCTAssertNotNil(mockManager.lastRequestHeaders, "Headers should be captured")
1515+
if let headers = mockManager.lastRequestHeaders {
1516+
XCTAssertTrue(headers.keys.contains("Authorization"), "Should include Authorization header")
1517+
XCTAssertTrue(headers["Authorization"]?.starts(with: "Basic ") == true, "Should use Basic auth")
1518+
XCTAssertFalse(headers.keys.contains("Content-Type"), "Should not include Content-Type header for GET")
1519+
}
1520+
1521+
// Verify no request body
1522+
XCTAssertNil(mockManager.lastRequestBody, "GET request should not have a body")
1523+
1524+
// Verify query parameters
1525+
XCTAssertNotNil(mockManager.lastQueryItems, "Query items should be captured")
1526+
if let queryItems = mockManager.lastQueryItems {
1527+
let queryDict = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name, $0.value) })
1528+
1529+
// Check required parameters
1530+
XCTAssertNotNil(queryDict["context"], "Should include context parameter")
1531+
XCTAssertEqual(queryDict["token"], "test", "Should include token parameter")
1532+
XCTAssertEqual(queryDict["mp_lib"], "swift", "Should include mp_lib parameter")
1533+
XCTAssertEqual(queryDict["$lib_version"], AutomaticProperties.libVersion(), "Should include $lib_version parameter")
1534+
1535+
// Verify context JSON structure
1536+
if let contextString = queryDict["context"],
1537+
let contextData = contextString?.data(using: .utf8),
1538+
let context = try? JSONSerialization.jsonObject(with: contextData) as? [String: Any] {
1539+
XCTAssertEqual(context["distinct_id"] as? String, "test_distinct_id", "Context should include distinct_id")
1540+
XCTAssertEqual(context["device_id"] as? String, "test_anonymous_id", "Context should include device_id")
1541+
} else {
1542+
XCTFail("Context should be valid JSON")
1543+
}
1544+
}
1545+
}
1546+
1547+
func testGETRequestWithCustomContext() {
1548+
// Set up custom context
1549+
let customOptions = MixpanelOptions(token: "custom-token", featureFlagsEnabled: true, featureFlagsContext: [
1550+
"user_id": "test-user-123",
1551+
"group_id": "test-group-456"
1552+
])
1553+
1554+
let customDelegate = MockFeatureFlagDelegate(
1555+
options: customOptions,
1556+
distinctId: "custom-distinct-id",
1557+
anonymousId: "custom-device-id"
1558+
)
1559+
1560+
let mockManager = MockFeatureFlagManager(serverURL: "https://api.mixpanel.com", delegate: customDelegate)
1561+
mockManager.requestValidationEnabled = true
1562+
mockManager.simulatedFetchResult = (success: true, flags: sampleFlags)
1563+
1564+
// Trigger a request
1565+
mockManager.loadFlags()
1566+
1567+
// Wait for request to be processed
1568+
let expectation = XCTestExpectation(description: "Custom context request validation")
1569+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { expectation.fulfill() }
1570+
wait(for: [expectation], timeout: 1.0)
1571+
1572+
// Verify no validation errors
1573+
XCTAssertNil(mockManager.requestValidationError, "Request validation should not have errors")
1574+
1575+
// Verify query parameters with custom context
1576+
XCTAssertNotNil(mockManager.lastQueryItems, "Query items should be captured")
1577+
if let queryItems = mockManager.lastQueryItems {
1578+
let queryDict = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name, $0.value) })
1579+
XCTAssertEqual(queryDict["token"], "custom-token", "Should include custom token")
1580+
// Verify context includes both standard and custom fields
1581+
if let contextString = queryDict["context"],
1582+
let contextData = contextString?.data(using: .utf8),
1583+
let context = try? JSONSerialization.jsonObject(with: contextData) as? [String: Any] {
1584+
1585+
XCTAssertEqual(context["distinct_id"] as? String, "custom-distinct-id", "Context should include distinct_id")
1586+
XCTAssertEqual(context["device_id"] as? String, "custom-device-id", "Context should include device_id")
1587+
XCTAssertEqual(context["user_id"] as? String, "test-user-123", "Context should include custom user_id")
1588+
XCTAssertEqual(context["group_id"] as? String, "test-group-456", "Context should include custom group_id")
1589+
} else {
1590+
XCTFail("Context should be valid JSON with custom fields")
1591+
}
1592+
}
1593+
}
1594+
1595+
func testGETRequestWithNilAnonymousId() {
1596+
// Set up with nil anonymous ID
1597+
let nilAnonymousDelegate = MockFeatureFlagDelegate(
1598+
options: MixpanelOptions(token: "test-token", featureFlagsEnabled: true),
1599+
distinctId: "test-distinct-id",
1600+
anonymousId: nil
1601+
)
1602+
1603+
let mockManager = MockFeatureFlagManager(serverURL: "https://api.mixpanel.com", delegate: nilAnonymousDelegate)
1604+
mockManager.requestValidationEnabled = true
1605+
mockManager.simulatedFetchResult = (success: true, flags: sampleFlags)
1606+
1607+
// Trigger a request
1608+
mockManager.loadFlags()
1609+
1610+
// Wait for request to be processed
1611+
let expectation = XCTestExpectation(description: "Nil anonymous ID request validation")
1612+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { expectation.fulfill() }
1613+
wait(for: [expectation], timeout: 1.0)
1614+
1615+
// Verify no validation errors
1616+
XCTAssertNil(mockManager.requestValidationError, "Request validation should not have errors with nil anonymous ID")
1617+
1618+
// Verify context excludes device_id when anonymous ID is nil
1619+
if let queryItems = mockManager.lastQueryItems {
1620+
let queryDict = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name, $0.value) })
1621+
1622+
if let contextString = queryDict["context"],
1623+
let contextData = contextString?.data(using: .utf8),
1624+
let context = try? JSONSerialization.jsonObject(with: contextData) as? [String: Any] {
1625+
1626+
XCTAssertEqual(context["distinct_id"] as? String, "test-distinct-id", "Context should include distinct_id")
1627+
XCTAssertNil(context["device_id"], "Context should not include device_id when anonymous ID is nil")
1628+
1629+
// Should only contain distinct_id (no additional context configured)
1630+
XCTAssertEqual(context.keys.count, 1, "Context should only contain distinct_id when no device_id or additional context")
1631+
} else {
1632+
XCTFail("Context should be valid JSON")
1633+
}
1634+
}
1635+
}
1636+
14301637
} // End Test Class

Sources/FeatureFlags.swift

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -436,31 +436,40 @@ class FeatureFlagManager: Network, MixpanelFlags {
436436
if let anonymousId = anonymousId {
437437
context["device_id"] = anonymousId
438438
}
439-
let requestBodyDict = ["context": context]
440439

441440
guard
442-
let requestBodyData = try? JSONSerialization.data(
443-
withJSONObject: requestBodyDict, options: [])
441+
let contextData = try? JSONSerialization.data(
442+
withJSONObject: context, options: []),
443+
let contextString = String(data: contextData, encoding: .utf8)
444444
else {
445-
print("Error: Failed to serialize request body for flags.")
445+
print("Error: Failed to serialize context for flags.")
446446
self._completeFetch(success: false)
447447
return
448448
}
449+
449450
guard let authData = "\(options.token):".data(using: .utf8) else {
450451
print("Error: Failed to create auth data.")
451452
self._completeFetch(success: false)
452453
return
453454
}
454455
let base64Auth = authData.base64EncodedString()
455-
let headers = ["Authorization": "Basic \(base64Auth)", "Content-Type": "application/json"]
456+
let headers = ["Authorization": "Basic \(base64Auth)"]
457+
458+
let queryItems = [
459+
URLQueryItem(name: "context", value: contextString),
460+
URLQueryItem(name: "token", value: options.token),
461+
URLQueryItem(name: "mp_lib", value: "swift"),
462+
URLQueryItem(name: "$lib_version", value: AutomaticProperties.libVersion())
463+
]
464+
456465
let responseParser: (Data) -> FlagsResponse? = { data in
457466
do { return try JSONDecoder().decode(FlagsResponse.self, from: data) } catch {
458467
print("Error parsing flags JSON: \(error)")
459468
return nil
460469
}
461470
}
462471
let resource = Network.buildResource(
463-
path: flagsRoute, method: .post, requestBody: requestBodyData, headers: headers,
472+
path: flagsRoute, method: .get, queryItems: queryItems, headers: headers,
464473
parse: responseParser)
465474

466475
// Make the API request

0 commit comments

Comments
 (0)