Skip to content

Commit e55d1e6

Browse files
committed
feat: add customHeaders closure for injecting request headers
Introduces a public `customHeaders` closure property on `Flagsmith` that allows consumers to inject arbitrary HTTP headers into every SDK request. The closure is invoked fresh on every request, so dynamic values (OAuth Bearer tokens, request correlation IDs, custom telemetry, etc.) are always up to date. Use cases this unblocks: - Authenticating through a gateway/proxy that requires custom auth headers (e.g. OAuth Bearer) that aren't the `X-Environment-Key` - Adding tracing/correlation IDs to requests for observability - Injecting A/B testing or user-agent headers for downstream systems Backward compatible — when `customHeaders` is nil (the default), behavior is unchanged. Changes: - Flagsmith.swift: add public `customHeaders` closure property - APIManager.swift: invoke closure and apply headers to every request - SSEManager.swift: same for SSE connection requests - CustomHeadersTests.swift: add tests covering invocation, nil, and per-request freshness
1 parent c5d8181 commit e55d1e6

4 files changed

Lines changed: 94 additions & 0 deletions

File tree

FlagsmithClient/Classes/Flagsmith.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@ public final class Flagsmith: @unchecked Sendable {
5858
}
5959
}
6060

61+
/// Custom HTTP headers to inject into every Flagsmith request.
62+
///
63+
/// The closure is invoked **fresh** on every request, so dynamic values
64+
/// (like OAuth Bearer tokens that get refreshed) are always up to date.
65+
///
66+
/// Example — inject an Authorization header:
67+
/// ```swift
68+
/// Flagsmith.shared.customHeaders = { [weak tokenStore] in
69+
/// guard let token = tokenStore?.tokens?.accessToken else { return [:] }
70+
/// return ["Authorization": "Bearer \(token)"]
71+
/// }
72+
/// ```
73+
public var customHeaders: (@Sendable () -> [String: String])?
74+
6175
/// Is flag analytics enabled?
6276
public var enableAnalytics: Bool {
6377
get { analytics.enableAnalytics }

FlagsmithClient/Classes/Internal/APIManager.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable {
142142
return
143143
}
144144

145+
// Inject custom headers (called fresh per request to support dynamic values like OAuth tokens)
146+
if let headers = Flagsmith.shared.customHeaders?() {
147+
for (key, value) in headers {
148+
request.setValue(value, forHTTPHeaderField: key)
149+
}
150+
}
151+
145152
// set the cache policy based on Flagsmith settings
146153
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
147154
if Flagsmith.shared.cacheConfig.useCache {

FlagsmithClient/Classes/Internal/SSEManager.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ final class SSEManager: NSObject, URLSessionDataDelegate, @unchecked Sendable {
158158
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
159159
request.setValue("keep-alive", forHTTPHeaderField: "Connection")
160160

161+
// Inject custom headers (called fresh per request to support dynamic values like OAuth tokens)
162+
if let headers = Flagsmith.shared.customHeaders?() {
163+
for (key, value) in headers {
164+
request.setValue(value, forHTTPHeaderField: key)
165+
}
166+
}
167+
161168
completionHandler = completion
162169
dataTask = session.dataTask(with: request)
163170
dataTask?.resume()
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//
2+
// CustomHeadersTests.swift
3+
// FlagsmithClientTests
4+
//
5+
6+
@testable import FlagsmithClient
7+
import XCTest
8+
9+
final class CustomHeadersTests: FlagsmithClientTestCase {
10+
override func tearDown() {
11+
super.tearDown()
12+
Flagsmith.shared.customHeaders = nil
13+
}
14+
15+
/// Verify the `customHeaders` closure is invoked when a request is made.
16+
func testCustomHeadersClosureIsInvoked() throws {
17+
let closureInvoked = expectation(description: "customHeaders closure invoked")
18+
19+
Flagsmith.shared.customHeaders = {
20+
closureInvoked.fulfill()
21+
return ["X-Test-Header": "value"]
22+
}
23+
Flagsmith.shared.apiKey = "mock-test-api-key"
24+
// Force a quick failure so the request doesn't hang
25+
Flagsmith.shared.baseURL = URL(fileURLWithPath: "/dev/null")
26+
27+
Flagsmith.shared.getFeatureFlags { _ in }
28+
29+
wait(for: [closureInvoked], timeout: 1.0)
30+
}
31+
32+
/// Verify that when `customHeaders` is nil, requests still work normally.
33+
func testNilCustomHeadersDoesNotCrash() throws {
34+
Flagsmith.shared.customHeaders = nil
35+
Flagsmith.shared.apiKey = "mock-test-api-key"
36+
Flagsmith.shared.baseURL = URL(fileURLWithPath: "/dev/null")
37+
38+
let requestFinished = expectation(description: "Request finished without crash")
39+
40+
Flagsmith.shared.getFeatureFlags { _ in
41+
requestFinished.fulfill()
42+
}
43+
44+
wait(for: [requestFinished], timeout: 1.0)
45+
}
46+
47+
/// Verify the closure is invoked on every request (not cached).
48+
func testCustomHeadersClosureInvokedEveryRequest() throws {
49+
var invocationCount = 0
50+
Flagsmith.shared.customHeaders = {
51+
invocationCount += 1
52+
return [:]
53+
}
54+
Flagsmith.shared.apiKey = "mock-test-api-key"
55+
Flagsmith.shared.baseURL = URL(fileURLWithPath: "/dev/null")
56+
57+
let firstRequest = expectation(description: "First request")
58+
let secondRequest = expectation(description: "Second request")
59+
60+
Flagsmith.shared.getFeatureFlags { _ in firstRequest.fulfill() }
61+
Flagsmith.shared.getFeatureFlags { _ in secondRequest.fulfill() }
62+
63+
wait(for: [firstRequest, secondRequest], timeout: 2.0)
64+
XCTAssertEqual(invocationCount, 2, "customHeaders should be invoked for every request")
65+
}
66+
}

0 commit comments

Comments
 (0)