diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index b6bd426..74bea8f 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -58,6 +58,20 @@ public final class Flagsmith: @unchecked Sendable { } } + /// Custom HTTP headers to inject into every Flagsmith request. + /// + /// The closure is invoked **fresh** on every request, so dynamic values + /// (like OAuth Bearer tokens that get refreshed) are always up to date. + /// + /// Example — inject an Authorization header: + /// ```swift + /// Flagsmith.shared.customHeaders = { [weak tokenStore] in + /// guard let token = tokenStore?.tokens?.accessToken else { return [:] } + /// return ["Authorization": "Bearer \(token)"] + /// } + /// ``` + public var customHeaders: (@Sendable () -> [String: String])? + /// Is flag analytics enabled? public var enableAnalytics: Bool { get { analytics.enableAnalytics } diff --git a/FlagsmithClient/Classes/Internal/APIManager.swift b/FlagsmithClient/Classes/Internal/APIManager.swift index 38622fd..d0ee6a9 100644 --- a/FlagsmithClient/Classes/Internal/APIManager.swift +++ b/FlagsmithClient/Classes/Internal/APIManager.swift @@ -142,6 +142,13 @@ final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { return } + // Inject custom headers (called fresh per request to support dynamic values like OAuth tokens) + if let headers = Flagsmith.shared.customHeaders?() { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + // set the cache policy based on Flagsmith settings request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData if Flagsmith.shared.cacheConfig.useCache { diff --git a/FlagsmithClient/Classes/Internal/SSEManager.swift b/FlagsmithClient/Classes/Internal/SSEManager.swift index b760aad..2d0dbc4 100644 --- a/FlagsmithClient/Classes/Internal/SSEManager.swift +++ b/FlagsmithClient/Classes/Internal/SSEManager.swift @@ -158,6 +158,13 @@ final class SSEManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") request.setValue("keep-alive", forHTTPHeaderField: "Connection") + // Inject custom headers (called fresh per request to support dynamic values like OAuth tokens) + if let headers = Flagsmith.shared.customHeaders?() { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + completionHandler = completion dataTask = session.dataTask(with: request) dataTask?.resume() diff --git a/FlagsmithClient/Tests/CustomHeadersTests.swift b/FlagsmithClient/Tests/CustomHeadersTests.swift new file mode 100644 index 0000000..1586a78 --- /dev/null +++ b/FlagsmithClient/Tests/CustomHeadersTests.swift @@ -0,0 +1,66 @@ +// +// CustomHeadersTests.swift +// FlagsmithClientTests +// + +@testable import FlagsmithClient +import XCTest + +final class CustomHeadersTests: FlagsmithClientTestCase { + override func tearDown() { + super.tearDown() + Flagsmith.shared.customHeaders = nil + } + + /// Verify the `customHeaders` closure is invoked when a request is made. + func testCustomHeadersClosureIsInvoked() throws { + let closureInvoked = expectation(description: "customHeaders closure invoked") + + Flagsmith.shared.customHeaders = { + closureInvoked.fulfill() + return ["X-Test-Header": "value"] + } + Flagsmith.shared.apiKey = "mock-test-api-key" + // Force a quick failure so the request doesn't hang + Flagsmith.shared.baseURL = URL(fileURLWithPath: "/dev/null") + + Flagsmith.shared.getFeatureFlags { _ in } + + wait(for: [closureInvoked], timeout: 1.0) + } + + /// Verify that when `customHeaders` is nil, requests still work normally. + func testNilCustomHeadersDoesNotCrash() throws { + Flagsmith.shared.customHeaders = nil + Flagsmith.shared.apiKey = "mock-test-api-key" + Flagsmith.shared.baseURL = URL(fileURLWithPath: "/dev/null") + + let requestFinished = expectation(description: "Request finished without crash") + + Flagsmith.shared.getFeatureFlags { _ in + requestFinished.fulfill() + } + + wait(for: [requestFinished], timeout: 1.0) + } + + /// Verify the closure is invoked on every request (not cached). + func testCustomHeadersClosureInvokedEveryRequest() throws { + var invocationCount = 0 + Flagsmith.shared.customHeaders = { + invocationCount += 1 + return [:] + } + Flagsmith.shared.apiKey = "mock-test-api-key" + Flagsmith.shared.baseURL = URL(fileURLWithPath: "/dev/null") + + let firstRequest = expectation(description: "First request") + let secondRequest = expectation(description: "Second request") + + Flagsmith.shared.getFeatureFlags { _ in firstRequest.fulfill() } + Flagsmith.shared.getFeatureFlags { _ in secondRequest.fulfill() } + + wait(for: [firstRequest, secondRequest], timeout: 2.0) + XCTAssertEqual(invocationCount, 2, "customHeaders should be invoked for every request") + } +}