From 0a98a88745aadc8c8e7ca09e890e9af12fa2b074 Mon Sep 17 00:00:00 2001 From: Valentin Varadi Date: Mon, 13 Apr 2026 13:45:36 +0200 Subject: [PATCH] feat: Add customHeaders closure for injecting request headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- FlagsmithClient/Classes/Flagsmith.swift | 14 ++++ .../Classes/Internal/APIManager.swift | 7 ++ .../Classes/Internal/SSEManager.swift | 7 ++ .../Tests/CustomHeadersTests.swift | 66 +++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 FlagsmithClient/Tests/CustomHeadersTests.swift 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") + } +}