Skip to content

Commit c4f8f12

Browse files
authored
Add custom HTTP session abilities. (#344)
* Use associatedtypes to clean up URLSession conformance * Add ability to have custom HTTP sessions. * Added/updated tests * Fixed linux exclusion
1 parent a1af4aa commit c4f8f12

File tree

8 files changed

+191
-57
lines changed

8 files changed

+191
-57
lines changed

Sources/Segment/Configuration.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public class Configuration {
6767
var jsonNonConformingNumberStrategy: JSONSafeEncoder.NonConformingFloatEncodingStrategy = .zero
6868
var storageMode: StorageMode = .disk
6969
var anonymousIdGenerator: AnonymousIdGenerator = SegmentAnonymousId()
70+
var httpSession: (() -> any HTTPSession) = HTTPSessions.urlSession
7071
}
7172

7273
internal var values: Values
@@ -272,6 +273,16 @@ public extension Configuration {
272273
values.anonymousIdGenerator = generator
273274
return self
274275
}
276+
277+
/// Use a custom HTTP session; Useful for non-apple platforms where Swift networking isn't as mature
278+
/// or has issues to work around.
279+
/// - Parameter httpSession: A class conforming to the HTTPSession protocol
280+
/// - Returns: The current configuration
281+
@discardableResult
282+
func httpSession(_ httpSession: @escaping @autoclosure () -> any HTTPSession) -> Configuration {
283+
values.httpSession = httpSession
284+
return self
285+
}
275286
}
276287

277288
extension Analytics {

Sources/Segment/Plugins/SegmentDestination.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public class SegmentDestination: DestinationPlugin, Subscriber, FlushCompletion
4141
internal struct UploadTaskInfo {
4242
let url: URL?
4343
let data: Data?
44-
let task: URLSessionDataTask
44+
let task: DataTask
4545
// set/used via an extension in iOSLifecycleMonitor.swift
4646
typealias CleanupClosure = () -> Void
4747
var cleanup: CleanupClosure? = nil

Sources/Segment/Utilities/HTTPClient.swift renamed to Sources/Segment/Utilities/Networking/HTTPClient.swift

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ public enum HTTPClientErrors: Error {
2020
public class HTTPClient {
2121
private static let defaultAPIHost = "api.segment.io/v1"
2222
private static let defaultCDNHost = "cdn-settings.segment.com/v1"
23-
24-
internal var session: URLSession
23+
24+
internal var session: any HTTPSession
2525
private var apiHost: String
2626
private var apiKey: String
2727
private var cdnHost: String
@@ -35,7 +35,7 @@ public class HTTPClient {
3535
self.apiHost = analytics.configuration.values.apiHost
3636
self.cdnHost = analytics.configuration.values.cdnHost
3737

38-
self.session = Self.configuredSession(for: self.apiKey)
38+
self.session = analytics.configuration.values.httpSession()
3939
}
4040

4141
func segmentURL(for host: String, path: String) -> URL? {
@@ -52,7 +52,7 @@ public class HTTPClient {
5252
/// - batch: The array of the events, considered a batch of events.
5353
/// - completion: The closure executed when done. Passes if the task should be retried or not if failed.
5454
@discardableResult
55-
func startBatchUpload(writeKey: String, batch: URL, completion: @escaping (_ result: Result<Bool, Error>) -> Void) -> URLSessionDataTask? {
55+
func startBatchUpload(writeKey: String, batch: URL, completion: @escaping (_ result: Result<Bool, Error>) -> Void) -> (any DataTask)? {
5656
guard let uploadURL = segmentURL(for: apiHost, path: "/b") else {
5757
self.analytics?.reportInternalError(HTTPClientErrors.failedToOpenBatch)
5858
completion(.failure(HTTPClientErrors.failedToOpenBatch))
@@ -77,7 +77,7 @@ public class HTTPClient {
7777
/// - batch: The array of the events, considered a batch of events.
7878
/// - completion: The closure executed when done. Passes if the task should be retried or not if failed.
7979
@discardableResult
80-
func startBatchUpload(writeKey: String, data: Data, completion: @escaping (_ result: Result<Bool, Error>) -> Void) -> URLSessionDataTask? {
80+
func startBatchUpload(writeKey: String, data: Data, completion: @escaping (_ result: Result<Bool, Error>) -> Void) -> (any UploadTask)? {
8181
guard let uploadURL = segmentURL(for: apiHost, path: "/b") else {
8282
self.analytics?.reportInternalError(HTTPClientErrors.failedToOpenBatch)
8383
completion(.failure(HTTPClientErrors.failedToOpenBatch))
@@ -199,11 +199,4 @@ extension HTTPClient {
199199

200200
return request
201201
}
202-
203-
internal static func configuredSession(for writeKey: String) -> URLSession {
204-
let configuration = URLSessionConfiguration.ephemeral
205-
configuration.httpMaximumConnectionsPerHost = 2
206-
let session = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
207-
return session
208-
}
209202
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
3+
#if os(Linux) || os(Windows)
4+
import FoundationNetworking
5+
#endif
6+
7+
extension URLSessionDataTask: DataTask {}
8+
extension URLSessionUploadTask: UploadTask {}
9+
10+
// Give the built in `URLSession` conformance to HTTPSession so that it can easily be used
11+
extension URLSession: HTTPSession {}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Foundation
2+
3+
#if os(Linux) || os(Windows)
4+
import FoundationNetworking
5+
#endif
6+
7+
public protocol DataTask {
8+
var state: URLSessionTask.State { get }
9+
func resume()
10+
}
11+
12+
public protocol UploadTask: DataTask {}
13+
14+
// An enumeration of default `HTTPSession` configurations to be used
15+
// This can be extended buy consumer to easily refer back to their configured session.
16+
public enum HTTPSessions {
17+
/// An implementation of `HTTPSession` backed by Apple's `URLSession`.
18+
public static func urlSession() -> any HTTPSession {
19+
let configuration = URLSessionConfiguration.ephemeral
20+
configuration.httpMaximumConnectionsPerHost = 2
21+
let session = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
22+
return session
23+
}
24+
}
25+
26+
public protocol HTTPSession {
27+
associatedtype DataTaskType: DataTask
28+
associatedtype UploadTaskType: UploadTask
29+
30+
func uploadTask(with request: URLRequest, fromFile file: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> UploadTaskType
31+
func uploadTask(with request: URLRequest, from bodyData: Data?, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> UploadTaskType
32+
func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> DataTaskType
33+
func finishTasksAndInvalidate()
34+
}

Tests/Segment-Tests/HTTPClient_Tests.swift

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,76 @@
55
// Created by Brandon Sneed on 1/21/21.
66
//
77

8+
#if !os(Linux)
9+
810
import XCTest
911
@testable import Segment
1012

1113
class HTTPClientTests: XCTestCase {
1214

1315
override func setUpWithError() throws {
1416
// Put setup code here. This method is called before the invocation of each test method in the class.
17+
RestrictedHTTPSession.reset()
1518
}
1619

1720
override func tearDownWithError() throws {
1821
// Put teardown code here. This method is called after the invocation of each test method in the class.
1922
}
2023

21-
/*func testExample() throws {
24+
func testCustomHTTPSessionUpload() throws {
25+
// Use a specific writekey to this test so we do not collide with other cached items.
26+
let analytics = Analytics(
27+
configuration: Configuration(writeKey: "testCustomSesh")
28+
.flushInterval(9999)
29+
.flushAt(9999)
30+
.httpSession(RestrictedHTTPSession())
31+
)
32+
33+
waitUntilStarted(analytics: analytics)
34+
35+
analytics.storage.hardReset(doYouKnowHowToUseThis: true)
36+
37+
analytics.identify(userId: "brandon", traits: MyTraits(email: "[email protected]"))
2238

39+
let flushDone = XCTestExpectation(description: "flush done")
40+
analytics.flush {
41+
flushDone.fulfill()
42+
}
43+
44+
wait(for: [flushDone])
45+
46+
XCTAssertEqual(RestrictedHTTPSession.fileUploads, 1)
2347
}
24-
25-
func testPerformanceExample() throws {
26-
// This is an example of a performance test case.
27-
self.measure {
28-
// Put the code you want to measure the time of here.
48+
49+
func testDefaultHTTPSessionUpload() throws {
50+
// Use a specific writekey to this test so we do not collide with other cached items.
51+
let analytics = Analytics(
52+
configuration: Configuration(writeKey: "testDefaultSesh")
53+
.flushInterval(9999)
54+
.flushAt(9999)
55+
)
56+
57+
// reach in and set it, would be the same as the default ultimately
58+
let segment = analytics.find(pluginType: SegmentDestination.self)
59+
XCTAssertTrue(!(segment?.httpClient?.session is RestrictedHTTPSession))
60+
XCTAssertTrue(segment?.httpClient?.session is URLSession)
61+
segment?.httpClient?.session = RestrictedHTTPSession()
62+
63+
waitUntilStarted(analytics: analytics)
64+
65+
analytics.storage.hardReset(doYouKnowHowToUseThis: true)
66+
67+
analytics.identify(userId: "brandon", traits: MyTraits(email: "[email protected]"))
68+
69+
let flushDone = XCTestExpectation(description: "flush done")
70+
analytics.flush {
71+
flushDone.fulfill()
2972
}
30-
}*/
31-
73+
74+
wait(for: [flushDone])
75+
76+
XCTAssertEqual(RestrictedHTTPSession.fileUploads, 1)
77+
}
3278
}
79+
80+
#endif

Tests/Segment-Tests/StressTests.swift

Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,34 @@
55
// Created by Brandon Sneed on 11/4/21.
66
//
77

8+
#if !os(Linux) && !os(tvOS) && !os(watchOS)
9+
810
import XCTest
911
@testable import Segment
1012

1113
class StressTests: XCTestCase {
1214

1315
override func setUpWithError() throws {
1416
// Put setup code here. This method is called before the invocation of each test method in the class.
17+
RestrictedHTTPSession.reset()
1518
}
1619

1720
override func tearDownWithError() throws {
1821
// Put teardown code here. This method is called after the invocation of each test method in the class.
1922
}
2023

2124
// Linux doesn't know what URLProtocol is and on tvOS/watchOS it somehow works differently and isn't hit.
22-
#if !os(Linux) && !os(tvOS) && !os(watchOS)
2325
func testDirectoryStorageStress2() throws {
2426
// register our network blocker
2527
guard URLProtocol.registerClass(BlockNetworkCalls.self) else { XCTFail(); return }
2628

27-
let analytics = Analytics(configuration: Configuration(writeKey: "stressTest2").errorHandler({ error in
28-
XCTFail("Storage Error: \(error)")
29-
}))
29+
let analytics = Analytics(configuration: Configuration(writeKey: "stressTest2")
30+
.errorHandler({ error in
31+
XCTFail("Storage Error: \(error)")
32+
})
33+
.httpSession(RestrictedHTTPSession())
34+
)
35+
3036
analytics.purgeStorage()
3137
analytics.storage.hardReset(doYouKnowHowToUseThis: true)
3238

@@ -41,20 +47,6 @@ class StressTests: XCTestCase {
4147

4248
waitUntilStarted(analytics: analytics)
4349

44-
// set the httpclient to use our blocker session
45-
let segment = analytics.find(pluginType: SegmentDestination.self)
46-
let configuration = URLSessionConfiguration.ephemeral
47-
configuration.allowsCellularAccess = true
48-
configuration.timeoutIntervalForResource = 30
49-
configuration.timeoutIntervalForRequest = 60
50-
configuration.httpMaximumConnectionsPerHost = 2
51-
configuration.protocolClasses = [BlockNetworkCalls.self]
52-
configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charset=utf-8",
53-
"Authorization": "Basic test",
54-
"User-Agent": "analytics-ios/\(Analytics.version())"]
55-
let blockSession = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
56-
segment?.httpClient?.session = blockSession
57-
5850
@Atomic var ready = false
5951
var queues = [DispatchQueue]()
6052
for i in 0..<30 {
@@ -110,9 +102,12 @@ class StressTests: XCTestCase {
110102
// register our network blocker
111103
guard URLProtocol.registerClass(BlockNetworkCalls.self) else { XCTFail(); return }
112104

113-
let analytics = Analytics(configuration: Configuration(writeKey: "stressTest").errorHandler({ error in
114-
XCTFail("Storage Error: \(error)")
115-
}))
105+
let analytics = Analytics(configuration: Configuration(writeKey: "stressTest2")
106+
.errorHandler({ error in
107+
XCTFail("Storage Error: \(error)")
108+
})
109+
.httpSession(RestrictedHTTPSession())
110+
)
116111
analytics.storage.hardReset(doYouKnowHowToUseThis: true)
117112

118113
DirectoryStore.fileValidator = { url in
@@ -126,20 +121,6 @@ class StressTests: XCTestCase {
126121

127122
waitUntilStarted(analytics: analytics)
128123

129-
// set the httpclient to use our blocker session
130-
let segment = analytics.find(pluginType: SegmentDestination.self)
131-
let configuration = URLSessionConfiguration.ephemeral
132-
configuration.allowsCellularAccess = true
133-
configuration.timeoutIntervalForResource = 30
134-
configuration.timeoutIntervalForRequest = 60
135-
configuration.httpMaximumConnectionsPerHost = 2
136-
configuration.protocolClasses = [BlockNetworkCalls.self]
137-
configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charset=utf-8",
138-
"Authorization": "Basic test",
139-
"User-Agent": "analytics-ios/\(Analytics.version())"]
140-
let blockSession = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
141-
segment?.httpClient?.session = blockSession
142-
143124
let writeQueue1 = DispatchQueue(label: "write queue 1", attributes: .concurrent)
144125
let writeQueue2 = DispatchQueue(label: "write queue 2", attributes: .concurrent)
145126
let writeQueue3 = DispatchQueue(label: "write queue 3", attributes: .concurrent)
@@ -317,5 +298,6 @@ class StressTests: XCTestCase {
317298
RunLoop.main.run(until: Date.distantPast)
318299
}
319300
}
320-
#endif
321301
}
302+
303+
#endif

Tests/Segment-Tests/Support/TestUtilities.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,61 @@ extension XCTestCase {
193193

194194
#if !os(Linux)
195195

196+
class RestrictedHTTPSession: HTTPSession {
197+
let sesh: URLSession
198+
static var fileUploads: Int = 0
199+
static var dataUploads: Int = 0
200+
static var dataTasks: Int = 0
201+
static var invalidated: Int = 0
202+
203+
init(blocking: Bool = true, failing: Bool = false) {
204+
let configuration = URLSessionConfiguration.ephemeral
205+
configuration.allowsCellularAccess = true
206+
configuration.timeoutIntervalForResource = 30
207+
configuration.timeoutIntervalForRequest = 60
208+
configuration.httpMaximumConnectionsPerHost = 2
209+
configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charset=utf-8",
210+
"Authorization": "Basic test",
211+
"User-Agent": "analytics-ios/\(Analytics.version())"]
212+
213+
var protos = [URLProtocol.Type]()
214+
if blocking { protos.append(BlockNetworkCalls.self) }
215+
if failing { protos.append(FailedNetworkCalls.self) }
216+
configuration.protocolClasses = protos
217+
218+
sesh = URLSession(configuration: configuration)
219+
}
220+
221+
static func reset() {
222+
fileUploads = 0
223+
dataUploads = 0
224+
dataTasks = 0
225+
invalidated = 0
226+
}
227+
228+
func uploadTask(with request: URLRequest, fromFile file: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask {
229+
defer { Self.fileUploads += 1 }
230+
return sesh.uploadTask(with: request, fromFile: file, completionHandler: completionHandler)
231+
}
232+
233+
func uploadTask(with request: URLRequest, from bodyData: Data?, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask {
234+
defer { Self.dataUploads += 1 }
235+
return sesh.uploadTask(with: request, from: bodyData, completionHandler: completionHandler)
236+
}
237+
238+
func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionDataTask {
239+
defer { Self.dataTasks += 1 }
240+
return sesh.dataTask(with: request, completionHandler: completionHandler)
241+
}
242+
243+
func finishTasksAndInvalidate() {
244+
defer { Self.invalidated += 1 }
245+
sesh.finishTasksAndInvalidate()
246+
}
247+
}
248+
249+
250+
196251
class BlockNetworkCalls: URLProtocol {
197252
var initialURL: URL? = nil
198253
override class func canInit(with request: URLRequest) -> Bool {

0 commit comments

Comments
 (0)