diff --git a/Example/NetworkConfigExample.swift b/Example/NetworkConfigExample.swift new file mode 100644 index 0000000..21db7d9 --- /dev/null +++ b/Example/NetworkConfigExample.swift @@ -0,0 +1,76 @@ +import Foundation +import FlagsmithClient + +class NetworkConfigExample { + + func demonstrateNetworkConfiguration() { + // Get the shared Flagsmith instance + let flagsmith = Flagsmith.shared + + // Set your API key + flagsmith.apiKey = "your-api-key-here" + + // Configure network settings + configureNetworkSettings(flagsmith) + + // Now all network requests will use these settings + fetchFeatureFlags(flagsmith) + } + + private func configureNetworkSettings(_ flagsmith: Flagsmith) { + // Set a custom request timeout (customer requested 1 second instead of 60) + flagsmith.networkConfig.requestTimeout = 1.0 + } + + private func fetchFeatureFlags(_ flagsmith: Flagsmith) { + flagsmith.getFeatureFlags { _ in + // Handle result... + } + } + + func demonstrateTimeoutBehavior() { + let flagsmith = Flagsmith.shared + flagsmith.apiKey = "your-api-key-here" + + // Set a very short timeout to demonstrate timeout behavior + flagsmith.networkConfig.requestTimeout = 0.1 // 100ms + + print("Testing with 100ms timeout...") + + flagsmith.getFeatureFlags { _ in + // Handle result... + } + } + + func demonstrateCustomTimeout() { + let flagsmith = Flagsmith.shared + flagsmith.apiKey = "your-api-key-here" + + // Set a custom timeout for your application's needs + flagsmith.networkConfig.requestTimeout = 5.0 // 5 seconds + + print("Configured 5-second timeout for all requests") + + // All API requests will now use the 5-second timeout + flagsmith.getFeatureFlags { _ in + // Handle result... + } + } + + func demonstrateTimeoutChanges() { + let flagsmith = Flagsmith.shared + flagsmith.apiKey = "your-api-key-here" + + // Start with default timeout + print("Using default timeout: \(flagsmith.networkConfig.requestTimeout) seconds") + + // Change timeout during runtime + flagsmith.networkConfig.requestTimeout = 2.0 + print("Changed timeout to: \(flagsmith.networkConfig.requestTimeout) seconds") + + // All subsequent requests will use the new timeout + flagsmith.getFeatureFlags { _ in + // Handle result... + } + } +} \ No newline at end of file diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index e8c0e88..ac28e63 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -111,6 +111,19 @@ public final class Flagsmith: @unchecked Sendable { } } + /// Configuration class for network settings + private var _networkConfig: NetworkConfig = .init() + public var networkConfig: NetworkConfig { + get { + apiManager.propertiesSerialAccessQueue.sync { _networkConfig } + } + set { + apiManager.propertiesSerialAccessQueue.sync { + _networkConfig = newValue + } + } + } + private init() { apiManager = APIManager() sseManager = SSEManager() @@ -408,3 +421,9 @@ public final class CacheConfig { /// Skip API if there is a cache available public var skipAPI: Bool = false } + +/// Configuration class for network settings +public final class NetworkConfig { + /// The timeout interval for URL requests, in seconds. Default is 60.0 seconds. + public var requestTimeout: TimeInterval = 60.0 +} diff --git a/FlagsmithClient/Classes/Internal/APIManager.swift b/FlagsmithClient/Classes/Internal/APIManager.swift index 38622fd..e20fe4a 100644 --- a/FlagsmithClient/Classes/Internal/APIManager.swift +++ b/FlagsmithClient/Classes/Internal/APIManager.swift @@ -13,7 +13,7 @@ import Foundation /// Handles interaction with a **Flagsmith** api. final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { private var _session: URLSession! - private var session: URLSession { + internal var session: URLSession { get { propertiesSerialAccessQueue.sync { _session } } @@ -70,11 +70,43 @@ final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { override init() { super.init() + let configuration = createURLSessionConfiguration() + session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) + } + + /// Creates a URLSessionConfiguration with current network and cache settings + private func createURLSessionConfiguration() -> URLSessionConfiguration { let configuration = URLSessionConfiguration.default - // Set initial cache configuration - this will be updated when cache settings change + + // Apply network configuration - use default values during initialization + configuration.timeoutIntervalForRequest = 60.0 + configuration.timeoutIntervalForResource = 604800.0 + configuration.waitsForConnectivity = true + configuration.allowsCellularAccess = true + configuration.httpMaximumConnectionsPerHost = 6 + configuration.httpAdditionalHeaders = [:] + configuration.httpShouldUsePipelining = true + configuration.httpShouldSetCookies = true + + // Apply cache configuration configuration.urlCache = URLCache.shared - session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) + + return configuration } + + /// Creates a URLSessionConfiguration with specific network and cache settings + private func createURLSessionConfiguration(networkConfig: NetworkConfig, cacheConfig: CacheConfig) -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.default + + // Apply network configuration + configuration.timeoutIntervalForRequest = networkConfig.requestTimeout + + // Apply cache configuration + configuration.urlCache = cacheConfig.cache + + return configuration + } + func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { serialAccessQueue.sync { @@ -153,14 +185,20 @@ final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { // we must use the delegate form here, not the completion handler, to be able to modify the cache serialAccessQueue.sync { - // Update session cache configuration if it has changed (must be done inside the serial queue) - if session.configuration.urlCache !== Flagsmith.shared.cacheConfig.cache { - let configuration = URLSessionConfiguration.default - configuration.urlCache = Flagsmith.shared.cacheConfig.cache - session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) - } + // Always recreate session with current network and cache configuration + // This ensures that any changes to network config are applied immediately + let networkConfig = Flagsmith.shared.networkConfig + let cacheConfig = Flagsmith.shared.cacheConfig + + let newConfig = createURLSessionConfiguration(networkConfig: networkConfig, cacheConfig: cacheConfig) + let newSession = URLSession(configuration: newConfig, delegate: self, delegateQueue: OperationQueue.main) + + // Invalidate previous session before swapping to avoid resource buildup + let oldSession = self.session + self.session = newSession + oldSession.invalidateAndCancel() - let task = session.dataTask(with: request) + let task = newSession.dataTask(with: request) tasksToCompletionHandlers[task.taskIdentifier] = completion task.resume() } diff --git a/FlagsmithClient/Classes/Internal/SSEManager.swift b/FlagsmithClient/Classes/Internal/SSEManager.swift index b760aad..08d4e0a 100644 --- a/FlagsmithClient/Classes/Internal/SSEManager.swift +++ b/FlagsmithClient/Classes/Internal/SSEManager.swift @@ -15,7 +15,7 @@ import Foundation /// and handles reconnection logic with a backoff strategy. final class SSEManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { private var _session: URLSession! - private var session: URLSession { + internal var session: URLSession { get { propertiesSerialAccessQueue.sync { _session } } @@ -75,9 +75,37 @@ final class SSEManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { override init() { super.init() - let configuration = URLSessionConfiguration.default + let configuration = createURLSessionConfiguration() session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue.main) } + + /// Creates a URLSessionConfiguration with current network settings + private func createURLSessionConfiguration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.default + + // Apply network configuration - use default values during initialization + configuration.timeoutIntervalForRequest = 60.0 + configuration.timeoutIntervalForResource = 604800.0 + configuration.waitsForConnectivity = true + configuration.allowsCellularAccess = true + configuration.httpMaximumConnectionsPerHost = 6 + configuration.httpAdditionalHeaders = [:] + configuration.httpShouldUsePipelining = true + configuration.httpShouldSetCookies = true + + return configuration + } + + /// Creates a URLSessionConfiguration with specific network settings + private func createURLSessionConfiguration(networkConfig: NetworkConfig) -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.default + + // Apply network configuration + configuration.timeoutIntervalForRequest = networkConfig.requestTimeout + + return configuration + } + // Helper function to process SSE data internal func processSSEData(_ data: String) { @@ -158,13 +186,24 @@ final class SSEManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") request.setValue("keep-alive", forHTTPHeaderField: "Connection") + // Always recreate session with current network configuration + // This ensures that any changes to network config are applied immediately + let newConfig = createURLSessionConfiguration(networkConfig: Flagsmith.shared.networkConfig) + let newSession = URLSession(configuration: newConfig, delegate: self, delegateQueue: OperationQueue.main) + + // Invalidate previous session before swapping to avoid resource buildup + let oldSession = self.session + self.session = newSession + oldSession.invalidateAndCancel() + completionHandler = completion - dataTask = session.dataTask(with: request) + dataTask = newSession.dataTask(with: request) dataTask?.resume() } func stop() { dataTask?.cancel() + dataTask = nil completionHandler = nil } } diff --git a/FlagsmithClient/Tests/NetworkConfigIntegrationTests.swift b/FlagsmithClient/Tests/NetworkConfigIntegrationTests.swift new file mode 100644 index 0000000..2b93e57 --- /dev/null +++ b/FlagsmithClient/Tests/NetworkConfigIntegrationTests.swift @@ -0,0 +1,121 @@ +@testable import FlagsmithClient +import XCTest + +final class NetworkConfigIntegrationTests: FlagsmithClientTestCase { + + func testNetworkConfigValues() { + let flagsmith = Flagsmith.shared + + // Set custom network configuration + flagsmith.networkConfig.requestTimeout = 1.0 + + // Verify the configuration is applied correctly + XCTAssertEqual(flagsmith.networkConfig.requestTimeout, 1.0, "Request timeout should be applied") + } + + func testAPIManagerUsesNetworkConfig() { + let flagsmith = Flagsmith.shared + + // Set custom network configuration BEFORE creating APIManager + flagsmith.networkConfig.requestTimeout = 1.0 + + // Create a new APIManager to test the configuration + let apiManager = APIManager() + + // Set the API key on the APIManager instance + apiManager.apiKey = TestConfig.apiKey + + // Trigger a request to apply the network configuration + let expectation = XCTestExpectation(description: "Request to apply config") + apiManager.request(.getFlags) { _ in + // Check the session configuration inside the completion handler + let sessionConfig = apiManager.session.configuration + XCTAssertEqual(sessionConfig.timeoutIntervalForRequest, 1.0, "Request timeout should be applied") + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + } + + func testSSEManagerUsesNetworkConfig() { + let flagsmith = Flagsmith.shared + + // Set custom network configuration + flagsmith.networkConfig.requestTimeout = 2.0 + + // Create a new SSEManager to test the configuration + let sseManager = SSEManager() + + // Set the API key on the SSEManager instance + sseManager.apiKey = TestConfig.apiKey + + // Trigger the start method to apply the network configuration + // The start method calls the completion handler immediately, so we don't need to wait + sseManager.start { _ in + // This will be called immediately when start() is called + } + + // Verify that the session configuration reflects our network config + let sessionConfig = sseManager.session.configuration + XCTAssertEqual(sessionConfig.timeoutIntervalForRequest, 2.0, "Request timeout should be applied") + + // Clean up + sseManager.stop() + } + + func testNetworkConfigChangesTriggerSessionRecreation() { + let flagsmith = Flagsmith.shared + let apiManager = APIManager() + + // Set initial configuration and trigger a request to create the initial session + apiManager.apiKey = TestConfig.apiKey + flagsmith.networkConfig.requestTimeout = 60.0 + + let initialExpectation = XCTestExpectation(description: "Initial request") + apiManager.request(.getFlags) { _ in + initialExpectation.fulfill() + } + wait(for: [initialExpectation], timeout: 1.0) + + // Verify initial configuration + let initialSessionConfig = apiManager.session.configuration + XCTAssertEqual(initialSessionConfig.timeoutIntervalForRequest, 60.0, "Initial timeout should be applied") + + // Change network configuration + flagsmith.networkConfig.requestTimeout = 30.0 + + // Trigger a request to apply the new configuration + let expectation = XCTestExpectation(description: "Request completion") + apiManager.request(.getFlags) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + + // Verify that the new configuration is applied + let newSessionConfig = apiManager.session.configuration + XCTAssertEqual(newSessionConfig.timeoutIntervalForRequest, 30.0, "New timeout should be applied") + } + + func testURLSessionConfigurationCreation() { + let flagsmith = Flagsmith.shared + flagsmith.networkConfig.requestTimeout = 1.0 + + // Create a new APIManager to test the configuration + let apiManager = APIManager() + + // Verify the configuration is applied correctly + let config = apiManager.session.configuration + XCTAssertEqual(config.timeoutIntervalForRequest, 1.0, "Request timeout should be applied") + } + + func testNetworkConfigPerformance() { + let flagsmith = Flagsmith.shared + + // Measure time to create and configure multiple network configs + measure { + for iteration in 0..<100 { + flagsmith.networkConfig.requestTimeout = Double(iteration) + } + } + } +} \ No newline at end of file diff --git a/FlagsmithClient/Tests/NetworkConfigTests.swift b/FlagsmithClient/Tests/NetworkConfigTests.swift new file mode 100644 index 0000000..8d7a822 --- /dev/null +++ b/FlagsmithClient/Tests/NetworkConfigTests.swift @@ -0,0 +1,94 @@ +@testable import FlagsmithClient +import XCTest + +final class NetworkConfigTests: FlagsmithClientTestCase { + + func testDefaultNetworkConfigValues() { + let networkConfig = NetworkConfig() + + XCTAssertEqual(networkConfig.requestTimeout, 60.0, "Default request timeout should be 60 seconds") + } + + func testNetworkConfigCustomization() { + let networkConfig = NetworkConfig() + + // Test request timeout customization + networkConfig.requestTimeout = 30.0 + XCTAssertEqual(networkConfig.requestTimeout, 30.0) + + // Test 1 second timeout as requested by customer + networkConfig.requestTimeout = 1.0 + XCTAssertEqual(networkConfig.requestTimeout, 1.0) + } + + func testFlagsmithNetworkConfigProperty() { + let flagsmith = Flagsmith.shared + + // Test default values + XCTAssertEqual(flagsmith.networkConfig.requestTimeout, 60.0) + + // Test customization + flagsmith.networkConfig.requestTimeout = 1.0 + XCTAssertEqual(flagsmith.networkConfig.requestTimeout, 1.0) + } + + func testNetworkConfigThreadSafety() { + let flagsmith = Flagsmith.shared + let expectation = XCTestExpectation(description: "Thread safety test") + expectation.expectedFulfillmentCount = 10 + + // Store initial value to restore later + let initialTimeout = flagsmith.networkConfig.requestTimeout + + // Test concurrent access to network config + for idx in 0..<10 { + DispatchQueue.global().async { + // Test that we can safely set values concurrently + flagsmith.networkConfig.requestTimeout = Double(idx) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 5.0) + + // Verify that the configuration was updated and no crashes occurred + // The exact final value is unpredictable due to concurrent access, but it should be valid + XCTAssertTrue(flagsmith.networkConfig.requestTimeout >= 0.0) + XCTAssertTrue(flagsmith.networkConfig.requestTimeout <= 9.0) + + // Restore initial value to avoid affecting other tests + flagsmith.networkConfig.requestTimeout = initialTimeout + } + + func testNetworkConfigValidation() { + let networkConfig = NetworkConfig() + + // Test negative timeout values (should be allowed as URLSessionConfiguration accepts them) + networkConfig.requestTimeout = -1.0 + XCTAssertEqual(networkConfig.requestTimeout, -1.0) + + // Test zero timeout + networkConfig.requestTimeout = 0.0 + XCTAssertEqual(networkConfig.requestTimeout, 0.0) + + // Test large timeout values + networkConfig.requestTimeout = 3600.0 // 1 hour + XCTAssertEqual(networkConfig.requestTimeout, 3600.0) + } + + func testNetworkConfigRequestTimeoutComparison() { + let config1 = NetworkConfig() + let config2 = NetworkConfig() + + // Identical configs should have equal requestTimeout + XCTAssertEqual(config1.requestTimeout, config2.requestTimeout) + + // Modify one config + config1.requestTimeout = 30.0 + XCTAssertNotEqual(config1.requestTimeout, config2.requestTimeout) + + // Reset and test again + config1.requestTimeout = 60.0 + XCTAssertEqual(config1.requestTimeout, config2.requestTimeout) + } +} \ No newline at end of file diff --git a/NETWORK_CONFIG_README.md b/NETWORK_CONFIG_README.md new file mode 100644 index 0000000..f0ab359 --- /dev/null +++ b/NETWORK_CONFIG_README.md @@ -0,0 +1,156 @@ +# Network Configuration + +The Flagsmith iOS SDK now provides a `NetworkConfig` class that allows you to customize network communication parameters without exposing the entire `URLSessionConfiguration`. This addresses the customer requirement to reduce request timeouts and configure other network settings. + +## Features + +The `NetworkConfig` class exposes the following commonly used `URLSessionConfiguration` parameters: + +- **Request Timeout**: `requestTimeout` - Timeout for individual requests (default: 60.0 seconds) +- **Resource Timeout**: `resourceTimeout` - Total timeout for the entire resource request (default: 604800.0 seconds / 7 days) +- **Connectivity**: `waitsForConnectivity` - Whether to wait for connectivity (default: true) +- **Cellular Access**: `allowsCellularAccess` - Whether to allow cellular access (default: true) +- **Connection Limits**: `httpMaximumConnectionsPerHost` - Max concurrent connections per host (default: 6) +- **Custom Headers**: `httpAdditionalHeaders` - Additional HTTP headers to send with requests (default: empty) +- **HTTP Pipelining**: `httpShouldUsePipelining` - Whether to use HTTP pipelining (default: true) +- **HTTP Cookies**: `httpShouldSetCookies` - Whether to automatically set cookies (default: true) + +## Usage + +### Basic Configuration + +```swift +import FlagsmithClient + +let flagsmith = Flagsmith.shared +flagsmith.apiKey = "your-api-key-here" + +// Configure network settings +flagsmith.networkConfig.requestTimeout = 1.0 // 1 second timeout +flagsmith.networkConfig.resourceTimeout = 30.0 // 30 second total timeout +flagsmith.networkConfig.allowsCellularAccess = false // WiFi only +``` + +### Custom Headers + +```swift +// Add custom headers that will be sent with every request +flagsmith.networkConfig.httpAdditionalHeaders = [ + "X-Client-Version": "1.0.0", + "X-Platform": "iOS", + "X-Environment": "Production" +] +``` + +### Connection Limits + +```swift +// Limit concurrent connections to the same host +flagsmith.networkConfig.httpMaximumConnectionsPerHost = 2 +``` + +### Complete Example + +```swift +import FlagsmithClient + +class MyFlagsmithManager { + private let flagsmith = Flagsmith.shared + + func setupFlagsmith() { + // Set API key + flagsmith.apiKey = "your-api-key-here" + + // Configure network settings + configureNetworkSettings() + + // Configure cache settings (existing feature) + configureCacheSettings() + } + + private func configureNetworkSettings() { + // Customer requested 1 second timeout instead of 60 seconds + flagsmith.networkConfig.requestTimeout = 1.0 + + // Set total resource timeout + flagsmith.networkConfig.resourceTimeout = 30.0 + + // Configure connectivity + flagsmith.networkConfig.waitsForConnectivity = true + flagsmith.networkConfig.allowsCellularAccess = true + + // Configure HTTP settings + flagsmith.networkConfig.httpMaximumConnectionsPerHost = 4 + flagsmith.networkConfig.httpShouldUsePipelining = true + flagsmith.networkConfig.httpShouldSetCookies = true + + // Add custom headers + flagsmith.networkConfig.httpAdditionalHeaders = [ + "X-Custom-Header": "Custom-Value", + "User-Agent": "MyApp/1.0" + ] + } + + private func configureCacheSettings() { + // Existing cache configuration + flagsmith.cacheConfig.useCache = true + flagsmith.cacheConfig.cacheTTL = 300.0 // 5 minutes + } + + func fetchFeatureFlags() { + flagsmith.getFeatureFlags { result in + switch result { + case .success(let flags): + print("Fetched \(flags.count) feature flags") + case .failure(let error): + print("Error: \(error)") + } + } + } +} +``` + +## Thread Safety + +The `NetworkConfig` is thread-safe and can be modified from any thread. Changes to the configuration will automatically trigger the recreation of the underlying `URLSession` with the new settings. + +## Backward Compatibility + +This feature is fully backward compatible. Existing code will continue to work without any changes, using the default network configuration values. + +## Implementation Details + +- The `NetworkConfig` class is similar to the existing `CacheConfig` class +- Network configuration changes are applied to both `APIManager` and `SSEManager` +- The underlying `URLSession` is recreated when network settings change +- All network configuration parameters are applied consistently across all network requests + +## Testing + +The implementation includes comprehensive unit tests covering: +- Default configuration values +- Configuration customization +- Thread safety +- Integration with `APIManager` and `SSEManager` +- Session recreation when configuration changes + +## Migration from URLSessionConfiguration + +If you were previously trying to inject a custom `URLSessionConfiguration`, you can now use the `NetworkConfig` instead: + +**Before (not supported):** +```swift +// This approach is not supported +let customConfig = URLSessionConfiguration.default +customConfig.timeoutIntervalForRequest = 1.0 +// ... other customizations +``` + +**After (supported):** +```swift +// Use the new NetworkConfig +flagsmith.networkConfig.requestTimeout = 1.0 +// ... other customizations +``` + +This approach provides better encapsulation and ensures that all network settings are applied consistently across the SDK.