diff --git a/Package.swift b/Package.swift index fb466b5..b88a739 100644 --- a/Package.swift +++ b/Package.swift @@ -15,15 +15,11 @@ let package = Package( name: "FTAPIKit", targets: ["FTAPIKit"]) ], - dependencies: [ - .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.2.1") - ], + dependencies: [], targets: [ .target( name: "FTAPIKit", - dependencies: [ - .product(name: "FTNetworkTracer", package: "FTNetworkTracer") - ] + dependencies: [] ), .testTarget( name: "FTAPIKitTests", diff --git a/Sources/FTAPIKit/NetworkObserver.swift b/Sources/FTAPIKit/NetworkObserver.swift new file mode 100644 index 0000000..7de99e8 --- /dev/null +++ b/Sources/FTAPIKit/NetworkObserver.swift @@ -0,0 +1,46 @@ +import Foundation + +#if os(Linux) +import FoundationNetworking +#endif + +/// Protocol for observing network request lifecycle events. +/// +/// Implement this protocol to add logging, analytics, or request tracking. +/// +/// ## Context Lifecycle +/// The `Context` associated type allows passing correlation data (request ID, start time, etc.) +/// through the request lifecycle: +/// 1. `willSendRequest` is called before the request starts and returns a `Context` value +/// 2. `didReceiveResponse` is always called with the raw response data (useful for debugging) +/// 3. `didFail` is called additionally if the request processing fails (network, HTTP status, or decoding error) +/// 4. If the observer is deallocated before the request completes, the context is discarded +/// and no completion callback is invoked +public protocol NetworkObserver: AnyObject, Sendable { + associatedtype Context: Sendable + + /// Called immediately before a request is sent. + /// - Parameter request: The URLRequest about to be sent + /// - Returns: Context to be passed to `didReceiveResponse` and optionally `didFail` + func willSendRequest(_ request: URLRequest) -> Context + + /// Called when a response is received from the server. + /// + /// This is always called with the raw response data, even if processing subsequently fails. + /// This allows observers to inspect the actual response for debugging purposes. + /// - Parameters: + /// - request: The original request + /// - response: The URL response (may be HTTPURLResponse) + /// - data: Response body data, if any (nil for download tasks) + /// - context: Value returned from `willSendRequest` + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: Context) + + /// Called when a request fails with an error. + /// + /// Called after `didReceiveResponse` if processing determines the request failed. + /// - Parameters: + /// - request: The original request + /// - error: The error that occurred (may be network, HTTP status, or decoding error) + /// - context: Value returned from `willSendRequest` + func didFail(request: URLRequest, error: Error, context: Context) +} diff --git a/Sources/FTAPIKit/URLServer+Task.swift b/Sources/FTAPIKit/URLServer+Task.swift index 11df0c5..fcfbcd6 100644 --- a/Sources/FTAPIKit/URLServer+Task.swift +++ b/Sources/FTAPIKit/URLServer+Task.swift @@ -22,29 +22,17 @@ extension URLServer { process: @escaping (Data?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionDataTask? { - let requestId = UUID().uuidString - let startTime = Date() - - networkTracer?.logAndTrackRequest(request: request, requestId: requestId) + let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } let task = urlSession.dataTask(with: request) { data, response, error in - networkTracer?.logAndTrackResponse( - request: request, - response: response, - data: data, - requestId: requestId, - startTime: startTime - ) + tokens.forEach { $0.didReceiveResponse(response, data) } let result = process(data, response, error) - if case let .failure(error) = result { - networkTracer?.logAndTrackError( - request: request, - error: error, - requestId: requestId - ) + if case let .failure(apiError) = result { + tokens.forEach { $0.didFail(apiError) } } + completion(result) } task.resume() @@ -57,29 +45,15 @@ extension URLServer { process: @escaping (Data?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionUploadTask? { - let requestId = UUID().uuidString - let startTime = Date() - - networkTracer?.logAndTrackRequest(request: request, requestId: requestId) + let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } let task = urlSession.uploadTask(with: request, fromFile: file) { data, response, error in - networkTracer?.logAndTrackResponse( - request: request, - response: response, - data: data, - requestId: requestId, - startTime: startTime - ) + tokens.forEach { $0.didReceiveResponse(response, data) } let result = process(data, response, error) - // Log and track error if any - if case let .failure(error) = result { - networkTracer?.logAndTrackError( - request: request, - error: error, - requestId: requestId - ) + if case let .failure(apiError) = result { + tokens.forEach { $0.didFail(apiError) } } completion(result) @@ -93,28 +67,15 @@ extension URLServer { process: @escaping (URL?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionDownloadTask? { - let requestId = UUID().uuidString - let startTime = Date() - - networkTracer?.logAndTrackRequest(request: request, requestId: requestId) + let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } let task = urlSession.downloadTask(with: request) { url, response, error in - networkTracer?.logAndTrackResponse( - request: request, - response: response, - data: nil, - requestId: requestId, - startTime: startTime - ) + tokens.forEach { $0.didReceiveResponse(response, nil) } let result = process(url, response, error) - if case let .failure(error) = result { - networkTracer?.logAndTrackError( - request: request, - error: error, - requestId: requestId - ) + if case let .failure(apiError) = result { + tokens.forEach { $0.didFail(apiError) } } completion(result) @@ -146,3 +107,24 @@ extension URLServer { return .failure(error) } } + +// This hides the specific 'Context' type inside closures. +private struct RequestToken: Sendable { + let didReceiveResponse: @Sendable (URLResponse?, Data?) -> Void + let didFail: @Sendable (Error) -> Void + + // The generic 'T' captures the specific observer type and its associated Context + init(observer: T, request: URLRequest) { + // We generate the context immediately upon initialization + let context = observer.willSendRequest(request) + + // We capture the specific 'observer' and 'context' inside these closures + self.didReceiveResponse = { [weak observer] response, data in + observer?.didReceiveResponse(for: request, response: response, data: data, context: context) + } + + self.didFail = { [weak observer] error in + observer?.didFail(request: request, error: error, context: context) + } + } +} diff --git a/Sources/FTAPIKit/URLServer.swift b/Sources/FTAPIKit/URLServer.swift index bad12dc..4279bc8 100644 --- a/Sources/FTAPIKit/URLServer.swift +++ b/Sources/FTAPIKit/URLServer.swift @@ -1,5 +1,4 @@ import Foundation -import FTNetworkTracer #if os(Linux) import FoundationNetworking @@ -45,16 +44,17 @@ public protocol URLServer: Server where Request == URLRequest { /// - Note: Provided default implementation. var urlSession: URLSession { get } - /// Optional network tracer for request logging and tracking - /// - Note: Provided default implementation returns nil. - var networkTracer: FTNetworkTracer? { get } + /// Array of network observers. + /// Each observer receives lifecycle callbacks for every request. + /// - Note: Provided default implementation returns empty array. + var networkObservers: [any NetworkObserver] { get } } public extension URLServer { var urlSession: URLSession { .shared } var decoding: Decoding { JSONDecoding() } var encoding: Encoding { JSONEncoding() } - var networkTracer: FTNetworkTracer? { nil } + var networkObservers: [any NetworkObserver] { [] } func buildRequest(endpoint: Endpoint) throws -> URLRequest { try buildStandardRequest(endpoint: endpoint) diff --git a/Tests/FTAPIKitTests/Mockups/Analytics.swift b/Tests/FTAPIKitTests/Mockups/Analytics.swift deleted file mode 100644 index 295193d..0000000 --- a/Tests/FTAPIKitTests/Mockups/Analytics.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import FTNetworkTracer - -class MockAnalytics: AnalyticsProtocol { - var requestCount = 0 - var responseCount = 0 - var errorCount = 0 - var lastRequestId: String? - var lastDuration: TimeInterval? - - let configuration: AnalyticsConfiguration = AnalyticsConfiguration( - privacy: .none, - unmaskedHeaders: [], - unmaskedUrlQueries: [], - unmaskedBodyParams: [] - ) - - func track(_ entry: AnalyticEntry) { - switch entry.type { - case .request: - requestCount += 1 - case .response: - responseCount += 1 - case .error: - errorCount += 1 - } - lastRequestId = entry.requestId - lastDuration = entry.duration - } -} diff --git a/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift b/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift new file mode 100644 index 0000000..bf3a948 --- /dev/null +++ b/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift @@ -0,0 +1,33 @@ +import Foundation +import FTAPIKit + +#if os(Linux) +import FoundationNetworking +#endif + +struct MockContext: Sendable { + let requestId: String + let startTime: Date +} + +final class MockNetworkObserver: NetworkObserver, @unchecked Sendable { + var willSendCount = 0 + var didReceiveCount = 0 + var didFailCount = 0 + var lastRequestId: String? + + func willSendRequest(_ request: URLRequest) -> MockContext { + willSendCount += 1 + let context = MockContext(requestId: UUID().uuidString, startTime: Date()) + lastRequestId = context.requestId + return context + } + + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: MockContext) { + didReceiveCount += 1 + } + + func didFail(request: URLRequest, error: Error, context: MockContext) { + didFailCount += 1 + } +} diff --git a/Tests/FTAPIKitTests/Mockups/Servers.swift b/Tests/FTAPIKitTests/Mockups/Servers.swift index 29f7956..b1fdc7c 100644 --- a/Tests/FTAPIKitTests/Mockups/Servers.swift +++ b/Tests/FTAPIKitTests/Mockups/Servers.swift @@ -1,6 +1,5 @@ import Foundation import FTAPIKit -import FTNetworkTracer #if os(Linux) import FoundationNetworking @@ -31,12 +30,12 @@ struct ErrorThrowingServer: URLServer { let baseUri = URL(string: "http://httpbin.org/")! } -struct HTTPBinServerWithTracer: URLServer { +struct HTTPBinServerWithObservers: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! - let networkTracer: FTNetworkTracer? + let networkObservers: [any NetworkObserver] - init(tracer: FTNetworkTracer?) { - self.networkTracer = tracer + init(observers: [any NetworkObserver] = []) { + self.networkObservers = observers } } diff --git a/Tests/FTAPIKitTests/NetworkObserverTests.swift b/Tests/FTAPIKitTests/NetworkObserverTests.swift new file mode 100644 index 0000000..734eda1 --- /dev/null +++ b/Tests/FTAPIKitTests/NetworkObserverTests.swift @@ -0,0 +1,94 @@ +import FTAPIKit +import XCTest + +#if os(Linux) +import FoundationNetworking +#endif + +final class NetworkObserverTests: XCTestCase { + private let timeout: TimeInterval = 30.0 + + // MARK: - Unit Tests (no network required) + + func testObserverIsCalledForRequest() { + let mockObserver = MockNetworkObserver() + let server = HTTPBinServerWithObservers(observers: [mockObserver]) + + XCTAssertEqual(server.networkObservers.count, 1, "NetworkObservers should contain one observer") + } + + func testEmptyObserversDoesNotCauseIssues() { + let server = HTTPBinServer() // Default observers is empty array + let endpoint = GetEndpoint() + + // Verify empty observers doesn't cause problems during request building + XCTAssertNoThrow(try server.buildRequest(endpoint: endpoint)) + XCTAssertTrue(server.networkObservers.isEmpty, "Default networkObservers should be empty") + } + + func testMultipleObserversSupported() { + let observer1 = MockNetworkObserver() + let observer2 = MockNetworkObserver() + let server = HTTPBinServerWithObservers(observers: [observer1, observer2]) + + XCTAssertEqual(server.networkObservers.count, 2, "Should support multiple observers") + } + + // MARK: - Integration Tests (requires network) + // Note: These tests may fail if httpbin.org is unavailable + + func testObserverReceivesLifecycleCallbacks() { + let mockObserver = MockNetworkObserver() + let server = HTTPBinServerWithObservers(observers: [mockObserver]) + let endpoint = GetEndpoint() + let expectation = self.expectation(description: "Request completed") + + server.call(endpoint: endpoint) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + + XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") + // didReceiveResponse is always called; didFail is called additionally on failure + XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always be called") + } + + func testObserverLogsFailedRequest() { + let mockObserver = MockNetworkObserver() + let server = HTTPBinServerWithObservers(observers: [mockObserver]) + let endpoint = NotFoundEndpoint() + let expectation = self.expectation(description: "Result") + + server.call(endpoint: endpoint) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + + // didReceiveResponse is always called with raw data; didFail is called additionally on failure + XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") + XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always be called") + XCTAssertEqual(mockObserver.didFailCount, 1, "didFail should be called on failure") + } + + func testMultipleObserversAllReceiveCallbacks() { + let observer1 = MockNetworkObserver() + let observer2 = MockNetworkObserver() + let server = HTTPBinServerWithObservers(observers: [observer1, observer2]) + let endpoint = GetEndpoint() + let expectation = self.expectation(description: "Request completed") + + server.call(endpoint: endpoint) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + + // Both observers should receive callbacks + XCTAssertEqual(observer1.willSendCount, 1, "Observer 1 willSendRequest should be called") + XCTAssertEqual(observer2.willSendCount, 1, "Observer 2 willSendRequest should be called") + XCTAssertEqual(observer1.didReceiveCount, 1, "Observer 1 didReceiveResponse should be called") + XCTAssertEqual(observer2.didReceiveCount, 1, "Observer 2 didReceiveResponse should be called") + } +} diff --git a/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift b/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift deleted file mode 100644 index c9c22c8..0000000 --- a/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import FTAPIKit -import FTNetworkTracer -import XCTest - -#if os(Linux) -import FoundationNetworking -#endif - -final class NetworkTracerIntegrationTests: XCTestCase { - private let timeout: TimeInterval = 30.0 - - // MARK: - Unit Tests (no network required) - - func testTracerIsCalledForRequest() { - let mockAnalytics = MockAnalytics() - let tracer = FTNetworkTracer(logger: nil, analytics: mockAnalytics) - let server = HTTPBinServerWithTracer(tracer: tracer) - let endpoint = GetEndpoint() - - // Build request to verify tracer integration - _ = try? server.buildRequest(endpoint: endpoint) - - // Note: Just building request doesn't trigger logging, - // but this verifies the tracer property is properly integrated - XCTAssertNotNil(server.networkTracer, "NetworkTracer should be set") - } - - func testNilTracerDoesNotCauseIssues() { - let server = HTTPBinServer() // Default tracer is nil - let endpoint = GetEndpoint() - - // Verify nil tracer doesn't cause problems during request building - XCTAssertNoThrow(try server.buildRequest(endpoint: endpoint)) - XCTAssertNil(server.networkTracer, "Default networkTracer should be nil") - } - - func testMockAnalyticsTracking() { - let mockAnalytics = MockAnalytics() - let analyticEntry = AnalyticEntry( - type: .request(method: "GET", url: "https://test.com"), - headers: [:], - body: nil, - duration: nil, - requestId: "test-123", - configuration: mockAnalytics.configuration - ) - - mockAnalytics.track(analyticEntry) - - XCTAssertEqual(mockAnalytics.requestCount, 1) - XCTAssertEqual(mockAnalytics.lastRequestId, "test-123") - } - - // MARK: - Integration Tests (requires network) - // Note: These tests may fail if httpbin.org is unavailable - - func testTracerLogsFailedRequest() { - let mockAnalytics = MockAnalytics() - let tracer = FTNetworkTracer(logger: nil, analytics: mockAnalytics) - let server = HTTPBinServerWithTracer(tracer: tracer) - let endpoint = NotFoundEndpoint() - let expectation = self.expectation(description: "Result") - - server.call(endpoint: endpoint) { _ in - expectation.fulfill() - } - - wait(for: [expectation], timeout: timeout) - - // Verify tracer was called (request is always logged, even on failure) - XCTAssertEqual(mockAnalytics.requestCount, 1, "Request should be logged once") - XCTAssertGreaterThanOrEqual(mockAnalytics.responseCount + mockAnalytics.errorCount, 1, - "Either response or error should be logged") - } -}