From 44e206a351524cebe2ccf8da5329f03daa192455 Mon Sep 17 00:00:00 2001 From: andre-statsig Date: Mon, 2 Dec 2024 16:00:12 -0800 Subject: [PATCH] Support path components on custom URLs (#310) --- Sources/Statsig/NetworkService.swift | 42 +++++----- Sources/Statsig/StatsigOptions.swift | 82 +++++++++++++++---- Tests/StatsigTests/ApiOverrideSpec.swift | 70 ++++++++++++++++ Tests/StatsigTests/AppLifecycleSpec.swift | 11 +-- Tests/StatsigTests/BaseSpec.swift | 1 + Tests/StatsigTests/CodableSpec.swift | 3 +- Tests/StatsigTests/ExposureLoggingSpec.swift | 3 +- Tests/StatsigTests/ManualFlushSpec.swift | 3 +- .../NotAwaitingInitCallsSpec.swift | 6 +- Tests/StatsigTests/PerformanceSpec.swift | 3 +- Tests/StatsigTests/StatsigSpec.swift | 5 +- Tests/StatsigTests/TestUtils.swift | 12 ++- 12 files changed, 185 insertions(+), 56 deletions(-) diff --git a/Sources/Statsig/NetworkService.swift b/Sources/Statsig/NetworkService.swift index 51a7782..a4b3e39 100644 --- a/Sources/Statsig/NetworkService.swift +++ b/Sources/Statsig/NetworkService.swift @@ -1,6 +1,6 @@ import Foundation -fileprivate enum Endpoint: String { +internal enum Endpoint: String { case initialize = "/v1/initialize" case logEvent = "/v1/rgstr" } @@ -19,6 +19,16 @@ class NetworkService { var store: InternalStore var inflightRequests = AtomicDictionary(label: "com.Statsig.InFlightRequests") + /** + Default URL used to initialize the SDK. Used for tests. + */ + internal static var defaultInitializationUrl = URL(string: "https://\(ApiHost)\(Endpoint.initialize.rawValue)") + + /** + Default URL used for log_event network requests. Used for tests. + */ + internal static var defaultEventLoggingUrl = URL(string: "https://\(LogEventHost)\(Endpoint.logEvent.rawValue)") + private final let networkRetryErrorCodes = [408, 500, 502, 503, 504, 522, 524, 599] init(sdkKey: String, options: StatsigOptions, store: InternalStore) { @@ -245,6 +255,13 @@ class NetworkService { return (nil, StatsigError.invalidJSONParam("requestBody")) } + private func urlForEndpoint(_ endpoint: Endpoint) -> URL? { + return switch endpoint { + case .initialize: self.statsigOptions.initializationUrl ?? NetworkService.defaultInitializationUrl + case .logEvent: self.statsigOptions.eventLoggingUrl ?? NetworkService.defaultEventLoggingUrl + } + } + private func makeAndSendRequest( _ endpoint: Endpoint, body: Data, @@ -253,20 +270,7 @@ class NetworkService { taskCapture: TaskCaptureHandler = nil ) { - var urlComponents = URLComponents() - urlComponents.scheme = "https" - urlComponents.host = ApiHost - urlComponents.path = endpoint.rawValue - - if let override = self.statsigOptions.mainApiUrl { - urlComponents.applyOverride(override) - } - - if endpoint == .logEvent, let loggingApiOverride = self.statsigOptions.logEventApiUrl { - urlComponents.applyOverride(loggingApiOverride) - } - - guard let requestURL = urlComponents.url else { + guard let requestURL = urlForEndpoint(endpoint) else { completion(nil, nil, StatsigError.invalidRequestURL("\(endpoint)")) return } @@ -332,11 +336,3 @@ class NetworkService { } } } - -extension URLComponents { - mutating func applyOverride(_ url: URL) { - scheme = url.scheme - host = url.host - port = url.port - } -} diff --git a/Sources/Statsig/StatsigOptions.swift b/Sources/Statsig/StatsigOptions.swift index 2a39d9f..a4a7722 100644 --- a/Sources/Statsig/StatsigOptions.swift +++ b/Sources/Statsig/StatsigOptions.swift @@ -69,20 +69,46 @@ public class StatsigOptions { public var shutdownOnBackground = true; /** - The API to use for all SDK network requests. You should not need to override this (unless you have another API that implements the Statsig API endpoints) + The URL used to initialize the SDK. You should not need to override this (unless you have another endpoint that implements the Statsig initialization endpoint) */ - public var api = "https://\(ApiHost)" { - didSet { - mainApiUrl = URL(string: api) ?? mainApiUrl + public var initializationUrl: URL? = nil + + /** + The URL used for log_event network requests. You should not need to override this (unless you have another API that implements the Statsig /v1/rgstr endpoint) + */ + public var eventLoggingUrl: URL? = nil + + /** + The API to use for initialization network requests. Any path will be replaced with /v1/initialize. If you need a custom path, set the full URL to initializationUrl. + You should not need to override this (unless you have another API that implements the Statsig API endpoints) + */ + public var api: String { + get { + return initializationUrl?.ignoringPath?.absoluteString ?? "https://\(ApiHost)" + } + set { + if let apiUrl = URL(string: newValue)?.ignoringPath { + self.initializationUrl = apiUrl.appendingPathComponent(Endpoint.initialize.rawValue, isDirectory: false) + } else { + print("[Statsig]: Failed to create URL with StatsigOptions.api. Please check if it's a valid URL") + } } } - + /** - The API to use for log_event network requests. You should not need to override this (unless you have another API that implements the Statsig /v1/log_event endpoint) + The API to use for log_event network requests. Any path will be replaced with /v1/rgstr. If you need a custom path, set the full URL to eventLoggingUrl. + You should not need to override this (unless you have another API that implements the Statsig /v1/rgstr endpoint) */ - public var eventLoggingApi = "https://\(LogEventHost)" { - didSet { - logEventApiUrl = URL(string: eventLoggingApi) ?? logEventApiUrl + public var eventLoggingApi: String { + get { + return eventLoggingUrl?.ignoringPath?.absoluteString ?? "https://\(LogEventHost)" + } + set { + if let apiUrl = URL(string: newValue)?.ignoringPath { + self.eventLoggingUrl = apiUrl.appendingPathComponent(Endpoint.logEvent.rawValue, isDirectory: false) + } else { + print("[Statsig]: Failed to create URL with StatsigOptions.eventLoggingApi. Please check if it's a valid URL") + } } } @@ -114,9 +140,7 @@ public class StatsigOptions { This property can be customized to utilize URLSession instances with specific configurations, including certificate pinning, for enhanced security when communicating with servers. */ public var urlSession: URLSession = .shared - - internal var mainApiUrl: URL? - internal var logEventApiUrl: URL? + var environment: [String: String] = [:] public init(initTimeout: Double? = 3.0, @@ -132,6 +156,8 @@ public class StatsigOptions { shutdownOnBackground: Bool? = true, api: String? = nil, eventLoggingApi: String? = nil, + initializationUrl: URL? = nil, + eventLoggingUrl: URL? = nil, evaluationCallback: ((EvaluationCallbackData) -> Void)? = nil, userValidationCallback: ((StatsigUser) -> StatsigUser)? = nil, customCacheKey: ((String, StatsigUser) -> String)? = nil, @@ -182,17 +208,24 @@ public class StatsigOptions { if let storageProvider = storageProvider { self.storageProvider = storageProvider } - - if let api = api { + + if let initializationUrl = initializationUrl { + self.initializationUrl = initializationUrl + if api != nil { + print("[Statsig]: StatsigOptions.api is being ignored because StatsigOptions.initializationUrl is also being set.") + } + } else if let api = api { self.api = api - self.mainApiUrl = URL(string: api) } - self.mainApiUrl = URL(string: self.api) ?? URL(string: "https://\(ApiHost)") - if let eventLoggingApi = eventLoggingApi { + if let eventLoggingUrl = eventLoggingUrl { + self.eventLoggingUrl = eventLoggingUrl + if eventLoggingApi != nil { + print("[Statsig]: StatsigOptions.eventLoggingApi is being ignored because StatsigOptions.eventLoggingUrl is also being set.") + } + } else if let eventLoggingApi = eventLoggingApi { self.eventLoggingApi = eventLoggingApi } - self.logEventApiUrl = URL(string: self.eventLoggingApi) ?? URL(string: "https://\(LogEventHost)") if let customCacheKey = customCacheKey { self.customCacheKey = customCacheKey @@ -209,3 +242,16 @@ public class StatsigOptions { self.userValidationCallback = userValidationCallback } } + +// NOTE: This is here to to prevent a bugfix from causing a breaking change to users of the `api` option +extension URL { + var ignoringPath: URL? { + get { + var urlComponents = URLComponents() + urlComponents.scheme = scheme + urlComponents.host = host + urlComponents.port = port + return urlComponents.url + } + } +} diff --git a/Tests/StatsigTests/ApiOverrideSpec.swift b/Tests/StatsigTests/ApiOverrideSpec.swift index 59d7087..ab5e694 100644 --- a/Tests/StatsigTests/ApiOverrideSpec.swift +++ b/Tests/StatsigTests/ApiOverrideSpec.swift @@ -22,6 +22,15 @@ final class ApiOverrideSpec: BaseSpec { request = TestUtils.startWithStatusAndWait(options: opts) } + func startWithUrls() { + let opts = StatsigOptions(initializationUrl: URL(string: "http://api.override.com/setup"), eventLoggingUrl: URL(string: "http://api.override.com/st")) + request = TestUtils.startWithStatusAndWait(options: opts) + } + + afterSuite { + TestUtils.resetDefaultUrls() + } + it("calls initialize on the overridden api") { start() expect(request?.url?.absoluteString).to(equal("http://api.override.com/v1/initialize")) @@ -38,6 +47,23 @@ final class ApiOverrideSpec: BaseSpec { Statsig.shutdown() expect(hitLog).toEventually(beTrue()) } + + it("calls initialize on the overridden api using URLs") { + startWithUrls() + expect(request?.url?.absoluteString).to(equal("http://api.override.com/setup")) + } + + it("calls log_event on the overridden api using URLs") { + startWithUrls() + var hitLog = false + TestUtils.captureLogs(host: "api.override.com", path: "/st") { logs in + hitLog = true + } + + Statsig.logEvent("test_event") + Statsig.shutdown() + expect(hitLog).toEventually(beTrue()) + } } describe("When Not Overridden") { @@ -69,6 +95,11 @@ final class ApiOverrideSpec: BaseSpec { request = TestUtils.startWithStatusAndWait(options: opts) } + func startWithUrls() { + let opts = StatsigOptions(eventLoggingUrl: URL(string: "http://api.log.co.nz/st")) + request = TestUtils.startWithStatusAndWait(options: opts) + } + it("calls initialize on the statsig api") { start() expect(request?.url?.absoluteString).to(equal("https://featureassets.org/v1/initialize")) @@ -85,6 +116,23 @@ final class ApiOverrideSpec: BaseSpec { Statsig.shutdown() expect(hitLog).toEventually(beTrue()) } + + it("calls initialize on the statsig api using URLs") { + startWithUrls() + expect(request?.url?.absoluteString).to(equal("https://featureassets.org/v1/initialize")) + } + + it("calls log_event on the overridden api.log.co.nz api using URLs") { + startWithUrls() + var hitLog = false + TestUtils.captureLogs(host: "api.log.co.nz", path: "/st") { logs in + hitLog = true + } + + Statsig.logEvent("test_event") + Statsig.shutdown() + expect(hitLog).toEventually(beTrue()) + } } describe("When Main and Logging API Overridden") { @@ -93,6 +141,11 @@ final class ApiOverrideSpec: BaseSpec { request = TestUtils.startWithStatusAndWait(options: opts) } + func startWithUrls() { + let opts = StatsigOptions(initializationUrl: URL(string: "http://main.api/setup"), eventLoggingUrl: URL(string: "http://api.log.co.nz/st")) + request = TestUtils.startWithStatusAndWait(options: opts) + } + it("calls initialize on the overridden api") { start() expect(request?.url?.absoluteString).to(equal("http://main.api/v1/initialize")) @@ -109,6 +162,23 @@ final class ApiOverrideSpec: BaseSpec { Statsig.shutdown() expect(hitLog).toEventually(beTrue()) } + + it("calls initialize on the overridden api using URLs") { + startWithUrls() + expect(request?.url?.absoluteString).to(equal("http://main.api/setup")) + } + + it("calls log_event on the overridden api.log.co.nz api using URLs") { + startWithUrls() + var hitLog = false + TestUtils.captureLogs(host: "api.log.co.nz", path: "/st") { logs in + hitLog = true + } + + Statsig.logEvent("test_event") + Statsig.shutdown() + expect(hitLog).toEventually(beTrue()) + } } } diff --git a/Tests/StatsigTests/AppLifecycleSpec.swift b/Tests/StatsigTests/AppLifecycleSpec.swift index 0c1d23b..9402696 100644 --- a/Tests/StatsigTests/AppLifecycleSpec.swift +++ b/Tests/StatsigTests/AppLifecycleSpec.swift @@ -18,7 +18,7 @@ final class AppLifecycleSpec: BaseSpec { func startAndLog(shutdownOnBackground: Bool, tag: String) { let opts = StatsigOptions(shutdownOnBackground: shutdownOnBackground) - opts.mainApiUrl = URL(string: "http://AppLifecycleSpec::\(tag)") + NetworkService.defaultInitializationUrl = URL(string: "http://AppLifecycleSpec::\(tag)/v1/initialize") _ = TestUtils.startWithStatusAndWait(options: opts) @@ -32,6 +32,11 @@ final class AppLifecycleSpec: BaseSpec { Statsig.logEvent("my_event") } + afterEach { + Statsig.shutdown() + TestUtils.resetDefaultUrls() + } + it("shuts down the logger on app background when shutdownOnBackground is true") { startAndLog(shutdownOnBackground: true, tag: "Shutdown") @@ -43,8 +48,6 @@ final class AppLifecycleSpec: BaseSpec { expect(logger.timesShutdownCalled).toEventually(equal(1)) expect(logger.timesFlushCalled).to(equal(0)) - - Statsig.shutdown() } @@ -58,8 +61,6 @@ final class AppLifecycleSpec: BaseSpec { expect(logger.timesFlushCalled).toEventually(equal(1)) expect(logger.timesShutdownCalled).to(equal(0)) - - Statsig.shutdown() } } } diff --git a/Tests/StatsigTests/BaseSpec.swift b/Tests/StatsigTests/BaseSpec.swift index 09dc827..d628dc6 100644 --- a/Tests/StatsigTests/BaseSpec.swift +++ b/Tests/StatsigTests/BaseSpec.swift @@ -38,6 +38,7 @@ class BaseSpec: QuickSpec { BaseSpec.resetUserDefaults() HTTPStubs.removeAllStubs() + TestUtils.resetDefaultUrls() } } diff --git a/Tests/StatsigTests/CodableSpec.swift b/Tests/StatsigTests/CodableSpec.swift index 9a12b42..952c2bd 100644 --- a/Tests/StatsigTests/CodableSpec.swift +++ b/Tests/StatsigTests/CodableSpec.swift @@ -18,9 +18,9 @@ class CodableSpec: BaseSpec { describe("Codable") { let opts = StatsigOptions(disableDiagnostics: true) - opts.mainApiUrl = URL(string: "http://CodableSpec") beforeEach { + NetworkService.defaultInitializationUrl = URL(string: "http://CodableSpec/v1/initialize") _ = TestUtils.startWithResponseAndWait([ "feature_gates": [ "a_gate".sha256(): [ @@ -45,6 +45,7 @@ class CodableSpec: BaseSpec { afterEach { Statsig.client?.shutdown() Statsig.client = nil + TestUtils.resetDefaultUrls() } diff --git a/Tests/StatsigTests/ExposureLoggingSpec.swift b/Tests/StatsigTests/ExposureLoggingSpec.swift index 5233d46..0820f6d 100644 --- a/Tests/StatsigTests/ExposureLoggingSpec.swift +++ b/Tests/StatsigTests/ExposureLoggingSpec.swift @@ -15,10 +15,10 @@ class ExposureLoggingSpec: BaseSpec { describe("ExposureLogging") { let opts = StatsigOptions(disableDiagnostics: true) - opts.logEventApiUrl = URL(string: "http://ExposureLoggingSpec") var logs: [[String: Any]] = [] beforeEach { + NetworkService.defaultEventLoggingUrl = URL(string: "http://ExposureLoggingSpec/v1/rgstr") _ = TestUtils.startWithResponseAndWait([ "feature_gates": [ "a_gate".sha256(): [ @@ -54,6 +54,7 @@ class ExposureLoggingSpec: BaseSpec { afterEach { Statsig.client?.shutdown() Statsig.client = nil + TestUtils.resetDefaultUrls() } describe("standard use") { diff --git a/Tests/StatsigTests/ManualFlushSpec.swift b/Tests/StatsigTests/ManualFlushSpec.swift index a956f94..c599907 100644 --- a/Tests/StatsigTests/ManualFlushSpec.swift +++ b/Tests/StatsigTests/ManualFlushSpec.swift @@ -17,7 +17,7 @@ final class ManualFlushSpec: BaseSpec { it("flushes the logger") { let opts = StatsigOptions() - opts.logEventApiUrl = URL(string: "http://ManualFlushSpec") + NetworkService.defaultEventLoggingUrl = URL(string: "http://ManualFlushSpec/v1/rgstr") _ = TestUtils.startWithResponseAndWait([:], options: opts) Statsig.logEvent("my_event") @@ -32,6 +32,7 @@ final class ManualFlushSpec: BaseSpec { expect(logs[0]["eventName"]as? String).to(equal("my_event")) Statsig.shutdown() + TestUtils.resetDefaultUrls() } } } diff --git a/Tests/StatsigTests/NotAwaitingInitCallsSpec.swift b/Tests/StatsigTests/NotAwaitingInitCallsSpec.swift index b1e9fc2..7336d2e 100644 --- a/Tests/StatsigTests/NotAwaitingInitCallsSpec.swift +++ b/Tests/StatsigTests/NotAwaitingInitCallsSpec.swift @@ -20,7 +20,7 @@ class NotAwaitingInitCallsSpec: BaseSpec { describe("Not Awaiting Init Calls") { beforeEach { - options.mainApiUrl = URL(string: "http://NotAwaitingInitCallsSpec") + NetworkService.defaultInitializationUrl = URL(string: "http://NotAwaitingInitCallsSpec/v1/initialize") TestUtils.clearStorage() @@ -43,6 +43,10 @@ class NotAwaitingInitCallsSpec: BaseSpec { } } + afterEach { + TestUtils.resetDefaultUrls() + } + it("gets the expected values") { var isInitialized = false diff --git a/Tests/StatsigTests/PerformanceSpec.swift b/Tests/StatsigTests/PerformanceSpec.swift index dcc232f..7f08d2e 100644 --- a/Tests/StatsigTests/PerformanceSpec.swift +++ b/Tests/StatsigTests/PerformanceSpec.swift @@ -14,7 +14,7 @@ final class PerformanceSpec: XCTestCase { override func setUpWithError() throws { let opts = StatsigOptions(disableDiagnostics: true) - opts.mainApiUrl = URL(string: "http://PerformanceSpec") + NetworkService.defaultInitializationUrl = URL(string: "http://PerformanceSpec/v1/initialize") _ = TestUtils.startWithResponseAndWait([ "feature_gates": [ @@ -40,6 +40,7 @@ final class PerformanceSpec: XCTestCase { override func tearDownWithError() throws { Statsig.client?.shutdown() Statsig.client = nil + TestUtils.resetDefaultUrls() } func testCheckGatePerformance() throws { diff --git a/Tests/StatsigTests/StatsigSpec.swift b/Tests/StatsigTests/StatsigSpec.swift index 826b1ea..e103c4a 100644 --- a/Tests/StatsigTests/StatsigSpec.swift +++ b/Tests/StatsigTests/StatsigSpec.swift @@ -72,6 +72,7 @@ class StatsigSpec: BaseSpec { HTTPStubs.removeAllStubs() Statsig.shutdown() TestUtils.clearStorage() + TestUtils.resetDefaultUrls() } let gateName1 = "gate_name_1" @@ -120,7 +121,7 @@ class StatsigSpec: BaseSpec { autoValueUpdateIntervalSec: 0.1, disableDiagnostics: true ) - opts.mainApiUrl = URL(string: "http://api.statsig.enableAutoValueUpdateTest") + NetworkService.defaultInitializationUrl = URL(string: "http://api.statsig.enableAutoValueUpdateTest/v1/initialize") Statsig.initialize(sdkKey: "client-api-key", options: opts) // first request, "lastSyncTimeForUser" field should not be present in the request body @@ -138,7 +139,7 @@ class StatsigSpec: BaseSpec { autoValueUpdateIntervalSec: 0.1, disableDiagnostics: true ) - opts.mainApiUrl = URL(string: "http://StatsigSpec.enableAutoValueUpdateEQtrue") + NetworkService.defaultInitializationUrl = URL(string: "http://StatsigSpec.enableAutoValueUpdateEQtrue/v1/initialize") var requestExpectation = self.expectation(description: "Request Made Once") stub(condition: isHost("StatsigSpec.enableAutoValueUpdateEQtrue")) { request in diff --git a/Tests/StatsigTests/TestUtils.swift b/Tests/StatsigTests/TestUtils.swift index 438d44f..3a72b45 100644 --- a/Tests/StatsigTests/TestUtils.swift +++ b/Tests/StatsigTests/TestUtils.swift @@ -51,7 +51,7 @@ class TestUtils { static func startWithResponseAndWait(_ response: [String: Any], _ key: String = "client-api-key", _ user: StatsigUser? = nil, _ statusCode: Int32 = 200, options: StatsigOptions? = nil) -> URLRequest? { var result: URLRequest? = nil - let host = options?.mainApiUrl?.host ?? ApiHost + let host = options?.initializationUrl?.host ?? NetworkService.defaultInitializationUrl?.host ?? ApiHost let handle = stub(condition: isHost(host)) { req in result = req return HTTPStubsResponse(jsonObject: response, statusCode: statusCode, headers: nil) @@ -68,7 +68,7 @@ class TestUtils { static func startWithStatusAndWait(_ statusCode: Int32 = 200, _ key: String = "client-api-key", _ user: StatsigUser? = nil, options: StatsigOptions? = nil) -> URLRequest? { var result: URLRequest? = nil - stub(condition: isHost(options?.mainApiUrl?.host ?? ApiHost)) { req in + stub(condition: isHost(options?.initializationUrl?.host ?? NetworkService.defaultInitializationUrl?.host ?? ApiHost)) { req in result = req return HTTPStubsResponse(data: Data(), statusCode: statusCode, headers: nil) } @@ -80,9 +80,10 @@ class TestUtils { } static func captureLogs(host: String = LogEventHost, + path: String = "/v1/rgstr", removeDiagnostics: Bool = true, onLog: @escaping ([String: Any]) -> Void) { - stub(condition: isHost(host) && isPath("/v1/rgstr")) { request in + stub(condition: isHost(host) && isPath(path)) { request in var data = try! JSONSerialization.jsonObject(with: request.ohhttpStubs_httpBody!, options: []) as! [String: Any] if removeDiagnostics, let events = data["events"] as? [[String: Any]] { data["events"] = events.filter({ item in @@ -115,6 +116,11 @@ class TestUtils { "has_updates": true ] } + + static func resetDefaultUrls() { + NetworkService.defaultInitializationUrl = URL(string: "https://\(ApiHost)\(Endpoint.initialize.rawValue)") + NetworkService.defaultEventLoggingUrl = URL(string: "https://\(LogEventHost)\(Endpoint.logEvent.rawValue)") + } } extension URLRequest {