Skip to content

Commit b4d7fbf

Browse files
authored
Merge pull request #110 from futuredapp/refactor/replace-tracer-with-observer-middleware
Replace FTNetworkTracer with observer middleware
2 parents dbdfbe9 + 87b0bb2 commit b4d7fbf

9 files changed

Lines changed: 218 additions & 173 deletions

File tree

Package.swift

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,11 @@ let package = Package(
1515
name: "FTAPIKit",
1616
targets: ["FTAPIKit"])
1717
],
18-
dependencies: [
19-
.package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.2.1")
20-
],
18+
dependencies: [],
2119
targets: [
2220
.target(
2321
name: "FTAPIKit",
24-
dependencies: [
25-
.product(name: "FTNetworkTracer", package: "FTNetworkTracer")
26-
]
22+
dependencies: []
2723
),
2824
.testTarget(
2925
name: "FTAPIKitTests",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Foundation
2+
3+
#if os(Linux)
4+
import FoundationNetworking
5+
#endif
6+
7+
/// Protocol for observing network request lifecycle events.
8+
///
9+
/// Implement this protocol to add logging, analytics, or request tracking.
10+
///
11+
/// ## Context Lifecycle
12+
/// The `Context` associated type allows passing correlation data (request ID, start time, etc.)
13+
/// through the request lifecycle:
14+
/// 1. `willSendRequest` is called before the request starts and returns a `Context` value
15+
/// 2. `didReceiveResponse` is always called with the raw response data (useful for debugging)
16+
/// 3. `didFail` is called additionally if the request processing fails (network, HTTP status, or decoding error)
17+
/// 4. If the observer is deallocated before the request completes, the context is discarded
18+
/// and no completion callback is invoked
19+
public protocol NetworkObserver: AnyObject, Sendable {
20+
associatedtype Context: Sendable
21+
22+
/// Called immediately before a request is sent.
23+
/// - Parameter request: The URLRequest about to be sent
24+
/// - Returns: Context to be passed to `didReceiveResponse` and optionally `didFail`
25+
func willSendRequest(_ request: URLRequest) -> Context
26+
27+
/// Called when a response is received from the server.
28+
///
29+
/// This is always called with the raw response data, even if processing subsequently fails.
30+
/// This allows observers to inspect the actual response for debugging purposes.
31+
/// - Parameters:
32+
/// - request: The original request
33+
/// - response: The URL response (may be HTTPURLResponse)
34+
/// - data: Response body data, if any (nil for download tasks)
35+
/// - context: Value returned from `willSendRequest`
36+
func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: Context)
37+
38+
/// Called when a request fails with an error.
39+
///
40+
/// Called after `didReceiveResponse` if processing determines the request failed.
41+
/// - Parameters:
42+
/// - request: The original request
43+
/// - error: The error that occurred (may be network, HTTP status, or decoding error)
44+
/// - context: Value returned from `willSendRequest`
45+
func didFail(request: URLRequest, error: Error, context: Context)
46+
}

Sources/FTAPIKit/URLServer+Task.swift

Lines changed: 34 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,17 @@ extension URLServer {
2222
process: @escaping (Data?, URLResponse?, Error?) -> Result<R, ErrorType>,
2323
completion: @escaping (Result<R, ErrorType>) -> Void
2424
) -> URLSessionDataTask? {
25-
let requestId = UUID().uuidString
26-
let startTime = Date()
27-
28-
networkTracer?.logAndTrackRequest(request: request, requestId: requestId)
25+
let tokens = networkObservers.map { RequestToken(observer: $0, request: request) }
2926

3027
let task = urlSession.dataTask(with: request) { data, response, error in
31-
networkTracer?.logAndTrackResponse(
32-
request: request,
33-
response: response,
34-
data: data,
35-
requestId: requestId,
36-
startTime: startTime
37-
)
28+
tokens.forEach { $0.didReceiveResponse(response, data) }
3829

3930
let result = process(data, response, error)
4031

41-
if case let .failure(error) = result {
42-
networkTracer?.logAndTrackError(
43-
request: request,
44-
error: error,
45-
requestId: requestId
46-
)
32+
if case let .failure(apiError) = result {
33+
tokens.forEach { $0.didFail(apiError) }
4734
}
35+
4836
completion(result)
4937
}
5038
task.resume()
@@ -57,29 +45,15 @@ extension URLServer {
5745
process: @escaping (Data?, URLResponse?, Error?) -> Result<R, ErrorType>,
5846
completion: @escaping (Result<R, ErrorType>) -> Void
5947
) -> URLSessionUploadTask? {
60-
let requestId = UUID().uuidString
61-
let startTime = Date()
62-
63-
networkTracer?.logAndTrackRequest(request: request, requestId: requestId)
48+
let tokens = networkObservers.map { RequestToken(observer: $0, request: request) }
6449

6550
let task = urlSession.uploadTask(with: request, fromFile: file) { data, response, error in
66-
networkTracer?.logAndTrackResponse(
67-
request: request,
68-
response: response,
69-
data: data,
70-
requestId: requestId,
71-
startTime: startTime
72-
)
51+
tokens.forEach { $0.didReceiveResponse(response, data) }
7352

7453
let result = process(data, response, error)
7554

76-
// Log and track error if any
77-
if case let .failure(error) = result {
78-
networkTracer?.logAndTrackError(
79-
request: request,
80-
error: error,
81-
requestId: requestId
82-
)
55+
if case let .failure(apiError) = result {
56+
tokens.forEach { $0.didFail(apiError) }
8357
}
8458

8559
completion(result)
@@ -93,28 +67,15 @@ extension URLServer {
9367
process: @escaping (URL?, URLResponse?, Error?) -> Result<URL, ErrorType>,
9468
completion: @escaping (Result<URL, ErrorType>) -> Void
9569
) -> URLSessionDownloadTask? {
96-
let requestId = UUID().uuidString
97-
let startTime = Date()
98-
99-
networkTracer?.logAndTrackRequest(request: request, requestId: requestId)
70+
let tokens = networkObservers.map { RequestToken(observer: $0, request: request) }
10071

10172
let task = urlSession.downloadTask(with: request) { url, response, error in
102-
networkTracer?.logAndTrackResponse(
103-
request: request,
104-
response: response,
105-
data: nil,
106-
requestId: requestId,
107-
startTime: startTime
108-
)
73+
tokens.forEach { $0.didReceiveResponse(response, nil) }
10974

11075
let result = process(url, response, error)
11176

112-
if case let .failure(error) = result {
113-
networkTracer?.logAndTrackError(
114-
request: request,
115-
error: error,
116-
requestId: requestId
117-
)
77+
if case let .failure(apiError) = result {
78+
tokens.forEach { $0.didFail(apiError) }
11879
}
11980

12081
completion(result)
@@ -146,3 +107,24 @@ extension URLServer {
146107
return .failure(error)
147108
}
148109
}
110+
111+
// This hides the specific 'Context' type inside closures.
112+
private struct RequestToken: Sendable {
113+
let didReceiveResponse: @Sendable (URLResponse?, Data?) -> Void
114+
let didFail: @Sendable (Error) -> Void
115+
116+
// The generic 'T' captures the specific observer type and its associated Context
117+
init<T: NetworkObserver>(observer: T, request: URLRequest) {
118+
// We generate the context immediately upon initialization
119+
let context = observer.willSendRequest(request)
120+
121+
// We capture the specific 'observer' and 'context' inside these closures
122+
self.didReceiveResponse = { [weak observer] response, data in
123+
observer?.didReceiveResponse(for: request, response: response, data: data, context: context)
124+
}
125+
126+
self.didFail = { [weak observer] error in
127+
observer?.didFail(request: request, error: error, context: context)
128+
}
129+
}
130+
}

Sources/FTAPIKit/URLServer.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import Foundation
2-
import FTNetworkTracer
32

43
#if os(Linux)
54
import FoundationNetworking
@@ -45,16 +44,17 @@ public protocol URLServer: Server where Request == URLRequest {
4544
/// - Note: Provided default implementation.
4645
var urlSession: URLSession { get }
4746

48-
/// Optional network tracer for request logging and tracking
49-
/// - Note: Provided default implementation returns nil.
50-
var networkTracer: FTNetworkTracer? { get }
47+
/// Array of network observers.
48+
/// Each observer receives lifecycle callbacks for every request.
49+
/// - Note: Provided default implementation returns empty array.
50+
var networkObservers: [any NetworkObserver] { get }
5151
}
5252

5353
public extension URLServer {
5454
var urlSession: URLSession { .shared }
5555
var decoding: Decoding { JSONDecoding() }
5656
var encoding: Encoding { JSONEncoding() }
57-
var networkTracer: FTNetworkTracer? { nil }
57+
var networkObservers: [any NetworkObserver] { [] }
5858

5959
func buildRequest(endpoint: Endpoint) throws -> URLRequest {
6060
try buildStandardRequest(endpoint: endpoint)

Tests/FTAPIKitTests/Mockups/Analytics.swift

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Foundation
2+
import FTAPIKit
3+
4+
#if os(Linux)
5+
import FoundationNetworking
6+
#endif
7+
8+
struct MockContext: Sendable {
9+
let requestId: String
10+
let startTime: Date
11+
}
12+
13+
final class MockNetworkObserver: NetworkObserver, @unchecked Sendable {
14+
var willSendCount = 0
15+
var didReceiveCount = 0
16+
var didFailCount = 0
17+
var lastRequestId: String?
18+
19+
func willSendRequest(_ request: URLRequest) -> MockContext {
20+
willSendCount += 1
21+
let context = MockContext(requestId: UUID().uuidString, startTime: Date())
22+
lastRequestId = context.requestId
23+
return context
24+
}
25+
26+
func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: MockContext) {
27+
didReceiveCount += 1
28+
}
29+
30+
func didFail(request: URLRequest, error: Error, context: MockContext) {
31+
didFailCount += 1
32+
}
33+
}

Tests/FTAPIKitTests/Mockups/Servers.swift

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import Foundation
22
import FTAPIKit
3-
import FTNetworkTracer
43

54
#if os(Linux)
65
import FoundationNetworking
@@ -31,12 +30,12 @@ struct ErrorThrowingServer: URLServer {
3130
let baseUri = URL(string: "http://httpbin.org/")!
3231
}
3332

34-
struct HTTPBinServerWithTracer: URLServer {
33+
struct HTTPBinServerWithObservers: URLServer {
3534
let urlSession = URLSession(configuration: .ephemeral)
3635
let baseUri = URL(string: "http://httpbin.org/")!
37-
let networkTracer: FTNetworkTracer?
36+
let networkObservers: [any NetworkObserver]
3837

39-
init(tracer: FTNetworkTracer?) {
40-
self.networkTracer = tracer
38+
init(observers: [any NetworkObserver] = []) {
39+
self.networkObservers = observers
4140
}
4241
}

0 commit comments

Comments
 (0)